netease-music-cli/scripts/mpv-ctl.mjs
2026-10-02 07:26:46 +08:00

310 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// mpv-ctl — 后台 mpv 的生命周期与 IPC 控制(网易云播放的「播放器层」)
//
// 为什么这样:mpv 常驻(--idle=yes)+ unix socket 上跑 JSON IPC,
// 所以播放/暂停/切歌/音量都是毫秒级命令,状态(时间、进度、曲名)也是真值,
// 不再靠截屏看进度条猜。mpv 自己会接着放歌单,不需要守护进程。
//
// 输出:JSON(内部原语);退出码 0 成功 / 2 用法 / 3 缺依赖 / 5 执行失败
// 用法:
// mpv-ctl play <m3u|url> [--title T] [--index N] 建队列并开播
// mpv-ctl status 当前状态(真值)
// mpv-ctl pause|resume|toggle|next|prev|stop
// mpv-ctl volume [0-130] | seek <秒|+n|-n> | shuffle on|off | repeat off|list|single (on=list)
// mpv-ctl append <m3u|url> | remove <1-based index> | clear
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
const DIR = process.env.NETEASE_MPV_DIR || process.env.NETEASE_MUSIC_STATE_DIR
|| path.join(process.env.PI_AGENT_DIR || path.join(os.homedir(), '.pi/agent'), 'local', 'skills', 'netease-music');
const SOCK = path.join(DIR, 'mpv.sock');
const LOG = path.join(DIR, 'mpv.log');
fs.mkdirSync(DIR, { recursive: true });
const die = (code, hint, rc) => {
console.error(JSON.stringify({ error: code, hint }));
process.exit(rc);
};
function connect(timeoutMs = 1500) {
return new Promise((resolve) => {
const s = net.connect(SOCK);
const t = setTimeout(() => {
s.destroy();
resolve(null);
}, timeoutMs);
s.once('connect', () => {
clearTimeout(t);
resolve(s);
});
s.once('error', () => {
clearTimeout(t);
s.destroy();
resolve(null);
});
});
}
function makeClient(sock) {
let buf = '';
let id = 0;
const pending = new Map();
sock.on('data', (d) => {
buf += d.toString();
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.request_id && pending.has(msg.request_id)) {
const { resolve } = pending.get(msg.request_id);
pending.delete(msg.request_id);
resolve(msg.error === 'success' || msg.error === undefined ? msg.data : { __error: msg.error });
}
}
});
const send = (command, timeoutMs = 4000) =>
new Promise((resolve) => {
const rid = ++id;
const t = setTimeout(() => {
pending.delete(rid);
resolve({ __error: 'timeout' });
}, timeoutMs);
pending.set(rid, {
resolve: (v) => {
clearTimeout(t);
resolve(v);
},
});
sock.write(JSON.stringify({ command, request_id: rid }) + '\n');
});
return { send, close: () => sock.end() };
}
async function ensureMpv() {
let sock = await connect(300);
if (sock) return makeClient(sock);
const out = fs.openSync(LOG, 'a');
const child = spawn(
'mpv',
['--idle=yes', '--no-video', '--force-window=no', '--audio-display=no', '--no-terminal', `--input-ipc-server=${SOCK}`, '--volume=70'],
{ detached: true, stdio: ['ignore', out, out] },
);
child.unref();
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
sock = await connect(300);
if (sock) return makeClient(sock);
}
die('deps', `mpv 没起来(日志 ${LOG}):确认 brew install mpv 成功`, 3);
}
const argv = process.argv.slice(2);
const CMD = argv[0];
const opts = {};
const pos = [];
for (let i = 1; i < argv.length; i++) {
if (argv[i] === '--title') opts.title = argv[++i];
else if (argv[i] === '--index') opts.index = +argv[++i];
else pos.push(argv[i]);
}
const GOOD = {
pause: ['set_property', 'pause', true],
resume: ['set_property', 'pause', false],
};
async function main() {
const repeatMode = (pos[0] || 'on') === 'on' ? 'list' : pos[0];
if (CMD === 'repeat' && (pos.length > 1 || !['off', 'list', 'single'].includes(repeatMode))) {
die('usage', '用法:repeat off|list|single(on = list)', 2);
}
if (CMD === 'remove' && (pos.length !== 1 || !/^\d+$/.test(pos[0]) || !Number.isSafeInteger(+pos[0]) || +pos[0] < 1)) {
die('usage', '用法:remove <队列序号,从 1 开始>', 2);
}
if (CMD === 'clear' && pos.length) die('usage', '用法:clear', 2);
if (CMD === 'play' && opts.index !== undefined && (!Number.isSafeInteger(opts.index) || opts.index < 0)) {
die('usage', '--index 需要从 0 开始的序号', 2);
}
const c = await ensureMpv();
const get = (p) => c.send(['get_property', p]);
const checked = async (command) => {
const result = await c.send(command);
if (result && result.__error) die('external', `mpv 报错:${result.__error}`, 5);
return result;
};
let out = { command: CMD };
switch (CMD) {
case 'play': {
const target = pos[0];
if (!target) die('usage', '用法:mpv-ctl play <m3u|url> [--title T] [--index N]', 2);
const isUrl = /^https?:/.test(target);
const isList = /\.(m3u8?|pls)$/i.test(target);
const cmd = isUrl || !isList ? ['loadfile', target, 'replace'] : ['loadlist', target, 'replace'];
if (isUrl && opts.title) cmd.splice(2, 0, `--force-media-title=${opts.title}`);
// Pause first: a load starts the first entry immediately, so an indexed
// start (or any later adjustment) would leak a fraction of the wrong
// song as a click. Silence the swap, position, then resume.
await checked(['set_property', 'pause', true]);
const r = await c.send(cmd);
if (r && r.__error) die('external', `mpv 拒绝播放:${r.__error}`, 5);
if (opts.index !== undefined) await checked(['set_property', 'playlist-pos', opts.index]);
await checked(['set_property', 'pause', false]);
out.playing = true;
break;
}
case 'append': {
const target = pos[0];
if (!target) die('usage', '用法:mpv-ctl append <m3u|url>', 2);
const isUrl = /^https?:/.test(target);
const r = await c.send(isUrl ? ['loadfile', target, 'append'] : ['loadlist', target, 'append']);
if (r && r.__error) die('external', `mpv 拒绝追加:${r.__error}`, 5);
out.count = await get('playlist-count');
break;
}
case 'pause':
case 'resume':
case 'toggle': {
let cmd = GOOD[CMD];
if (CMD === 'toggle') cmd = ['set_property', 'pause', !(await c.send(['get_property', 'pause']))];
const r = await c.send(cmd);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
out.paused = await get('pause');
break;
}
case 'next':
case 'prev': {
const r = await c.send([CMD === 'next' ? 'playlist-next' : 'playlist-prev', 'force']);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
break;
}
case 'stop':
case 'clear': {
await checked(['stop']);
await checked(['playlist-clear']);
out.stopped = true;
break;
}
case 'remove': {
const index = +pos[0] - 1;
const [count, current, paused] = await Promise.all([get('playlist-count'), get('playlist-pos'), get('pause')]);
if (!Number.isInteger(count) || index >= count) die('notfound', `队列里没有第 ${pos[0]} 首`, 4);
if (index === current) {
if (count === 1) {
await checked(['stop']);
await checked(['playlist-clear']);
} else {
// 先选下一首,末尾则选上一首;不受循环模式影响,并保留暂停状态。
await checked(['set_property', 'playlist-pos', index + 1 < count ? index + 1 : index - 1]);
await checked(['playlist-remove', index]);
await checked(['set_property', 'pause', paused === true]);
}
} else await checked(['playlist-remove', index]);
out.removed = index + 1;
out.count = await get('playlist-count');
break;
}
case 'seek': {
const v = pos[0] ?? '0';
const r = await c.send(['seek', v, 'relative']);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
break;
}
case 'volume': {
if (pos[0]) {
await c.send(['set_property', 'volume', Number(pos[0])]);
}
out.volume = await get('volume');
break;
}
case 'shuffle': {
const on = (pos[0] || 'on') !== 'off';
await checked(['set_property', 'shuffle', on]);
out.shuffle = on;
break;
}
case 'repeat': {
// 每次同时设置两个属性,避免从 single 切换后仍单曲循环。
await checked(['set_property', 'loop-file', repeatMode === 'single' ? 'inf' : 'no']);
await checked(['set_property', 'loop-playlist', repeatMode === 'list' ? 'inf' : 'no']);
out.repeat = repeatMode === 'list'; // 保留原字段含义:是否整列循环。
out.repeat_mode = repeatMode;
break;
}
case 'playlist': {
const [ppos, list] = await Promise.all([get('playlist-pos'), get('playlist')]);
const entries = Array.isArray(list) ? list : [];
out = {
command: 'playlist',
pos: Number.isInteger(ppos) ? ppos : -1,
count: entries.length,
entries: entries.map((e, i) => ({
index: i + 1,
title: typeof e.title === 'string' ? e.title : '',
filename: typeof e.filename === 'string' ? e.filename : '',
duration: Math.round(e.duration || 0),
})),
};
break;
}
case 'jump': {
const n = Number(pos[0]);
if (!Number.isInteger(n) || n < 1) die('usage', '用法:mpv-ctl jump <队列第几首,从 1 开始>', 2);
const count = await get('playlist-count');
if (!Number.isFinite(count) || count < 1) die('notfound', '队列是空的(先 play/daily/liked/artist/album 建队列)', 4);
if (n > count) die('notfound', `队列只有 ${count} 首,没有第 ${n} 首`, 4);
const r = await c.send(['set_property', 'playlist-pos', n - 1]);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
await c.send(['set_property', 'pause', false]);
out = { command: 'jump', jumped: n, count };
break;
}
case 'status': {
const [idle, paused, tpos, dur, vol, ppos, pcount, title, path_, loop, fileLoop, shuffle] = await Promise.all([
get('idle-active'),
get('pause'),
get('time-pos'),
get('duration'),
get('volume'),
get('playlist-pos'),
get('playlist-count'),
get('media-title'),
get('path'),
get('loop-playlist'),
get('loop-file'),
get('shuffle'),
]);
out = {
command: 'status',
idle: idle === true,
playing: idle === false && paused === false,
paused: paused === true,
pos: Number.isFinite(ppos) ? ppos : -1,
count: Number.isFinite(pcount) ? pcount : 0,
elapsed: Number.isFinite(tpos) ? Math.round(tpos) : null,
duration: Number.isFinite(dur) ? Math.round(dur) : null,
volume: Number.isFinite(vol) ? vol : null,
title: typeof title === 'string' ? title : '',
path: typeof path_ === 'string' ? path_ : '',
repeat: loop === 'inf',
repeat_mode: fileLoop === 'inf' ? 'single' : loop === 'inf' ? 'list' : 'off',
shuffle: shuffle === true,
};
break;
}
default:
die('usage', `未知命令 ${CMD || '(空)'}:play/append/status/playlist/jump/remove/clear/pause/resume/toggle/next/prev/stop/volume/seek/shuffle/repeat`, 2);
}
console.log(JSON.stringify(out));
c.close();
}
main().catch((e) => die('internal', String(e && e.message ? e.message : e), 5));