#!/usr/bin/env node // mpv-ctl — 后台 mpv 的生命周期与 IPC 控制(网易云播放的「播放器层」) // // 为什么这样:mpv 常驻(--idle=yes)+ unix socket 上跑 JSON IPC, // 所以播放/暂停/切歌/音量都是毫秒级命令,状态(时间、进度、曲名)也是真值, // 不再靠截屏看进度条猜。mpv 自己会接着放歌单,不需要守护进程。 // // 输出:JSON(内部原语);退出码 0 成功 / 2 用法 / 3 缺依赖 / 5 执行失败 // 用法: // mpv-ctl play [--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 | 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 [--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 ', 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));