// ==UserScript== // @name 网易云音乐显示完整歌单 // @namespace https://github.com/nondanee // @version 1.4.6 // @description 解除歌单歌曲展示数量限制 & 播放列表 1000 首上限 // @author nondanee // @match *://music.163.com/* // @icon https://s1.music.126.net/style/favicon.ico // @grant none // @run-at document-start // @downloadURL none // ==/UserScript== (() => { if (window.top === window.self) { try { const observer = new MutationObserver(() => { document.querySelector('#g_iframe').contentWindow.dispatchEvent(new Event('songchange')) }) observer.observe(document.querySelector('.m-playbar .words'), { childList: true }) } catch (_) {} return } const locate = (object, pattern) => { for (const key in object) { const value = object[key] const type = typeof value if (!Object.prototype.hasOwnProperty.call(object, key) || !value) continue switch (typeof value) { case 'function': { if (String(value).match(pattern)) return [key] break } case 'object': { const path = locate(value, pattern) if (path) return [key].concat(path) break } } } } const findMethod = (object, pattern) => { const path = locate(object, pattern) if (!path) throw new Error('MethodNotFound') let poiner = object const last = path.pop() path.forEach(key => poiner = poiner[key]) const origin = poiner[last] return { origin, override: (value) => { value.toString = () => origin.toString() poiner[last] = value } } } const cloneEvent = (event) => { const copy = new event.constructor(event.type, event) copy.target = event.target return copy } const normalize = song => { song = { ...song, ...song.privilege } return { ...song, album: song.al, alias: song.alia || song.ala || [], artists: song.ar || [], commentThreadId: `R_SO_4_${song.id}`, copyrightId: song.cp, duration: song.dt, mvid: song.mv, position: song.no, ringtone: song.rt, status: song.st, pstatus: song.pst, version: song.v, songType: song.t, score: song.pop, transNames: song.tns || [], privilege: song.privilege, lyrics: song.lyrics } } const zFill = (string = '', length = 2) => { string = String(string) while (string.length < length) string = '0' + string return string } const formatDuration = duration => { const oneSecond = 1e3 const oneMinute = 60 * oneSecond const result = [] Array(oneMinute, oneSecond) .reduce((remain, unit) => { const value = Math.floor(remain / unit) result.push(value) return remain - value * unit }, duration || 0) return result .map(value => zFill(value, 2)) .join(':') } const TYPE = { SONG: '18', PLAYLIST: '13', } const CACHE = window.COMPLETE_PLAYLIST_CACHE = { [TYPE.SONG]: {}, [TYPE.PLAYLIST]: {} } const interceptRequest = () => { const request = findMethod(window.nej, '\\.replace\\("api","weapi') const Fetch = (url, options) => ( new Promise((resolve, reject) => request.origin(url, { ...options, cookie: true, method: 'GET', onerror: reject, onload: resolve, type: 'json' }) ) ) window.getPlaylistDetail = async (url, options) => { // const search = new URLSearchParams(options.data) // search.set('n', 0) // options.data = search.toString() const data = await Fetch(url, options) const slice = 1000 const trackIds = (data.playlist || {}).trackIds || [] const tracks = (data.playlist || {}).tracks || [] if (!trackIds.length || trackIds.length === tracks.length) return data const missingTrackIds = trackIds.slice(tracks.length) const round = Math.ceil(missingTrackIds.length / slice) const result = await Promise.all( Array(round).fill().map((_, index) => { const part = missingTrackIds.slice(index * slice).slice(0, slice).map(({ id }) => ({ id })) return Fetch('/api/v3/song/detail', { data: `c=${JSON.stringify(part)}` }) }) ) const songMap = {} const privilegeMap = {} result.forEach(({ songs, privileges }) => { songs.forEach(_ => songMap[_.id] = _) privileges.forEach(_ => privilegeMap[_.id] = _) }) const missingTracks = missingTrackIds .map(({ id }) => ({ ...songMap[id], privilege: privilegeMap[id] })) const missPrivileges = missingTracks .map(({ id }) => privilegeMap[id]) data.playlist.tracks = tracks.concat(missingTracks) data.privileges = (data.privileges || []).concat(missPrivileges) CACHE[TYPE.PLAYLIST][data.playlist.id] = data.playlist.tracks .map(song => CACHE[TYPE.SONG][song.id] = normalize(song)) return data } const overrideRequest = async (url, options) => { if (/\/playlist\/detail/.test(url)) { const { onload, onerror } = options return window.getPlaylistDetail(url, options).then(onload).catch(onerror) } return request.origin(url, options) } request.override(overrideRequest) } const handleSongChange = () => { try { const { track } = window.top.player.getPlaying() const { id, source, program } = track if (program) return const base = 'span.ply' const attrs = `[data-res-id="${id}"][data-res-type="${TYPE.SONG}"]` // player.addTo() 相同 id 不同 source 会被过滤 // const { fid, fdata } = source // if (String(fid) !== TYPE.PLAYLIST) return // const attrs = `[data-res-id="${id}"][data-res-from="${fid}"][data-res-data="${fdata}"]` document.querySelectorAll(base).forEach(node => { node.classList.remove('ply-z-slt') }) document.querySelectorAll(base + attrs).forEach(node => { node.classList.add('ply-z-slt') }) } catch (_) {} } const escapeHTML = string => ( string.replace( /[&<>'"]/g, word => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', })[word] || word ) ) const bindEvent = () => { const ACTIONS = new Set(['play', 'addto']) const onClick = (event) => { const { resAction, resId, resType, resData, } = event.target.dataset event.stopPropagation() if (!ACTIONS.has(resAction)) { // 没有 privilege 冒泡后会报错 document.body.dispatchEvent(cloneEvent(event)) return } const data = (CACHE[resType] || {})[resId] if (!data) return const playlistId = Number(resType === TYPE.PLAYLIST ? resId : resData) const list = (Array.isArray(data) ? data : [data]) .map(song => ({ ...song, source: { fdata: playlistId, fid: TYPE.PLAYLIST, link: `/playlist?id=${playlistId}&_hash=songlist-${song.id}`, title: '歌单', }, })) window.top.player.addTo( list, resAction === 'play' && resType === TYPE.PLAYLIST, resAction === 'play' ) } const body = document.querySelector('table tbody') const play = document.querySelector('#content-operation .u-btni-addply') const add = document.querySelector('#content-operation .u-btni-add') if (play) play.addEventListener('click', onClick) if (add) add.addEventListener('click', onClick) if (body) body.addEventListener('click', onClick) } const completePlaylist = async (id) => { const render = (song, index, playlist) => { const { album, artists, status, duration } = song const deletable = playlist.creator.userId === window.GUser.userId const durationText = formatDuration(duration) const artistText = artists.map(({ name }) => escapeHTML(name)).join('/') const annotation = escapeHTML(song.transNames[0] || song.alias[0] || '') const albumName = escapeHTML(album.name) const songName = escapeHTML(song.name) return `
 ${index + 1}
