// ==UserScript==
// @name 网易云音乐显示完整歌单
// @namespace https://github.com/nondanee
// @version 1.4.13
// @description 解除歌单歌曲展示数量限制 & 播放列表 1000 首上限
// @author nondanee
// @match *://music.163.com/*
// @icon https://s1.music.126.net/style/favicon.ico
// @grant none
// @run-at document-start
// ==/UserScript==
(() => {
if (window.top === window.self) {
const observe = () => {
try {
const callback = () => document.contentFrame.dispatchEvent(new Event('songchange'))
const observer = new MutationObserver(callback)
observer.observe(document.querySelector('.m-playbar .words'), { childList: true })
} catch (_) {}
}
window.addEventListener('load', observe, false)
return
}
const locate = (object, pattern) => {
for (const key in object) {
const value = object[key]
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 // 有问题
Object.defineProperty(copy, 'target', { value: 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 = () => {
if (window.getPlaylistDetail) return
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
const data = (CACHE[resType] || {})[resId]
if (!data) return
event.stopPropagation()
if (!ACTIONS.has(resAction)) {
// 没有 privilege 冒泡后会报错
document.body.dispatchEvent(cloneEvent(event))
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 `
<tr id="${song.id}${Date.now()}" class="${index % 2 ? '' : 'even'} ${status ? 'js-dis' : ''}">
<td class="left">
<div class="hd "><span data-res-id="${song.id}" data-res-type="18" data-res-action="play" data-res-from="13" data-res-data="${playlist.id}" class="ply "> </span><span class="num">${index + 1}</span></div>
</td>
<td>
<div class="f-cb">
<div class="tt">
<div class="ttc">
<span class="txt">
<a href="#/song?id=${song.id}"><b title="${songName}${annotation ? ` - (${annotation})` : ''}">${songName}</b></a>
${annotation ? `<span title="${annotation}" class="s-fc8">${annotation ? ` - (${annotation})` : ''}</span>` : ''}
${song.mvid ? `<a href="#/mv?id=${song.mvid}" title="播放mv" class="mv">MV</a>` : ''}
</span>
</div>
</div>
</div>
</td>
<td class=" s-fc3">
<span class="u-dur candel">${durationText}</span>
<div class="opt hshow">
<a class="u-icn u-icn-81 icn-add" href="javascript:;" title="添加到播放列表" hidefocus="true" data-res-type="18" data-res-id="${song.id}" data-res-action="addto" data-res-from="13" data-res-data="${playlist.id}"></a>
<span data-res-id="${song.id}" data-res-type="18" data-res-action="fav" class="icn icn-fav" title="收藏"></span>
<span data-res-id="${song.id}" data-res-type="18" data-res-action="share" data-res-name="${albumName}" data-res-author="${artistText}" data-res-pic="${album.picUrl}" class="icn icn-share" title="分享">分享</span>
<span data-res-id="${song.id}" data-res-type="18" data-res-action="download" class="icn icn-dl" title="下载"></span>
${deletable ? `<span data-res-id="${song.id}" data-res-type="18" data-res-from="13" data-res-data="${playlist.id}" data-res-action="delete" class="icn icn-del" title="删除">删除</span>` : ''}
</div>
</td>
<td>
<div class="text" title="${artistText}">
<span title="${artistText}">
${artists.map(({ id, name }) => `<a href="#/artist?id=${id}" hidefocus="true">${escapeHTML(name)}</a>`).join('/')}
</span>
</div>
</td>
<td>
<div class="text">
<a href="#/album?id=${album.id}" title="${albumName}">${albumName}</a>
</div>
</td>
</tr>
`
}
const seeMore = document.querySelector('.m-playlist-see-more')
if (seeMore) seeMore.innerHTML = '<div class="text">更多内容加载中...</div>'
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 = () => {
interceptRequest()
const { href, search } = location
if (/\/my\//.test(href)) return
const id = new URLSearchParams(search).get('id')
if (/playlist[/?]/.test(href) && id) completePlaylist(id)
}
window.addEventListener('songchange', handleSongChange)
window.addEventListener('load', handleRoute, false)
window.addEventListener('hashchange', handleRoute, false)
})()