@llblab/pi-actors 0.48.1 → 0.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/BACKLOG.md +4 -1
- package/CHANGELOG.md +16 -0
- package/README.md +4 -4
- package/dist/lib/async-runs.d.ts +7 -0
- package/dist/lib/async-runs.js +63 -8
- package/dist/lib/file-state.js +15 -1
- package/dist/lib/inspector.js +4 -0
- package/dist/lib/observability.js +2 -5
- package/dist/lib/recipes-discovery.js +3 -1
- package/dist/lib/recipes-references.d.ts +3 -0
- package/dist/lib/recipes-references.js +15 -0
- package/dist/lib/runs-start.d.ts +2 -1
- package/dist/lib/runs-start.js +11 -5
- package/dist/lib/tools-local.js +8 -5
- package/dist/skills/actors/SKILL.md +2 -2
- package/dist/skills/actors/references/persistent-tools.md +1 -1
- package/dist/skills/actors/references/recipes.md +2 -2
- package/dist/skills/actors/references/runs.md +2 -0
- package/dist/skills/actors/scripts/validate-recipe.mjs +12 -0
- package/dist/skills/music-player/SKILL.md +57 -0
- package/dist/skills/music-player/genapps/music-player.mjs +360 -0
- package/{skills/media/recipes/player.json → dist/skills/music-player/recipes/playback.json} +4 -3
- package/dist/skills/music-player/scripts/playback-client.mjs +143 -0
- package/dist/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
- package/docs/async-runs.md +1 -1
- package/docs/recipe-library.md +2 -5
- package/docs/template-recipes.md +7 -0
- package/docs/tool-registry.md +1 -1
- package/lib/async-runs.ts +92 -15
- package/lib/file-state.ts +14 -1
- package/lib/inspector.ts +4 -0
- package/lib/observability.ts +2 -5
- package/lib/recipes-discovery.ts +2 -1
- package/lib/recipes-references.ts +20 -0
- package/lib/runs-start.ts +16 -6
- package/lib/tools-local.ts +6 -4
- package/package.json +1 -1
- package/skills/actors/SKILL.md +2 -2
- package/skills/actors/references/persistent-tools.md +1 -1
- package/skills/actors/references/recipes.md +2 -2
- package/skills/actors/references/runs.md +2 -0
- package/skills/actors/scripts/validate-recipe.mjs +12 -0
- package/skills/music-player/SKILL.md +57 -0
- package/skills/music-player/genapps/music-player.mjs +360 -0
- package/{dist/skills/media/recipes/player.json → skills/music-player/recipes/playback.json} +4 -3
- package/skills/music-player/scripts/playback-client.mjs +143 -0
- package/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
- package/dist/skills/media/SKILL.md +0 -44
- package/dist/skills/media/recipes/library.json +0 -45
- package/dist/skills/media/recipes/playlist-build.json +0 -15
- package/dist/skills/media/recipes/playlist-scan.json +0 -11
- package/dist/skills/media/scripts/media-utils.mjs +0 -47
- package/skills/media/SKILL.md +0 -44
- package/skills/media/recipes/library.json +0 -45
- package/skills/media/recipes/playlist-build.json +0 -15
- package/skills/media/recipes/playlist-scan.json +0 -11
- package/skills/media/scripts/media-utils.mjs +0 -47
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { basename, dirname, isAbsolute } from "node:path";
|
|
2
|
+
|
|
3
|
+
const ACTIONS = new Set([
|
|
4
|
+
"play",
|
|
5
|
+
"pause",
|
|
6
|
+
"resume",
|
|
7
|
+
"toggle",
|
|
8
|
+
"next",
|
|
9
|
+
"previous",
|
|
10
|
+
"stop",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function requireAdapter(argument) {
|
|
14
|
+
if (!argument || typeof argument !== "object" || Array.isArray(argument)) {
|
|
15
|
+
throw new Error("music-player init requires an object argument");
|
|
16
|
+
}
|
|
17
|
+
const control = typeof argument.control === "string" ? argument.control : "";
|
|
18
|
+
const stateDir = typeof argument.stateDir === "string" ? argument.stateDir : "";
|
|
19
|
+
const node = typeof argument.node === "string" ? argument.node : process.execPath;
|
|
20
|
+
if (!isAbsolute(control) || !control.endsWith("playback.mjs")) {
|
|
21
|
+
throw new Error("music-player control must be an absolute playback.mjs path");
|
|
22
|
+
}
|
|
23
|
+
if (!isAbsolute(stateDir)) {
|
|
24
|
+
throw new Error("music-player stateDir must be an absolute path");
|
|
25
|
+
}
|
|
26
|
+
if (!isAbsolute(node)) {
|
|
27
|
+
throw new Error("music-player node executable must be an absolute path");
|
|
28
|
+
}
|
|
29
|
+
return { control, node, stateDir };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function inline(value) {
|
|
33
|
+
return String(value ?? "").replaceAll("`", "'");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizePlayback(status) {
|
|
37
|
+
const actorAvailable = status.actor_available === true;
|
|
38
|
+
if (!actorAvailable) {
|
|
39
|
+
return {
|
|
40
|
+
actorAvailable: false,
|
|
41
|
+
count: null,
|
|
42
|
+
index: null,
|
|
43
|
+
pid: "",
|
|
44
|
+
player: "",
|
|
45
|
+
progressPercent: null,
|
|
46
|
+
seekPercent: 0,
|
|
47
|
+
state: "inactive",
|
|
48
|
+
track: "",
|
|
49
|
+
updatedAt: typeof status.updated_at === "string" ? status.updated_at : "",
|
|
50
|
+
volume: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
actorAvailable: true,
|
|
55
|
+
state: typeof status.state === "string" ? status.state : "unknown",
|
|
56
|
+
index: Number.isInteger(status.index) ? status.index : null,
|
|
57
|
+
count: Number.isInteger(status.count) ? status.count : null,
|
|
58
|
+
track: typeof status.track === "string" ? status.track : "",
|
|
59
|
+
player: typeof status.player === "string" ? status.player : "",
|
|
60
|
+
volume: Number.isInteger(status.volume) ? status.volume : null,
|
|
61
|
+
seekPercent: Number.isInteger(status.seek_percent) ? status.seek_percent : 0,
|
|
62
|
+
progressPercent: Number.isInteger(status.progress_percent) ? status.progress_percent : null,
|
|
63
|
+
pid: typeof status.pid === "string" || typeof status.pid === "number"
|
|
64
|
+
? String(status.pid)
|
|
65
|
+
: "",
|
|
66
|
+
updatedAt: typeof status.updated_at === "string" ? status.updated_at : "",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readPlayback(adapter, run) {
|
|
71
|
+
const result = await run({
|
|
72
|
+
command: adapter.node,
|
|
73
|
+
args: [adapter.control, "status", adapter.stateDir],
|
|
74
|
+
cwd: dirname(adapter.control),
|
|
75
|
+
timeoutMs: 5_000,
|
|
76
|
+
});
|
|
77
|
+
if (result.code !== 0) {
|
|
78
|
+
throw new Error(`music-player status failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
79
|
+
}
|
|
80
|
+
let status;
|
|
81
|
+
try {
|
|
82
|
+
status = JSON.parse(result.stdout);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error("music-player status returned invalid JSON");
|
|
85
|
+
}
|
|
86
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) {
|
|
87
|
+
throw new Error("music-player status returned an invalid object");
|
|
88
|
+
}
|
|
89
|
+
return normalizePlayback(status);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function withPlayback(state, playback) {
|
|
93
|
+
return { version: 1, adapter: state.adapter, playback };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mainControls(playback) {
|
|
97
|
+
if (!playback.actorAvailable || playback.state === "stopped" || playback.state === "unknown") {
|
|
98
|
+
return [
|
|
99
|
+
[{ label: "▶ Start", prompt: "Start the music player", selected_style: "success" }],
|
|
100
|
+
[{ label: "🔄 Status", prompt: "music-player::status", selected_style: "primary" }],
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
const primary = playback.state === "paused"
|
|
104
|
+
? { label: "▶ Play", prompt: "music-player::resume", selected_style: "success" }
|
|
105
|
+
: { label: "⏸ Pause", prompt: "music-player::pause", selected_style: "primary" };
|
|
106
|
+
const percentages = [0, 15, 30, 45, 60, 75, 90];
|
|
107
|
+
const seek = percentages.map((percent) => ({
|
|
108
|
+
label: percent === 0 ? "P0" : String(percent),
|
|
109
|
+
prompt: `music-player::seek_percent(${percent})`,
|
|
110
|
+
selected_style: percent === 0 ? "primary" : "success",
|
|
111
|
+
}));
|
|
112
|
+
const volume = percentages.map((percent) => ({
|
|
113
|
+
label: percent === 0 ? "V0" : String(percent),
|
|
114
|
+
prompt: `music-player::volume(${percent})`,
|
|
115
|
+
selected_style: percent === 0 ? "primary" : "success",
|
|
116
|
+
}));
|
|
117
|
+
return [
|
|
118
|
+
seek,
|
|
119
|
+
volume,
|
|
120
|
+
[
|
|
121
|
+
{ label: "⏮ Previous", prompt: "music-player::previous", selected_style: "primary" },
|
|
122
|
+
primary,
|
|
123
|
+
{ label: "⏭ Next", prompt: "music-player::next", selected_style: "success" },
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
{ label: "⏹ Stop", prompt: "music-player::stop", selected_style: "danger" },
|
|
127
|
+
{ label: "🔄 Status", prompt: "music-player::status", selected_style: "primary" },
|
|
128
|
+
],
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function playbackLines(playback) {
|
|
133
|
+
const track = playback.track ? basename(playback.track) : "—";
|
|
134
|
+
const index = playback.index === null ? "—" : playback.index + 1;
|
|
135
|
+
const count = playback.count === null ? "—" : playback.count;
|
|
136
|
+
return [
|
|
137
|
+
`- **Actor:** \`${playback.actorAvailable ? "available" : "unavailable"}\``,
|
|
138
|
+
`- **State:** \`${inline(playback.state)}\``,
|
|
139
|
+
`- **Progress:** \`${playback.progressPercent === null ? "—" : `${playback.progressPercent}%`}\``,
|
|
140
|
+
`- **Volume:** \`${playback.volume === null ? "—" : `${playback.volume}%`}\``,
|
|
141
|
+
`- **Backend:** \`${inline(playback.player || "—")}\``,
|
|
142
|
+
`- **Queue:** \`${index}/${count}\``,
|
|
143
|
+
`- **Track:** \`${inline(track)}\``,
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function render(playback) {
|
|
148
|
+
return [
|
|
149
|
+
"**🎵 Music Player**",
|
|
150
|
+
"",
|
|
151
|
+
...playbackLines(playback),
|
|
152
|
+
"",
|
|
153
|
+
`<!-- telegram_button ${JSON.stringify(mainControls(playback))} -->`,
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function waitForTransition(adapter, run, before, action) {
|
|
158
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
159
|
+
const playback = await readPlayback(adapter, run);
|
|
160
|
+
const changed = action === "stop"
|
|
161
|
+
? playback.state === "stopped"
|
|
162
|
+
: action === "pause"
|
|
163
|
+
? playback.state === "paused"
|
|
164
|
+
: action === "play" || action === "resume"
|
|
165
|
+
? playback.state === "playing"
|
|
166
|
+
: action === "toggle"
|
|
167
|
+
? playback.state !== before.state
|
|
168
|
+
: playback.index !== before.index || playback.track !== before.track;
|
|
169
|
+
if (changed) return playback;
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
171
|
+
}
|
|
172
|
+
return await readPlayback(adapter, run);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeAbsoluteVolume(argument) {
|
|
176
|
+
const candidate =
|
|
177
|
+
argument && typeof argument === "object" && !Array.isArray(argument)
|
|
178
|
+
? argument.percent
|
|
179
|
+
: argument;
|
|
180
|
+
if (!Number.isInteger(candidate) || candidate < 0 || candidate > 100) {
|
|
181
|
+
throw new Error("music-player volume requires an integer percent 0..100");
|
|
182
|
+
}
|
|
183
|
+
return candidate;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function applyVolume(percent, context) {
|
|
187
|
+
const result = await context.run({
|
|
188
|
+
command: context.state.adapter.node,
|
|
189
|
+
args: [
|
|
190
|
+
context.state.adapter.control,
|
|
191
|
+
"volume",
|
|
192
|
+
context.state.adapter.stateDir,
|
|
193
|
+
String(percent),
|
|
194
|
+
],
|
|
195
|
+
cwd: dirname(context.state.adapter.control),
|
|
196
|
+
timeoutMs: 5_000,
|
|
197
|
+
});
|
|
198
|
+
if (result.code !== 0) {
|
|
199
|
+
throw new Error(`music-player volume failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
200
|
+
}
|
|
201
|
+
let playback;
|
|
202
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
203
|
+
playback = await readPlayback(context.state.adapter, context.run);
|
|
204
|
+
if (playback.volume === percent) break;
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
206
|
+
}
|
|
207
|
+
playback ??= await readPlayback(context.state.adapter, context.run);
|
|
208
|
+
return {
|
|
209
|
+
state: withPlayback(context.state, playback),
|
|
210
|
+
output: render(playback),
|
|
211
|
+
viewMode: "edit",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeSeekPercent(argument) {
|
|
216
|
+
if (!Number.isInteger(argument) || argument < 0 || argument > 100) {
|
|
217
|
+
throw new Error("music-player seek requires an integer percent 0..100");
|
|
218
|
+
}
|
|
219
|
+
return argument;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function applySeek(percent, context) {
|
|
223
|
+
const result = await context.run({
|
|
224
|
+
command: context.state.adapter.node,
|
|
225
|
+
args: [
|
|
226
|
+
context.state.adapter.control,
|
|
227
|
+
"seek",
|
|
228
|
+
context.state.adapter.stateDir,
|
|
229
|
+
String(percent),
|
|
230
|
+
],
|
|
231
|
+
cwd: dirname(context.state.adapter.control),
|
|
232
|
+
timeoutMs: 5_000,
|
|
233
|
+
});
|
|
234
|
+
if (result.code !== 0) {
|
|
235
|
+
throw new Error(`music-player seek failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
236
|
+
}
|
|
237
|
+
let playback;
|
|
238
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
239
|
+
playback = await readPlayback(context.state.adapter, context.run);
|
|
240
|
+
if (playback.seekPercent === percent && playback.pid) break;
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
242
|
+
}
|
|
243
|
+
playback ??= await readPlayback(context.state.adapter, context.run);
|
|
244
|
+
return {
|
|
245
|
+
state: withPlayback(context.state, playback),
|
|
246
|
+
output: render(playback),
|
|
247
|
+
viewMode: "edit",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function apply(action, context) {
|
|
252
|
+
if (!ACTIONS.has(action)) throw new Error(`unsupported music-player action: ${action}`);
|
|
253
|
+
const before = await readPlayback(context.state.adapter, context.run);
|
|
254
|
+
const result = await context.run({
|
|
255
|
+
command: context.state.adapter.node,
|
|
256
|
+
args: [context.state.adapter.control, action, context.state.adapter.stateDir],
|
|
257
|
+
cwd: dirname(context.state.adapter.control),
|
|
258
|
+
timeoutMs: 5_000,
|
|
259
|
+
});
|
|
260
|
+
if (result.code !== 0) {
|
|
261
|
+
throw new Error(`music-player ${action} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
262
|
+
}
|
|
263
|
+
const playback = await waitForTransition(
|
|
264
|
+
context.state.adapter,
|
|
265
|
+
context.run,
|
|
266
|
+
before,
|
|
267
|
+
action,
|
|
268
|
+
);
|
|
269
|
+
return {
|
|
270
|
+
state: withPlayback(context.state, playback),
|
|
271
|
+
output: render(playback),
|
|
272
|
+
viewMode: "edit",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function init({ argument, run }) {
|
|
277
|
+
const adapter = requireAdapter(argument);
|
|
278
|
+
const playback = await readPlayback(adapter, run);
|
|
279
|
+
return {
|
|
280
|
+
state: { version: 1, adapter, playback },
|
|
281
|
+
output: render(playback),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function status({ state, run }) {
|
|
286
|
+
const playback = await readPlayback(state.adapter, run);
|
|
287
|
+
const changed = JSON.stringify(playback) !== JSON.stringify(state.playback);
|
|
288
|
+
return {
|
|
289
|
+
...(changed ? { state: withPlayback(state, playback) } : {}),
|
|
290
|
+
output: render(playback),
|
|
291
|
+
viewMode: "edit",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function refresh({ state, run }) {
|
|
296
|
+
const playback = await readPlayback(state.adapter, run);
|
|
297
|
+
const changed = JSON.stringify(playback) !== JSON.stringify(state.playback);
|
|
298
|
+
return {
|
|
299
|
+
...(changed ? { state: withPlayback(state, playback) } : {}),
|
|
300
|
+
output: render(playback),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function main({ state, run }) {
|
|
305
|
+
const playback = await readPlayback(state.adapter, run);
|
|
306
|
+
const changed = JSON.stringify(playback) !== JSON.stringify(state.playback);
|
|
307
|
+
return {
|
|
308
|
+
...(changed ? { state: withPlayback(state, playback) } : {}),
|
|
309
|
+
output: render(playback),
|
|
310
|
+
viewMode: "edit",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function play(context) {
|
|
315
|
+
return await apply("resume", context);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function pause(context) {
|
|
319
|
+
return await apply("pause", context);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function resume(context) {
|
|
323
|
+
return await apply("resume", context);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function toggle(context) {
|
|
327
|
+
return await apply("toggle", context);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function next(context) {
|
|
331
|
+
return await apply("next", context);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function previous(context) {
|
|
335
|
+
return await apply("previous", context);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function seek_percent({ argument, ...context }) {
|
|
339
|
+
return await applySeek(normalizeSeekPercent(argument), context);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function volume({ argument, ...context }) {
|
|
343
|
+
return await applyVolume(normalizeAbsoluteVolume(argument), context);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function volume_delta({ argument, ...context }) {
|
|
347
|
+
if (!Number.isInteger(argument) || argument < -100 || argument > 100) {
|
|
348
|
+
throw new Error("music-player volume_delta requires an integer -100..100");
|
|
349
|
+
}
|
|
350
|
+
const playback = await readPlayback(context.state.adapter, context.run);
|
|
351
|
+
const current = playback.volume;
|
|
352
|
+
if (!Number.isInteger(current)) {
|
|
353
|
+
throw new Error("music-player current volume is unavailable");
|
|
354
|
+
}
|
|
355
|
+
return await applyVolume(Math.max(0, Math.min(100, current + argument)), context);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function stop(context) {
|
|
359
|
+
return await apply("stop", context);
|
|
360
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"async": true,
|
|
3
|
+
"singleton": true,
|
|
3
4
|
"args": [
|
|
4
|
-
"command:enum(play,pause,resume,toggle,next,previous,stop,status)",
|
|
5
5
|
"source:string",
|
|
6
6
|
"loop:bool",
|
|
7
7
|
"volume:int",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"state_dir:path"
|
|
10
10
|
],
|
|
11
11
|
"defaults": {
|
|
12
|
-
"command": "play",
|
|
13
12
|
"loop": "true",
|
|
14
13
|
"volume": "70",
|
|
15
14
|
"player": "auto"
|
|
@@ -21,8 +20,10 @@
|
|
|
21
20
|
"toggle",
|
|
22
21
|
"next",
|
|
23
22
|
"previous",
|
|
23
|
+
"seek",
|
|
24
|
+
"volume",
|
|
24
25
|
"stop",
|
|
25
26
|
"status"
|
|
26
27
|
],
|
|
27
|
-
"template": "{skill_dir}/scripts/
|
|
28
|
+
"template": "{skill_dir}/scripts/playback.mjs play {source} {loop} {volume} {player} {state_dir}"
|
|
28
29
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Actor-neutral client for the local Music Player playback protocol.
|
|
5
|
+
*
|
|
6
|
+
* Reads portable JSON status and sends bounded generation-fenced commands to
|
|
7
|
+
* the playback service endpoint without reading pi-actors Run or Control state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { createConnection } from "node:net";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const ACTIONS = new Set([
|
|
16
|
+
"play",
|
|
17
|
+
"resume",
|
|
18
|
+
"pause",
|
|
19
|
+
"toggle",
|
|
20
|
+
"next",
|
|
21
|
+
"previous",
|
|
22
|
+
"seek",
|
|
23
|
+
"volume",
|
|
24
|
+
"stop",
|
|
25
|
+
"status",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function fail(message, code = 1) {
|
|
29
|
+
console.error(`music-player: ${message}`);
|
|
30
|
+
process.exit(code);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function expandPath(value) {
|
|
34
|
+
if (value === "~") return homedir();
|
|
35
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJson(path, fallback) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parsePercent(value, label) {
|
|
48
|
+
if (!/^\d+$/.test(String(value))) {
|
|
49
|
+
fail(`${label} percent must be an integer 0..100`, 2);
|
|
50
|
+
}
|
|
51
|
+
const percent = Number(value);
|
|
52
|
+
if (percent < 0 || percent > 100) {
|
|
53
|
+
fail(`${label} percent must be an integer 0..100`, 2);
|
|
54
|
+
}
|
|
55
|
+
return percent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function projectCurrentProgress(status, nowMs = Date.now()) {
|
|
59
|
+
const duration = Number(status.duration_seconds);
|
|
60
|
+
let position = Number(status.position_seconds);
|
|
61
|
+
const updatedAtMs = Number(status.position_updated_at_ms);
|
|
62
|
+
if (!Number.isFinite(position)) position = 0;
|
|
63
|
+
if (status.state === "playing" && Number.isFinite(updatedAtMs)) {
|
|
64
|
+
position += Math.max(0, nowMs - updatedAtMs) / 1000;
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
67
|
+
return { ...status, progress_percent: null };
|
|
68
|
+
}
|
|
69
|
+
position = Math.min(Math.max(position, 0), duration);
|
|
70
|
+
return {
|
|
71
|
+
...status,
|
|
72
|
+
progress_percent: Math.round((position / duration) * 100),
|
|
73
|
+
position_seconds: position,
|
|
74
|
+
position_updated_at_ms: nowMs,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function sendCommand(endpoint, action, input) {
|
|
79
|
+
const payload = `${JSON.stringify({
|
|
80
|
+
action,
|
|
81
|
+
...(input !== undefined ? { input } : {}),
|
|
82
|
+
service_instance_id: endpoint.service_instance_id,
|
|
83
|
+
})}\n`;
|
|
84
|
+
const response = await new Promise((resolveResponse, rejectResponse) => {
|
|
85
|
+
const socket = createConnection(endpoint.path);
|
|
86
|
+
let content = "";
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
socket.destroy();
|
|
89
|
+
rejectResponse(new Error("playback service command timed out"));
|
|
90
|
+
}, 5_000);
|
|
91
|
+
timeout.unref?.();
|
|
92
|
+
socket.setEncoding("utf8");
|
|
93
|
+
socket.on("connect", () => socket.write(payload));
|
|
94
|
+
socket.on("data", (chunk) => {
|
|
95
|
+
content += chunk;
|
|
96
|
+
if (Buffer.byteLength(content, "utf8") > 4096) {
|
|
97
|
+
socket.destroy(new Error("playback service response is too large"));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
socket.on("error", rejectResponse);
|
|
101
|
+
socket.on("end", () => {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
try {
|
|
104
|
+
resolveResponse(JSON.parse(content.trim()));
|
|
105
|
+
} catch {
|
|
106
|
+
rejectResponse(new Error("playback service returned invalid JSON"));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
if (!response?.ok) {
|
|
111
|
+
throw new Error(response?.error || "playback service rejected the command");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const [action = "status", rawStateDir, ...actionArgs] = process.argv.slice(2);
|
|
116
|
+
if (!ACTIONS.has(action) || !rawStateDir) {
|
|
117
|
+
fail("usage: playback-client.mjs <status|play|resume|pause|toggle|next|previous|seek|volume|stop> <state-dir> [action args]", 2);
|
|
118
|
+
}
|
|
119
|
+
const stateDir = expandPath(rawStateDir);
|
|
120
|
+
if (action === "status") {
|
|
121
|
+
const status = readJson(join(stateDir, "player.json"), { state: "unknown" });
|
|
122
|
+
process.stdout.write(`${JSON.stringify(projectCurrentProgress(status))}\n`);
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
const endpointPath = join(stateDir, "playback-endpoint.json");
|
|
126
|
+
if (!existsSync(endpointPath)) fail(`playback service is not active: ${stateDir}`, 3);
|
|
127
|
+
const endpoint = readJson(endpointPath, {});
|
|
128
|
+
if (
|
|
129
|
+
typeof endpoint.path !== "string" ||
|
|
130
|
+
!endpoint.path ||
|
|
131
|
+
typeof endpoint.service_instance_id !== "string" ||
|
|
132
|
+
!endpoint.service_instance_id
|
|
133
|
+
) fail(`playback service endpoint is invalid: ${stateDir}`, 3);
|
|
134
|
+
let input;
|
|
135
|
+
if (action === "seek" || action === "volume") {
|
|
136
|
+
input = { percent: parsePercent(actionArgs[0], action) };
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await sendCommand(endpoint, action === "resume" ? "play" : action, input);
|
|
140
|
+
console.log(`music-player: command=${action} handled state_dir=${stateDir}`);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
fail(error instanceof Error ? error.message : String(error), 3);
|
|
143
|
+
}
|