${songName} ${annotation ? `${annotation ? ` - (${annotation})` : ''}` : ''} ${song.mvid ? `MV` : ''}
${durationText}
分享 ${deletable ? `删除` : ''}
${artists.map(({ id, name }) => `${escapeHTML(name)}`).join('/')}
${albumName}
` } const seeMore = document.querySelector('.m-playlist-see-more') if (seeMore) seeMore.innerHTML = '
更多内容加载中...
' const data = await window.getPlaylistDetail( '/api/v6/playlist/detail/', { data: `id=${id}&offset=0&total=true&limit=1000&n=1000` } ) const { playlist } = data const content = playlist.tracks .map((song, index) => render(normalize(song), index, playlist)) .join('') const body = document.querySelector('table tbody') if (body) body.innerHTML = content bindEvent() handleSongChange() if (seeMore) seeMore.parentNode.removeChild(seeMore) } const handleRoute = () => { const { href, search } = location if (/\/my\//.test(href)) return const id = new URLSearchParams(search).get('id') if (/playlist[/?]/.test(href) && id) completePlaylist(id) } const handleLoad = () => { interceptRequest() handleRoute() } window.addEventListener('songchange', handleSongChange) window.addEventListener('load', handleLoad, false) window.addEventListener('hashchange', handleRoute, false) })()