// ==UserScript==
// @name 网易云音乐显示完整歌单
// @namespace https://github.com/nondanee
// @version 1.2.3
// @description 解除歌单歌曲展示数量限制 & 播放列表 1000 首上限
// @author nondanee
// @match https://music.163.com/*
// @grant none
// ==/UserScript==
(() => {
if (window.top === window.self) return
const search = (object, pattern) => {
let result = null
Object.keys(object)
.some(key => {
if (!object[key]) return
else if (typeof object[key] === 'function') {
result = String(object[key]).match(pattern) ? [key] : null
}
else if (typeof object[key] === 'object') {
const chain = search(object[key], pattern)
result = chain ? [key].concat(chain) : null
}
return !!result
})
return result
}
const escapeHTML = string =>
string.replace(
/[&<>'"]/g,
word =>
({
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"',
})[word] || word
)
const attach = (object, path, property) => {
path = (path || []).slice()
let poiner = object
const last = path.pop()
path.forEach(key => {
if (!(key in poiner)) throw new Error('KeyError')
poiner = poiner[key]
})
return property ? poiner[last] = property : poiner[last]
}
const skipEventListener = (element, type, skip) => {
const entry = Array(skip).fill(null)
.reduce(pointer => pointer.parentNode || {}, element)
element.addEventListener(type, event => {
event.stopImmediatePropagation()
const target = event.target.cloneNode(false)
target.style.display = 'none'
entry.parentNode.appendChild(target)
target.click()
entry.parentNode.removeChild(target)
})
}
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 showDuration = time => {
const pad = number => number < 10 ? '0' + number : number
time = parseInt(time / 1000)
const minute = parseInt(time / 60)
const second = time % 60
return [pad(minute), pad(second)].join(':')
}
const hijackRequest = () => {
const location = search(window.nej || {}, '\\.replace\\("api","weapi')
const originRequest = attach(window.nej || {}, location)
const simpleRequest = (url, options = {}) =>
new Promise((resolve, reject) =>
originRequest(url, {
...options,
cookie: true,
method: 'GET',
onerror: reject,
onload: resolve,
type: 'json'
})
)
const mapify = list => list.reduce((output, item) => ({ ...output, [item.id]: item }), {})
window.scriptCache = {
playlist: {},
song: {},
}
window.playlistDetail = async (url, id, origin) => {
const capacity = 1000
const data = await simpleRequest(url, { data: `id=${id}&n=${origin ? 1000 : 0}` })
const trackIds = (data.playlist || {}).trackIds || []
const tracks = (data.playlist || {}).tracks || []
if (!trackIds.length || trackIds.length === tracks.length) return data
if (origin) return data
const batch = Math.ceil(trackIds.length / capacity)
const result = await Promise.all(
Array.from(Array(batch).keys())
.map(index => trackIds.slice(index * capacity).slice(0, capacity).map(({ id }) => ({ id })))
.map(part => simpleRequest('/api/v3/song/detail', { data: `c=${JSON.stringify(part)}` }))
)
const songMap = mapify(Array.prototype.concat.apply([], result.map(({ songs }) => songs)))
const privilegeMap = mapify(Array.prototype.concat.apply([], result.map(({ privileges }) => privileges)))
data.playlist.tracks = trackIds
.map(({ id }) => songMap[id] ? { ...songMap[id], privilege: privilegeMap[id] } : null)
.filter(song => song)
data.privileges = data.playlist.tracks
.map(({ id }) => privilegeMap[id])
window.scriptCache.playlist[id] = data.playlist.tracks
.map(song => window.scriptCache.song[song.id] = normalize(song))
return data
}
const overrideRequest = (url, options) => {
if (url.includes('/playlist/detail')) {
const data = new URLSearchParams(options.data)
const { onload, onerror } = options
window.playlistDetail(url, data.get('id'), true).then(onload).catch(onerror)
}
else {
originRequest(url, options)
}
}
attach(window.nej, location, overrideRequest)
}
const completePlaylist = () => {
const render = (song, index, playlist) => {
const { album, artists, status, duration } = song
const deletable = playlist.creator.userId === window.GUser.userId
const durationText = showDuration(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-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 playlistId = (window.location.href.match(/playlist\?id=(\d+)/) || [])[1]
const action = async () => {
const seeMore = document.querySelector('.m-playlist-see-more')
if (seeMore) seeMore.innerHTML = '<div class="text">更多内容加载中...</div>'
const data = await window.playlistDetail('/api/v6/playlist/detail', playlistId)
const { playlist } = data
const content = playlist.tracks.map((song, index) => render(normalize(song), index, playlist)).join('')
const replace = () => {
document.querySelector('table tbody').innerHTML = content
proxyAction()
seeMore && seeMore.parentNode.removeChild(seeMore)
}
if (document.querySelector('table'))
replace()
else
waitChange(replace, document.querySelector('.g-mn3.f-pr.j-flag .f-pr'))
}
if (playlistId) action()
}
const waitChange = (action, element) => {
let observer = null
const handler = () => {
action()
observer && observer.disconnect()
}
observer = new MutationObserver(handler)
observer.observe(element, { childList: true, attributes: true, subtree: 'true' })
}
const proxyAction = (table) => {
const targetAction = new Set(['play', 'addto'])
const typeMap = { song: '18', playlist: '13' }
const handler = (event, type) => {
const { resType, resAction, resId, resFrom, resData } = event.target.dataset
if (resAction === 'delete') return waitChange(completePlaylist, document.querySelector('.g-mn3.f-pr.j-flag .f-pr'))
if (typeMap[type] !== resType || !targetAction.has(resAction)) return
const list = ((window.scriptCache || {})[type] || {})[resId]
if (!list) return
event.stopPropagation()
window.top.player.addTo(
Array.isArray(list) ? list : [list],
resAction === 'play' && type === 'playlist',
resAction === 'play'
)
}
const tableBody = document.querySelector('table tbody')
tableBody && tableBody.addEventListener('click', event => handler(event, 'song'))
const operationElement = document.querySelector('#content-operation') || document.querySelector('#flag_play_addto_btn_wrapper')
const contentPlay = operationElement && operationElement.querySelector('.u-btni-addply')
const contentAdd = operationElement && operationElement.querySelector('.u-btni-add')
contentPlay && contentPlay.addEventListener('click', event => handler(event, 'playlist'))
contentAdd && contentAdd.addEventListener('click', event => handler(event, 'playlist'))
const tableWrap = document.querySelector('table')
tableWrap && skipEventListener(tableWrap, 'click', 3) // default listener throw an error
}
hijackRequest()
window.addEventListener('load', completePlaylist, false)
window.addEventListener('hashchange', completePlaylist, false)
})()