import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test, { after } from 'node:test'; const api = fileURLToPath(new URL('../scripts/netease-api.mjs', import.meta.url)); const shell = fileURLToPath(new URL('../scripts/netease-music.sh', import.meta.url)); const fake = fileURLToPath(new URL('./fixtures/api-fetch.mjs', import.meta.url)); const mpv = fileURLToPath(new URL('../scripts/mpv-ctl.mjs', import.meta.url)); // 所有临时文件、配置、队列都限制在技能目录;播放器与后台保活均用离线替身。 const runtime = mkdtempSync(fileURLToPath(new URL('./.runtime-', import.meta.url))); after(() => rmSync(runtime, { recursive: true, force: true })); mkdirSync(join(runtime, 'bin')); writeFileSync(join(runtime, 'bin/nohup'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); Object.assign(process.env, { TMPDIR: runtime, PI_AGENT_DIR: runtime, NETEASE_MPV_DIR: join(runtime, 'mpv'), NETEASE_TEST_API_CALLS: '', NETEASE_TEST_MPV_STATE: '', NETEASE_TEST_SCENARIO: '', PYTHONDONTWRITEBYTECODE: '1', PATH: `${join(runtime, 'bin')}:${process.env.PATH}`, }); function run(args, scenario = '') { const p = spawnSync(process.execPath, ['--import', fake, api, ...args], { encoding: 'utf8', env: { ...process.env, NETEASE_COOKIE: '', NETEASE_TEST_SCENARIO: scenario }, }); assert.equal(p.status, 0, p.stderr); return { out: JSON.parse(p.stdout), calls: p.stderr.trim().split('\n').filter(Boolean).map((s) => JSON.parse(s)) }; } const cases = [ { cmd: 'playlist', arg: '1', key: 'songs', total: 507 }, { cmd: 'liked', key: 'songs', total: 507, ids: [1503, 1502, 1501] }, { cmd: 'album', arg: '1', key: 'songs', total: 7 }, { cmd: 'artist', arg: '1', key: 'songs', total: 7 }, { cmd: 'chart', arg: '1', key: 'songs', total: 507 }, { cmd: 'search', arg: '关键词', key: 'songs', total: 7, path: 'search/get/web' }, { cmd: 'playlists', arg: '关键词', key: 'playlists', total: 7, path: 'cloudsearch/get/web' }, { cmd: 'charts', key: 'charts', total: 7 }, { cmd: 'myalbums', key: 'albums', total: 7, path: 'album/sublist' }, { cmd: 'myartists', key: 'artists', total: 7, path: 'artist/sublist' }, { cmd: 'recent', key: 'songs', total: 7 }, { cmd: 'podcasts', key: 'podcasts', total: 7 }, { cmd: 'podcast', arg: '1', key: 'songs', total: 7, path: 'dj/program/byradio' }, { cmd: 'myplaylists', key: 'playlists', total: 7 }, { cmd: 'albums', arg: '1', key: 'albums', total: 7, path: 'artist/albums/1' }, { cmd: 'daily', key: 'songs', total: 7 }, ]; for (const c of cases) { test(`${c.cmd}: 默认第一页、下一页、越界空页及 total`, () => { const args = [c.cmd, ...(c.arg ? [c.arg] : []), '--limit', '3']; const first = run(args).out; assert.equal(first.count, 3); assert.equal(first.total, c.total); if (c.key === 'songs') for (const s of first.songs) assert.equal(s.cover, ''); const { out, calls } = run([...args, '--offset', '3']); assert.equal(out.count, out[c.key].length); assert.equal(out.total, c.total); assert.deepEqual(out[c.key].map((s) => s.id), c.ids || [1003, 1004, 1005]); assert.ok(!first[c.key].some((a) => out[c.key].some((b) => a.id === b.id))); if (c.path) { const request = calls.find((r) => r.path === c.path).data; assert.equal(+request.offset, 3); assert.equal(+request.limit, 3); } const last = run([...args, '--offset', '10000']); assert.equal(last.out.count, 0); assert.deepEqual(last.out[c.key], []); assert.equal(last.out.total, c.total); if (['playlist', 'liked', 'chart'].includes(c.cmd)) assert.ok(!last.calls.some((r) => r.path === 'v3/song/detail')); }); } test('歌单超过 500 首仍可分页;详情只查询本页,原有顺序不丢', () => { const { out, calls } = run(['playlist', '1', '--limit', '3', '--offset', '500']); assert.equal(out.total, 507); assert.deepEqual(out.songs.map((s) => s.id), [1500, 1501, 1502]); assert.deepEqual(JSON.parse(calls.find((r) => r.path === 'v3/song/detail').data.c), [{ id: 1500 }, { id: 1501 }, { id: 1502 }]); }); test('空库也能翻过末尾,total 为 0', () => { for (const c of cases.filter((c) => c.cmd !== 'liked')) { const { out } = run([c.cmd, ...(c.arg ? [c.arg] : []), '--limit', '3', '--offset', '3'], 'empty'); assert.equal(out.count, 0, c.cmd); assert.equal(out.total, 0, c.cmd); assert.deepEqual(out[c.key], [], c.cmd); } }); test('缺总数字段时用接口返回的数量,缺详情仍保留真正 total', () => { for (const c of cases.filter((c) => c.path || c.cmd === 'album' || c.cmd === 'playlist')) { const args = [c.cmd, ...(c.arg ? [c.arg] : []), '--limit', '3', '--offset', '3']; assert.equal(run(args, 'no-total').out.total, c.cmd === 'album' ? 7 : 3, c.cmd); } const { out } = run(['playlist', '1', '--limit', '3', '--offset', '3'], 'missing-song'); assert.equal(out.total, 507); assert.equal(out.count, 2); assert.deepEqual(out.songs.map((s) => s.id), [1003, 1005]); assert.deepEqual(run(['playlist', '1', '--limit', '3', '--offset', '3'], 'no-track-ids').out.songs, []); }); test('最近播放和播客过滤缺失歌曲时 count 只数实际返回行', () => { for (const cmd of ['recent', 'podcast']) { const { out } = run([cmd, ...(cmd === 'podcast' ? ['1'] : []), '--limit', '3', '--offset', '3'], 'missing-song'); assert.equal(out.count, 2); assert.equal(out.total, 7); } }); test('推荐播客忽略上游 offset:拉前缀后本地截页,total 回退到前缀长度', () => { const { out, calls } = run(['podcasts', '--recommend', '--limit', '3', '--offset', '3']); assert.equal(out.count, 3); assert.equal(out.total, 6); assert.deepEqual(out.podcasts.map((p) => p.id), [1003, 1004, 1005]); assert.equal(calls[0].data.limit, 6); assert.equal(calls[0].data.offset, 3); assert.deepEqual(run(['podcasts', '--recommend', '--limit', '3', '--offset', '10000']).out.podcasts, []); }); test('歌单搜索回退时保留 limit/offset/total;末尾不更换搜索索引', () => { const { out, calls } = run(['playlists', '关键词', '--limit', '3', '--offset', '3'], 'search-fallback'); assert.equal(out.total, 7); assert.deepEqual(out.playlists.map((p) => p.id), [1003, 1004, 1005]); assert.equal(calls.length, 2); for (const { data } of calls) assert.equal(+data.offset, 3); assert.equal(run(['playlists', '关键词', '--limit', '3', '--offset', '10000']).calls.length, 1); }); test('歌手热门旧接口回退也分页,不把全部作品数当热门数', () => { const { out } = run(['artist', '1', '--limit', '3', '--offset', '3'], 'artist-fallback'); assert.equal(out.total, 7); assert.deepEqual(out.songs.map((s) => s.id), [1003, 1004, 1005]); }); test('非法/缺失 offset 在请求前报 usage', () => { for (const value of ['-1', '1.5', 'no', 'Infinity', '9007199254740992', undefined]) { const p = spawnSync(process.execPath, ['--import', fake, api, 'charts', '--offset', ...(value ? [value] : [])], { encoding: 'utf8' }); assert.equal(p.status, 2, p.stderr); assert.equal(JSON.parse(p.stderr).error, 'usage'); } }); test('只读 shell 入口转发 limit/offset,JSON 带 total,人读样式不增加字段', () => { const env = { ...process.env, NODE_OPTIONS: `--import=${fake}`, PI_SKILL_NETEASE_MUSIC_COOKIE: 'test', PI_SKILL_NETEASE_MUSIC_QUALITY: 'lossless' }; const args = [shell, 'myplaylists', '--limit', '3', '--offset', '3']; const human = spawnSync('bash', args, { encoding: 'utf8', env }); assert.equal(human.status, 0, human.stderr); assert.match(human.stdout, /^我的歌单(3 张)\ncount=3\nsource=api\nas_of=/); assert.doesNotMatch(human.stdout, /total=/); const ids = human.stdout.split('\n').filter((s) => /^ \d/.test(s)).map((s) => +s.trim().split(' ')[0]); assert.deepEqual(ids, [1003, 1004, 1005]); const json = spawnSync('bash', [...args, '--json'], { encoding: 'utf8', env }); assert.equal(json.status, 0, json.stderr); assert.equal(JSON.parse(json.stdout).total, '7'); assert.equal(JSON.parse(json.stdout).count, '3'); }); const coverUrl = 'https://artwork.invalid/cover.png'; const coverBytes = Buffer.from('89504e4700ff1080', 'hex'); function coverDir(t) { const dir = mkdtempSync(fileURLToPath(new URL('./.cover-test-', import.meta.url))); t.after(() => rmSync(dir, { recursive: true, force: true })); return dir; } function coverShell(args, scenario = 'cover', extraEnv = {}) { return spawnSync('bash', [shell, 'cover', ...args], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: `--import=${fake}`, NETEASE_TEST_SCENARIO: scenario, PI_SKILL_NETEASE_MUSIC_COOKIE: 'test', PI_SKILL_NETEASE_MUSIC_QUALITY: 'lossless', ...extraEnv, }, }); } function coverError(p, status, error) { assert.equal(p.status, status, p.stderr); assert.equal(p.stdout, ''); const lines = p.stderr.trim().split('\n').map((s) => JSON.parse(s)); assert.equal(lines.at(-1).error, error, p.stderr); assert.equal(typeof lines.at(-1).hint, 'string'); return lines.slice(0, -1); } test('歌曲映射保留既有字段顺序,追加 cover 并按 al / album / picUrl 回退', () => { for (const [scenario, cover] of [['cover', coverUrl], ['cover-album', `${coverUrl}?album`], ['cover-song', `${coverUrl}?song`], ['', '']]) { const { out } = run(['song', '1000'], scenario); assert.deepEqual(Object.keys(out), ['id', 'name', 'artist', 'album', 'ms', 'vip', 'cover']); assert.deepEqual(out, { id: 1000, name: '歌曲 1000', artist: '歌手', album: '专辑', ms: 180000, vip: false, cover }); const search = run(['search', '关键词', '--limit', '1'], scenario).out; assert.equal(search.songs[0].cover, cover); } }); test('cover 按 id / 关键词返回单个 JSON;搜索缺封面时只补查选中的歌曲', () => { for (const [arg, scenario] of [['1000', 'cover'], ['关键词', 'cover'], ['关键词', 'cover-search-no-art']]) { const p = coverShell([arg], scenario); assert.equal(p.status, 0, p.stderr); assert.deepEqual(JSON.parse(p.stdout), { id: 1000, name: '歌曲 1000', artist: '歌手', cover: coverUrl }); const calls = p.stderr.trim().split('\n').map((s) => JSON.parse(s)); assert.deepEqual(calls.map((c) => c.path), arg === '1000' ? ['v3/song/detail'] : scenario === 'cover-search-no-art' ? ['search/get/web', 'v3/song/detail'] : ['search/get/web']); if (scenario === 'cover-search-no-art') assert.deepEqual(JSON.parse(calls[1].data.c), [{ id: 1000 }]); } }); test('cover --download 保存原始字节和实际大小,查询/下载不改队列或登录文件', (t) => { const dir = coverDir(t); const state = join(dir, 'local/skills/netease-music'); mkdirSync(state, { recursive: true }); const originals = { 'config.json': '{"cookie":"MUSIC_U=fixture","quality":"lossless"}', 'queue.json': '[{"id":42}]', 'queue.m3u8': '#EXTM3U\nexisting\n', }; for (const [name, contents] of Object.entries(originals)) writeFileSync(join(state, name), contents); const env = { PI_AGENT_DIR: dir, PI_SKILL_NETEASE_MUSIC_COOKIE: '', PI_SKILL_NETEASE_MUSIC_QUALITY: '' }; const query = coverShell(['1000', '--json'], 'cover', env); assert.equal(query.status, 0, query.stderr); assert.deepEqual(JSON.parse(query.stdout), { id: 1000, name: '歌曲 1000', artist: '歌手', cover: coverUrl }); const file = join(dir, '封面 bytes.png'); const p = coverShell(['1000', '--download', file], 'cover', env); assert.equal(p.status, 0, p.stderr); assert.deepEqual(JSON.parse(p.stdout), { id: 1000, cover: coverUrl, file, bytes: coverBytes.length }); assert.deepEqual(readFileSync(file), coverBytes); assert.deepEqual(readdirSync(state).sort(), Object.keys(originals).sort()); for (const [name, contents] of Object.entries(originals)) assert.equal(readFileSync(join(state, name), 'utf8'), contents); assert.deepEqual(readdirSync(dir).sort(), ['local', '封面 bytes.png']); }); test('cover 下载拒绝覆盖已有文件,--force 才允许替换', (t) => { const file = join(coverDir(t), 'cover.png'); writeFileSync(file, 'existing image'); coverError(coverShell(['1000', '--download', file]), 2, 'usage'); assert.equal(readFileSync(file, 'utf8'), 'existing image'); const p = coverShell(['1000', '--download', file, '--force']); assert.equal(p.status, 0, p.stderr); assert.deepEqual(readFileSync(file), coverBytes); assert.deepEqual(JSON.parse(p.stdout), { id: 1000, cover: coverUrl, file, bytes: coverBytes.length }); }); test('cover 缺歌曲/封面时返回 notfound JSON,不下载、不落盘', (t) => { const file = join(coverDir(t), 'cover.png'); for (const scenario of ['cover-no-song', 'cover-missing']) { for (const arg of ['1000', '关键词']) { const calls = coverError(coverShell([arg, '--download', file], scenario), 4, 'notfound'); assert.ok(!calls.some((c) => c.path === 'artwork')); assert.equal(existsSync(file), false); } } }); test('cover 下载限制声明/实际字节数,失败不截断已有文件,恰好 10 MB 允许', (t) => { const file = join(coverDir(t), 'cover.png'); for (const scenario of ['cover-large-header', 'cover-large-stream', 'cover-false-length']) { coverError(coverShell(['1000', '--download', file], scenario), 5, 'external'); assert.equal(existsSync(file), false); writeFileSync(file, 'existing image'); coverError(coverShell(['1000', '--download', file, '--force'], scenario), 5, 'external'); assert.equal(readFileSync(file, 'utf8'), 'existing image'); rmSync(file); } const p = coverShell(['1000', '--download', file], 'cover-exact-limit'); assert.equal(p.status, 0, p.stderr); assert.equal(JSON.parse(p.stdout).bytes, 10_000_000); assert.equal(readFileSync(file).length, 10_000_000); }); test('cover 空图片/404/非图片响应为错误 JSON,不创建目标文件', (t) => { const file = join(coverDir(t), 'cover.png'); for (const [scenario, status, error] of [['cover-empty-image', 4, 'notfound'], ['cover-http-missing', 4, 'notfound'], ['cover-non-image', 5, 'external']]) { coverError(coverShell(['1000', '--download', file], scenario), status, error); assert.equal(existsSync(file), false); } }); test('cover 非法/缺失参数在请求前报 usage JSON', () => { for (const args of [[], ['1000', '--download'], ['1000', '--download', ''], ['1000', '--download', '--force'], ['1000', '--force'], ['1000', '--unknown']]) { assert.deepEqual(coverError(coverShell(args), 2, 'usage'), []); } }); function jsonLines(file) { return existsSync(file) ? readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []; } function player(t, ids = [1000, 1001, 1002], initial = {}) { const dir = mkdtempSync(join(runtime, 'player-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const local = join(dir, 'local/skills/netease-music'); mkdirSync(local, { recursive: true }); const stateFile = join(dir, 'mpv.json'); const songs = ids.map((id) => ({ id, name: `歌曲 ${id}`, artist: '歌手', album: '专辑', ms: 180000, vip: false, cover: '', level: 'lossless' })); const entries = ids.map((id) => ({ title: `[#${id}] 歌手 - 歌曲 ${id}`, filename: `https://audio.invalid/${id}.flac` })); writeFileSync(stateFile, JSON.stringify({ entries, pos: ids.length ? 1 % ids.length : -1, pause: false, elapsed: 37, shuffle: false, loopFile: 'no', loopList: 'no', ...initial })); writeFileSync(join(local, 'config.json'), '{"cookie":"fixture unchanged"}'); writeFileSync(join(local, 'queue.json'), JSON.stringify({ songs, skipped: 0, source: 'fixture' })); writeFileSync(join(local, 'songs.json'), JSON.stringify(songs)); writeFileSync(join(local, 'queue.m3u8'), '#EXTM3U\n' + entries.map((e) => `#EXTINF:180,${e.title}\n${e.filename}\n`).join('')); writeFileSync(join(local, 'urls.txt'), ids.map((id, i) => `${id}\tlossless\t${entries[i].filename}\n`).join('')); writeFileSync(join(local, 'urls_at'), '123'); const env = { ...process.env, NODE_OPTIONS: `--import=${fake}`, NETEASE_COOKIE: '', PI_AGENT_DIR: dir, NETEASE_MPV_DIR: local, NETEASE_TEST_MPV_STATE: stateFile, NETEASE_TEST_API_CALLS: join(dir, 'api.calls'), NETEASE_TEST_SCENARIO: '', PI_SKILL_NETEASE_MUSIC_COOKIE: 'test', PI_SKILL_NETEASE_MUSIC_QUALITY: 'lossless' }; return { dir, local, stateFile, state: () => JSON.parse(readFileSync(stateFile, 'utf8')), calls: () => jsonLines(`${stateFile}.calls`), apiCalls: () => jsonLines(env.NETEASE_TEST_API_CALLS), queue: () => JSON.parse(readFileSync(join(local, 'queue.json'), 'utf8')), run: (args, scenario = '') => spawnSync('bash', [shell, ...args], { encoding: 'utf8', env: { ...env, NETEASE_TEST_SCENARIO: scenario }, timeout: 15000 }), mpv: (args) => spawnSync(process.execPath, [mpv, ...args], { encoding: 'utf8', env, timeout: 5000 }), }; } function okJson(p) { assert.equal(p.status, 0, `${p.stderr}\n${p.stdout}`); return JSON.parse(p.stdout); } function apiFailure(args, scenario, code, kind) { const p = spawnSync(process.execPath, ['--import', fake, api, ...args], { encoding: 'utf8', env: { ...process.env, NETEASE_COOKIE: '', NETEASE_TEST_SCENARIO: scenario }, }); return coverError(p, code, kind); } function localFiles(p) { return Object.fromEntries(['queue.json', 'queue.m3u8', 'songs.json', 'urls.txt', 'urls_at', 'config.json'] .map((file) => [file, existsSync(join(p.local, file)) ? readFileSync(join(p.local, file), 'utf8') : null])); } const writes = (calls) => calls.filter(([op]) => op !== 'get_property'); test('repeat 三模式切换清掉另一个循环属性;on/无参数保持整列循环,状态兼容旧布尔字段', (t) => { const p = player(t); for (const [arg, mode] of [['single', 'single'], ['list', 'list'], ['single', 'single'], ['off', 'off'], ['on', 'list'], [undefined, 'list']]) { const out = okJson(p.mpv(['repeat', ...(arg ? [arg] : [])])); assert.equal(out.repeat_mode, mode); assert.equal(out.repeat, mode === 'list'); const state = p.state(); assert.equal(state.loopFile, mode === 'single' ? 'inf' : 'no'); assert.equal(state.loopList, mode === 'list' ? 'inf' : 'no'); const status = okJson(p.mpv(['status'])); assert.equal(status.repeat_mode, mode); assert.equal(status.repeat, mode === 'list'); } const out = okJson(p.run(['repeat', 'single', '--json'])); assert.equal(out.repeat_mode, 'single'); assert.equal(out.repeat, 'false'); assert.equal(out.song_id, '1001'); const human = p.run(['status']); assert.equal(human.status, 0, human.stderr); assert.match(human.stdout, /^正在播放:歌曲 1001 - 歌手\n/); assert.match(human.stdout, /\nrepeat_mode=single\n/); assert.deepEqual(p.apiCalls(), []); }); test('repeat/remove/clear 参数错误在 IPC 前拒绝;mpv 拒绝变更不能回成功', (t) => { const p = player(t); for (const args of [['repeat', 'bogus'], ['repeat', 'list', 'single'], ['remove'], ['remove', '0'], ['remove', '-1'], ['remove', '1.2'], ['remove', '9007199254740992'], ['clear', 'extra']]) { const result = p.mpv(args); assert.equal(result.status, 2, result.stderr); assert.equal(JSON.parse(result.stderr).error, 'usage'); } assert.deepEqual(p.calls(), []); assert.equal(p.run(['repeat', 'list', 'single']).status, 2); assert.deepEqual(p.calls(), []); const rejected = player(t, [1000, 1001], { fail: ['set_property', 'loop-file'] }); assert.equal(rejected.run(['repeat', 'single']).status, 5); }); test('play-ids 批量详情保持输入顺序和重复项,分批查询;默认/指定 start 从 1 开始', () => { const { out, calls } = run(['play-ids', '1002,1000,1002', '--start', '3']); assert.deepEqual(out.songs.map((s) => s.id), [1002, 1000, 1002]); assert.equal(out.start, 3); assert.equal(out.count, 3); assert.equal(out.total, 3); assert.equal(calls.length, 1); assert.equal(run(['play-ids', '1000']).out.start, 1); const ids = Array.from({ length: 205 }, (_, i) => i + 1000); const batch = run(['play-ids', ids.join(',')]); assert.deepEqual(batch.out.songs.map((s) => s.id), ids); assert.equal(batch.calls.length, 2); }); test('play-ids 非法 id/start 在请求前失败,缺详情不会退化成错误队列', () => { for (const args of [[], [''], ['1000,'], [',1000'], ['1000,,1001'], ['1000', '1001'], ['0'], ['-1'], ['1.2'], ['1000, 1001'], ['9007199254740992'], ['1000', '--start'], ['1000', '--start', '0'], ['1000', '--start', '2'], ['1000', '--start', '-1'], ['1000', '--start', '1.5'], ['1000', '--start', 'Infinity'], ['1000', '--start', '1e0']]) { assert.deepEqual(apiFailure(['play-ids', ...args], '', 2, 'usage'), [], args.join(' ')); } assert.equal(apiFailure(['play-ids', '1000,1004'], 'missing-song', 4, 'notfound').length, 1); }); test('play-ids 替换并从指定歌曲起播;不可播的前项跳过后仍对齐原序号,保留上一条快照', (t) => { const p = player(t, [1010, 1011], { shuffle: true }); const out = okJson(p.run(['play-ids', '1004,1001,1000', '--start', '3', '--json'], 'missing-url')); assert.equal(out.start, '3'); assert.equal(out.song_id, '1000'); assert.equal(out.queue, '2'); assert.match(out.skipped, /^1/); assert.deepEqual(p.queue().songs.map((s) => s.id), [1001, 1000]); assert.equal(p.state().pos, 1); assert.equal(p.state().pause, false); assert.equal(p.state().shuffle, false); assert.equal(p.state().entries.length, 2); assert.deepEqual(JSON.parse(readFileSync(join(p.local, 'queues/prev.json'))).songs.map((s) => s.id), [1010, 1011]); const cmds = writes(p.calls()); assert.deepEqual(cmds.slice(0, 5).map((c) => c.slice(0, 2)), [ ['set_property', 'shuffle'], ['set_property', 'pause'], ['loadlist', join(p.local, 'queue.m3u8')], ['set_property', 'playlist-pos'], ['set_property', 'pause'], ]); assert.equal(cmds[1][2], true); assert.equal(readFileSync(join(p.local, 'config.json'), 'utf8'), '{"cookie":"fixture unchanged"}'); const duplicates = okJson(p.run(['play-ids', '1000,1001,1000', '--start', '3', '--json'])); assert.equal(duplicates.song_id, '1000'); assert.equal(p.state().pos, 2); assert.equal(p.state().entries.length, 3); okJson(p.run(['play-ids', '1002,1001', '--json'])); assert.equal(p.state().pos, 0); }); test('play-ids 选中项无地址、无详情或参数错误时不替换队列、不发播放 IPC', (t) => { for (const [args, scenario, status] of [ [['1000,1004', '--start', '2'], 'missing-url', 4], [['1000'], 'no-urls', 4], [['1004'], 'missing-song', 4], [['1000,1001', '--start', '3'], '', 2], [['1000', '--start'], '', 2], [['1000', '--shuffle'], '', 2], ]) { const p = player(t); const before = localFiles(p), state = p.state(); const result = p.run(['play-ids', ...args], scenario); assert.equal(result.status, status, result.stderr); assert.equal(result.stdout, ''); assert.deepEqual(localFiles(p), before); assert.deepEqual(p.state(), state); assert.deepEqual(writes(p.calls()), []); assert.ok(!existsSync(join(p.local, 'queues/prev.json'))); } }); test('remove 按显示顺序移除,当前项选下一首/末尾选上一首,保留暂停;空队列停止', (t) => { for (const c of [ { ids: [1000, 1001, 1002], pos: 1, remove: 2, want: [1000, 1002], current: 1002, elapsed: 0 }, { ids: [1000, 1001, 1002], pos: 2, remove: 3, want: [1000, 1001], current: 1001, elapsed: 0, pause: true }, { ids: [1000, 1001, 1002], pos: 1, remove: 1, want: [1001, 1002], current: 1001, elapsed: 37 }, { ids: [1000, 1001, 1002], pos: 1, remove: 3, want: [1000, 1001], current: 1001, elapsed: 37 }, { ids: [1000, 1001, 1000], pos: 0, remove: 3, want: [1000, 1001], current: 1000, elapsed: 37 }, { ids: [1000], pos: 0, remove: 1, want: [], current: undefined }, { ids: [1000], pos: 0, remove: 1, want: [], current: undefined, pause: true }, ]) { const p = player(t, c.ids, { pos: c.pos, pause: !!c.pause, loopFile: 'inf' }); const out = okJson(p.run(['remove', String(c.remove), '--json'])); assert.equal(out.removed, String(c.remove)); assert.deepEqual(p.queue().songs.map((s) => s.id), c.want); assert.equal(p.state().entries.length, c.want.length); assert.equal(p.state().pause, !!c.pause); assert.equal(p.state().loopFile, 'inf'); assert.equal(out.song_id, c.current === undefined ? undefined : String(c.current)); if (c.current !== undefined) assert.equal(p.state().elapsed, c.elapsed); else { assert.equal(p.state().pos, -1); assert.equal(out.state, 'idle'); } assert.deepEqual(JSON.parse(readFileSync(join(p.local, 'songs.json'))).map((s) => s.id), c.want); assert.equal(readFileSync(join(p.local, 'queue.m3u8'), 'utf8').split('\n').filter((s) => s.startsWith('https:')).length, c.want.length); assert.equal(readFileSync(join(p.local, 'urls.txt'), 'utf8').trim().split('\n').filter(Boolean).length, c.want.length); assert.deepEqual(p.apiCalls(), []); } }); test('remove 开随机后使用 mpv 的队列序号;queue/status 与本地文件一致', (t) => { const p = player(t, [1000, 1001, 1002]); const state = p.state(); state.entries = [state.entries[2], state.entries[0], state.entries[1]]; state.pos = 1; state.shuffle = true; writeFileSync(p.stateFile, JSON.stringify(state)); const out = okJson(p.run(['remove', '1', '--json'])); assert.equal(out.song_id, '1000'); assert.equal(out.queue_pos, '1/2'); assert.equal(out.shuffle, 'true'); assert.deepEqual(p.queue().songs.map((s) => s.id), [1000, 1001]); const queue = p.run(['queue']); assert.equal(queue.status, 0, queue.stderr); assert.match(queue.stdout, /1\. 歌手 - 歌曲 1000.*正在放/); assert.doesNotMatch(queue.stdout, /1002/); }); test('remove 越界/错误参数或 IPC 拒绝不改本地文件,clear 被拒绝也不删文件', (t) => { for (const args of [['remove'], ['remove', '0'], ['remove', '-1'], ['remove', '1.5'], ['remove', '4'], ['remove', '1', '2'], ['clear', 'extra']]) { const p = player(t), before = localFiles(p); assert.ok([2, 4].includes(p.run(args).status), args.join(' ')); assert.deepEqual(localFiles(p), before); assert.deepEqual(writes(p.calls()), []); } for (const [args, fail] of [[['remove', '3'], ['playlist-remove']], [['clear'], ['stop']]]) { const p = player(t, [1000, 1001, 1002], { fail }), before = localFiles(p); const result = p.run(args); assert.equal(result.status, 5, result.stderr); assert.deepEqual(localFiles(p), before); assert.equal(result.stdout, ''); } }); test('clear 停止并清空 mpv、本地队列和保活,保留配置与快照,重复调用成功', (t) => { const p = player(t, [1000, 1001, 1002], { pause: true }); mkdirSync(join(p.local, 'queues')); writeFileSync(join(p.local, 'queues/saved.json'), '{"songs":[]}'); writeFileSync(join(p.local, 'keepalive.pid'), 'not-a-pid'); for (let i = 0; i < 2; i++) { const out = okJson(p.run(['clear', '--json'])); assert.equal(out.state, 'idle'); assert.equal(out.song_id, undefined); assert.equal(out.queue_pos, undefined); assert.deepEqual(p.state().entries, []); assert.equal(p.state().pos, -1); } for (const file of ['queue.json', 'queue.m3u8', 'songs.json', 'urls.txt', 'urls_at', 'keepalive.pid']) assert.equal(existsSync(join(p.local, file)), false, file); assert.equal(readFileSync(join(p.local, 'config.json'), 'utf8'), '{"cookie":"fixture unchanged"}'); assert.equal(readFileSync(join(p.local, 'queues/saved.json'), 'utf8'), '{"songs":[]}'); assert.deepEqual(writes(p.calls()), [['stop'], ['playlist-clear'], ['stop'], ['playlist-clear']]); assert.deepEqual(p.apiCalls(), []); }); test('所有歌曲列表/搜索/详情透传布尔 liked,缺失/未知时不伪造 false;liked 列表明确为 true', () => { const args = [ ['song', '1000'], ['song', '1001'], ['song', '关键词'], ['search', '关键词'], ['playlist', '1'], ['daily'], ['artist', '1'], ['album', '1'], ['chart', '1'], ['recent'], ['fm'], ['podcast', '1'], ['episode', '1'], ['play-ids', '1000,1001'], ]; for (const command of args) { const out = run([...command, '--limit', '2'], 'liked-flags').out; for (const song of out.songs || [out]) assert.equal(song.liked, song.id % 2 === 0, command.join(' ')); const unknown = run([...command, '--limit', '2'], 'liked-unknown'); for (const song of unknown.out.songs || [unknown.out]) assert.equal(Object.hasOwn(song, 'liked'), false); assert.ok(!unknown.calls.some((c) => c.path === 'song/like/get')); } const liked = run(['liked', '--limit', '2']).out; assert.deepEqual(liked.songs.map((s) => s.liked), [true, true]); assert.deepEqual(Object.keys(run(['song', '1000'], 'liked-flags').out), ['id', 'name', 'artist', 'album', 'ms', 'vip', 'cover', 'liked']); }); test('liked-ids 返回完整数值 ID 列表和空表,不把鉴权/限流/坏响应当成未喜欢', (t) => { const { out, calls } = run(['liked-ids', '--limit', '1', '--offset', '9']); assert.deepEqual(out, { count: 3, total: 3, ids: [1000, 1002, 1004] }); assert.deepEqual(calls.map((c) => c.path), ['song/like/get']); assert.deepEqual(run(['liked-ids'], 'empty').out, { count: 0, total: 0, ids: [] }); assert.deepEqual(apiFailure(['liked-ids', 'extra'], '', 2, 'usage'), []); for (const [scenario, code, error] of [['liked-error', 5, 'external'], ['liked-auth', 3, 'auth'], ['liked-no-ids', 5, 'external'], ['liked-bad-ids', 5, 'external']]) apiFailure(['liked-ids'], scenario, code, error); const p = player(t), before = localFiles(p); assert.deepEqual(okJson(p.run(['liked-ids', '--json'])), { count: 3, total: 3, ids: [1000, 1002, 1004] }); assert.match(p.run(['liked-ids']).stdout, /^我喜欢的歌曲 ID(3 首)\ncount=3\nids=1000,1002,1004\n/); assert.deepEqual(localFiles(p), before); assert.deepEqual(p.calls(), []); }); test('dislike 映射 fm/trash,校验 API 返回码和参数;只上报 ban,不喜欢/取消喜欢', () => { const { out, calls } = run(['dislike', '1001']); assert.deepEqual(out, { id: 1001, disliked: true, code: 200 }); assert.deepEqual(calls, [{ path: 'radio/trash/add', data: { songId: 1001, time: 25, alg: 'RT', csrf_token: '' } }]); for (const args of [[], ['0'], ['-1'], ['abc'], ['1.5'], ['Infinity'], ['9007199254740992'], ['1000', '1001']]) assert.deepEqual(apiFailure(['dislike', ...args], '', 2, 'usage'), []); apiFailure(['dislike', '1001'], 'dislike-error', 5, 'external'); apiFailure(['dislike', '1001'], 'dislike-auth', 3, 'auth'); }); test('dislike 支持当前歌曲或显式 id,按歌曲身份取当前项,不改队列/播放/配置;无当前项则报错', (t) => { const p = player(t); const state = p.state(); state.entries.reverse(); state.pos = 0; writeFileSync(p.stateFile, JSON.stringify(state)); const before = localFiles(p); assert.equal(okJson(p.run(['dislike', '--json'])).song_id, '1002'); assert.equal(okJson(p.run(['dislike', '1000', '--json'])).song_id, '1000'); assert.equal(p.run(['dislike', '1000'], 'dislike-error').status, 5); assert.deepEqual(localFiles(p), before); assert.deepEqual(p.state(), state); assert.deepEqual(writes(p.calls()), []); assert.deepEqual(p.apiCalls().map((c) => c.path), ['radio/trash/add', 'radio/trash/add', 'radio/trash/add']); const empty = player(t, []); assert.equal(empty.run(['dislike']).status, 4); assert.deepEqual(empty.apiCalls(), []); assert.equal(empty.run(['dislike', '1000']).status, 0); });