570 lines
31 KiB
JavaScript
570 lines
31 KiB
JavaScript
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);
|
||
});
|