@llblab/pi-actors 0.48.1 → 0.49.0
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 +12 -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 +53 -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 +53 -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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Packaged local music
|
|
4
|
+
* Packaged persistent local music playback controlled-service helper.
|
|
5
5
|
*
|
|
6
|
-
* This script backs the
|
|
6
|
+
* This script backs the singleton music-player/playback Recipe. It scans local music
|
|
7
7
|
* sources, builds playback queues, launches an available backend player, and
|
|
8
8
|
* consumes generation-bound Controls such as play, pause, next, previous,
|
|
9
9
|
* stop, and status.
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
16
17
|
import {
|
|
17
18
|
accessSync,
|
|
18
19
|
constants,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
mkdirSync,
|
|
21
22
|
readdirSync,
|
|
22
23
|
readFileSync,
|
|
24
|
+
renameSync,
|
|
23
25
|
rmSync,
|
|
24
26
|
statSync,
|
|
25
27
|
watch,
|
|
@@ -55,9 +57,27 @@ async function importRuntimeModule(name) {
|
|
|
55
57
|
return await import(pathToFileURL(module).href);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const actorAdapterEnabled = process.argv[2] !== "serve";
|
|
61
|
+
let appendRunControlInStateDir;
|
|
62
|
+
let claimRunControlByIdInStateDir;
|
|
63
|
+
let processRunControlsInStateDir;
|
|
64
|
+
let readRunControlJournalFromStateDir;
|
|
65
|
+
let updateRunControlStatusInStateDir;
|
|
66
|
+
let isAlive;
|
|
67
|
+
let verifyRunProcessIdentity;
|
|
68
|
+
let appendRunTraceEvent = () => {};
|
|
69
|
+
if (actorAdapterEnabled) {
|
|
70
|
+
({
|
|
71
|
+
appendRunControlInStateDir,
|
|
72
|
+
claimRunControlByIdInStateDir,
|
|
73
|
+
processRunControlsInStateDir,
|
|
74
|
+
readRunControlJournalFromStateDir,
|
|
75
|
+
updateRunControlStatusInStateDir,
|
|
76
|
+
} = await importRuntimeModule("runs-controls"));
|
|
77
|
+
({ isAlive, verifyRunProcessIdentity } =
|
|
78
|
+
await importRuntimeModule("runs-process"));
|
|
79
|
+
({ appendRunTraceEvent } = await importRuntimeModule("runs-trace"));
|
|
80
|
+
}
|
|
61
81
|
|
|
62
82
|
const AUDIO_EXTENSIONS = new Set([
|
|
63
83
|
".aac",
|
|
@@ -77,15 +97,17 @@ const CONTROL_COMMANDS = new Set([
|
|
|
77
97
|
"toggle",
|
|
78
98
|
"next",
|
|
79
99
|
"previous",
|
|
100
|
+
"seek",
|
|
101
|
+
"volume",
|
|
80
102
|
"stop",
|
|
81
103
|
"status",
|
|
82
104
|
]);
|
|
83
|
-
|
|
84
105
|
function usage() {
|
|
85
106
|
console.error(`Usage:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
107
|
+
playback.mjs <play|serve> <source-file-dir-url-playlist-or-list> [loop=true] [volume=70] [player=auto] [state-dir]
|
|
108
|
+
playback.mjs <pause|resume|toggle|next|previous|stop|status> <state-dir>
|
|
109
|
+
playback.mjs <seek|volume> <state-dir> <percent>
|
|
110
|
+
playback.mjs control <state-dir> <play|pause|toggle|next|previous|seek|volume|stop|status> [percent]
|
|
89
111
|
|
|
90
112
|
Runs a foreground music player so pi-actors can own it as a controlled Run.
|
|
91
113
|
Controls use canonical records in <state-dir>/controls.jsonl.
|
|
@@ -121,6 +143,16 @@ function exists(path) {
|
|
|
121
143
|
}
|
|
122
144
|
}
|
|
123
145
|
|
|
146
|
+
function isLocalProcessAlive(pid) {
|
|
147
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return error?.code === "EPERM";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
124
156
|
function isDirectory(path) {
|
|
125
157
|
try {
|
|
126
158
|
return statSync(path).isDirectory();
|
|
@@ -176,9 +208,35 @@ function parseBool(value) {
|
|
|
176
208
|
}
|
|
177
209
|
}
|
|
178
210
|
|
|
211
|
+
function parsePercent(value, label) {
|
|
212
|
+
const candidate =
|
|
213
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
214
|
+
? value.percent
|
|
215
|
+
: value;
|
|
216
|
+
if (!/^\d+$/.test(String(candidate))) {
|
|
217
|
+
throw new Error(`${label} percent must be an integer 0..100`);
|
|
218
|
+
}
|
|
219
|
+
const percent = Number(candidate);
|
|
220
|
+
if (percent < 0 || percent > 100) {
|
|
221
|
+
throw new Error(`${label} percent must be an integer 0..100`);
|
|
222
|
+
}
|
|
223
|
+
return percent;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseSeekPercent(value) {
|
|
227
|
+
return parsePercent(value, "seek");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseVolumePercent(value) {
|
|
231
|
+
return parsePercent(value, "volume");
|
|
232
|
+
}
|
|
233
|
+
|
|
179
234
|
function normalizeVolume(value) {
|
|
180
|
-
|
|
181
|
-
|
|
235
|
+
try {
|
|
236
|
+
return parseVolumePercent(value);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
fail(error instanceof Error ? error.message : String(error), 2);
|
|
239
|
+
}
|
|
182
240
|
}
|
|
183
241
|
|
|
184
242
|
function powershellCommand() {
|
|
@@ -359,7 +417,28 @@ try {
|
|
|
359
417
|
];
|
|
360
418
|
}
|
|
361
419
|
|
|
362
|
-
function
|
|
420
|
+
function probeDurationSeconds(track) {
|
|
421
|
+
if (!have("ffprobe")) return undefined;
|
|
422
|
+
const result = spawnSync(
|
|
423
|
+
"ffprobe",
|
|
424
|
+
[
|
|
425
|
+
"-v",
|
|
426
|
+
"error",
|
|
427
|
+
"-show_entries",
|
|
428
|
+
"format=duration",
|
|
429
|
+
"-of",
|
|
430
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
431
|
+
track,
|
|
432
|
+
],
|
|
433
|
+
{ encoding: "utf8", timeout: 5_000, windowsHide: true },
|
|
434
|
+
);
|
|
435
|
+
const duration = Number(String(result.stdout).trim());
|
|
436
|
+
return result.status === 0 && Number.isFinite(duration) && duration > 0
|
|
437
|
+
? duration
|
|
438
|
+
: undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function playerCommand(ctx, player, volume, track, startSeconds = 0) {
|
|
363
442
|
switch (player) {
|
|
364
443
|
case "mpv":
|
|
365
444
|
return [
|
|
@@ -369,6 +448,7 @@ function playerCommand(ctx, player, volume, track) {
|
|
|
369
448
|
"--really-quiet",
|
|
370
449
|
"--force-window=no",
|
|
371
450
|
`--volume=${volume}`,
|
|
451
|
+
...(startSeconds > 0 ? [`--start=${startSeconds}`] : []),
|
|
372
452
|
track,
|
|
373
453
|
],
|
|
374
454
|
];
|
|
@@ -388,6 +468,7 @@ function playerCommand(ctx, player, volume, track) {
|
|
|
388
468
|
"-autoexit",
|
|
389
469
|
"-volume",
|
|
390
470
|
String(volume),
|
|
471
|
+
...(startSeconds > 0 ? ["-ss", String(startSeconds)] : []),
|
|
391
472
|
track,
|
|
392
473
|
],
|
|
393
474
|
];
|
|
@@ -401,11 +482,15 @@ function playerCommand(ctx, player, volume, track) {
|
|
|
401
482
|
"--play-and-exit",
|
|
402
483
|
"--volume",
|
|
403
484
|
String(Math.floor((volume * 256) / 100)),
|
|
485
|
+
...(startSeconds > 0 ? ["--start-time", String(startSeconds)] : []),
|
|
404
486
|
track,
|
|
405
487
|
],
|
|
406
488
|
];
|
|
407
489
|
case "play":
|
|
408
|
-
return [
|
|
490
|
+
return [
|
|
491
|
+
"play",
|
|
492
|
+
["-q", track, ...(startSeconds > 0 ? ["trim", String(startSeconds)] : [])],
|
|
493
|
+
];
|
|
409
494
|
case "wmp":
|
|
410
495
|
return windowsMediaPlayerCommand(ctx, volume, track);
|
|
411
496
|
default:
|
|
@@ -448,20 +533,60 @@ function emitStoppedEvent(ctx, reason = "stop") {
|
|
|
448
533
|
emitPlayerEvent(ctx, "player.stopped", "Music player stopped", { reason });
|
|
449
534
|
}
|
|
450
535
|
|
|
536
|
+
function positionSnapshot(ctx, state, nowMs = Date.now()) {
|
|
537
|
+
const duration = Number(ctx.durationSeconds);
|
|
538
|
+
let position = Number(ctx.positionSeconds ?? 0);
|
|
539
|
+
if (state === "playing" && Number.isFinite(ctx.positionStartedAtMs)) {
|
|
540
|
+
position += Math.max(0, nowMs - ctx.positionStartedAtMs) / 1000;
|
|
541
|
+
}
|
|
542
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
543
|
+
position = Math.min(Math.max(position, 0), duration);
|
|
544
|
+
} else {
|
|
545
|
+
position = Math.max(position, 0);
|
|
546
|
+
}
|
|
547
|
+
const progressPercent = Number.isFinite(duration) && duration > 0
|
|
548
|
+
? Math.min(100, Math.max(0, (position / duration) * 100))
|
|
549
|
+
: undefined;
|
|
550
|
+
return { duration, position, progressPercent, updatedAtMs: nowMs };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function freezePosition(ctx) {
|
|
554
|
+
const snapshot = positionSnapshot(ctx, "playing");
|
|
555
|
+
ctx.positionSeconds = snapshot.position;
|
|
556
|
+
ctx.positionStartedAtMs = undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function resumePosition(ctx) {
|
|
560
|
+
ctx.positionStartedAtMs = Date.now();
|
|
561
|
+
}
|
|
562
|
+
|
|
451
563
|
function writeStatus(ctx, state, index, count, track, player, pid = "") {
|
|
452
564
|
const updatedAt = new Date().toISOString();
|
|
565
|
+
const position = positionSnapshot(ctx, state);
|
|
453
566
|
writeText(
|
|
454
567
|
ctx.statusFile,
|
|
455
|
-
`state=${state}\nindex=${index}\ncount=${count}\ntrack=${track}\nplayer=${player}\npid=${pid}\nupdated_at=${updatedAt}\n`,
|
|
568
|
+
`state=${state}\nindex=${index}\ncount=${count}\ntrack=${track}\nplayer=${player}\nvolume=${ctx.volume}\nseek_percent=${ctx.seekPercent ?? 0}\nprogress_percent=${position.progressPercent === undefined ? "" : Math.round(position.progressPercent)}\npid=${pid}\nupdated_at=${updatedAt}\n`,
|
|
456
569
|
);
|
|
457
570
|
writeText(
|
|
458
571
|
ctx.statusJsonFile,
|
|
459
|
-
`${JSON.stringify({ state, index, count, track, player, pid: String(pid), updated_at: updatedAt })}\n`,
|
|
572
|
+
`${JSON.stringify({ state, index, count, track, player, volume: ctx.volume, seek_percent: ctx.seekPercent ?? 0, progress_percent: position.progressPercent === undefined ? null : Math.round(position.progressPercent), position_seconds: position.position, duration_seconds: Number.isFinite(position.duration) ? position.duration : null, position_updated_at_ms: position.updatedAtMs, pid: String(pid), updated_at: updatedAt })}\n`,
|
|
460
573
|
);
|
|
461
|
-
ctx.current = {
|
|
574
|
+
ctx.current = {
|
|
575
|
+
count,
|
|
576
|
+
index,
|
|
577
|
+
pid: String(pid),
|
|
578
|
+
player,
|
|
579
|
+
state,
|
|
580
|
+
track,
|
|
581
|
+
volume: ctx.volume,
|
|
582
|
+
seekPercent: ctx.seekPercent ?? 0,
|
|
583
|
+
progressPercent: position.progressPercent,
|
|
584
|
+
};
|
|
585
|
+
writePlaybackCheckpoint(ctx, state, index, track);
|
|
462
586
|
}
|
|
463
587
|
|
|
464
588
|
function setState(ctx, state) {
|
|
589
|
+
ctx.desiredState = state;
|
|
465
590
|
writeText(ctx.stateFile, state);
|
|
466
591
|
if (ctx.current) {
|
|
467
592
|
writeStatus(
|
|
@@ -514,12 +639,91 @@ function controlCurrentPlayback(ctx, command) {
|
|
|
514
639
|
sendSignalToCurrent(ctx, "SIGSTOP");
|
|
515
640
|
break;
|
|
516
641
|
case "stop":
|
|
642
|
+
sendSignalToCurrent(ctx, "SIGCONT");
|
|
517
643
|
sendSignalToCurrent(ctx, "SIGTERM");
|
|
518
644
|
break;
|
|
519
645
|
}
|
|
520
646
|
}
|
|
521
647
|
|
|
522
|
-
function
|
|
648
|
+
function pipeWireAudioStreamIds() {
|
|
649
|
+
if (process.platform !== "linux" || !have("wpctl")) return [];
|
|
650
|
+
const result = spawnSync("wpctl", ["status", "-n"], {
|
|
651
|
+
encoding: "utf8",
|
|
652
|
+
windowsHide: true,
|
|
653
|
+
});
|
|
654
|
+
if (result.status !== 0) return [];
|
|
655
|
+
const audio = (String(result.stdout).split(/\nVideo\n/u)[0] ?? "");
|
|
656
|
+
const streams = audio.split(/\n\s*└─ Streams:\s*\n/u)[1] ?? "";
|
|
657
|
+
return [...streams.matchAll(/^\s+(\d+)\.\s/gmu)]
|
|
658
|
+
.map((match) => Number(match[1]))
|
|
659
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function wpctlInspect(id) {
|
|
663
|
+
const result = spawnSync("wpctl", ["inspect", String(id)], {
|
|
664
|
+
encoding: "utf8",
|
|
665
|
+
windowsHide: true,
|
|
666
|
+
});
|
|
667
|
+
return result.status === 0 ? String(result.stdout) : "";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function findPipeWireAudioStreamForPid(pid) {
|
|
671
|
+
for (const nodeId of pipeWireAudioStreamIds()) {
|
|
672
|
+
const node = wpctlInspect(nodeId);
|
|
673
|
+
if (!/media\.class = "Stream\/Output\/Audio"/u.test(node)) continue;
|
|
674
|
+
const clientId = Number(node.match(/client\.id = "(\d+)"/u)?.[1]);
|
|
675
|
+
if (!Number.isInteger(clientId) || clientId <= 0) continue;
|
|
676
|
+
const client = wpctlInspect(clientId);
|
|
677
|
+
const processId = Number(client.match(/application\.process\.id = "(\d+)"/u)?.[1]);
|
|
678
|
+
if (processId === pid) return nodeId;
|
|
679
|
+
}
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function setCurrentPlaybackVolume(ctx, percent) {
|
|
684
|
+
const pid = Number(ctx.child?.pid);
|
|
685
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
686
|
+
const cached = ctx.pipeWireVolumeTarget;
|
|
687
|
+
const nodeId = cached?.pid === pid
|
|
688
|
+
? cached.nodeId
|
|
689
|
+
: findPipeWireAudioStreamForPid(pid);
|
|
690
|
+
if (!Number.isInteger(nodeId) || nodeId <= 0) return false;
|
|
691
|
+
const result = spawnSync(
|
|
692
|
+
"wpctl",
|
|
693
|
+
["set-volume", String(nodeId), `${percent}%`],
|
|
694
|
+
{ encoding: "utf8", windowsHide: true },
|
|
695
|
+
);
|
|
696
|
+
if (result.status !== 0) {
|
|
697
|
+
ctx.pipeWireVolumeTarget = undefined;
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
ctx.pipeWireVolumeTarget = { nodeId, pid };
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function seekCurrentPlayback(ctx, percent) {
|
|
705
|
+
if (!["mpv", "ffplay", "cvlc", "play"].includes(ctx.player)) {
|
|
706
|
+
throw new Error(`player ${ctx.player} does not support percentage seeking`);
|
|
707
|
+
}
|
|
708
|
+
const track = ctx.current?.track;
|
|
709
|
+
if (!track) throw new Error("current track is unavailable for seeking");
|
|
710
|
+
const duration = probeDurationSeconds(track);
|
|
711
|
+
if (duration === undefined) {
|
|
712
|
+
throw new Error("current track duration is unavailable for seeking");
|
|
713
|
+
}
|
|
714
|
+
ctx.seekPercent = percent;
|
|
715
|
+
ctx.seekSeconds = (duration * percent) / 100;
|
|
716
|
+
writeText(ctx.commandFile, "seek");
|
|
717
|
+
emitPlayerEvent(
|
|
718
|
+
ctx,
|
|
719
|
+
"player.seek",
|
|
720
|
+
`Music player seek set to ${percent}%`,
|
|
721
|
+
{ percent, seconds: ctx.seekSeconds },
|
|
722
|
+
);
|
|
723
|
+
controlCurrentPlayback(ctx, "stop");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function handleControl(ctx, input, controlInput) {
|
|
523
727
|
const command = input.trim().toLowerCase();
|
|
524
728
|
if (!command) return;
|
|
525
729
|
if (!CONTROL_COMMANDS.has(command)) {
|
|
@@ -529,19 +733,23 @@ function handleControl(ctx, input) {
|
|
|
529
733
|
switch (command) {
|
|
530
734
|
case "play":
|
|
531
735
|
case "resume":
|
|
736
|
+
resumePosition(ctx);
|
|
532
737
|
setState(ctx, "playing");
|
|
533
738
|
controlCurrentPlayback(ctx, "play");
|
|
534
739
|
break;
|
|
535
740
|
case "pause":
|
|
741
|
+
freezePosition(ctx);
|
|
536
742
|
setState(ctx, "paused");
|
|
537
743
|
controlCurrentPlayback(ctx, "pause");
|
|
538
744
|
break;
|
|
539
745
|
case "toggle": {
|
|
540
746
|
const current = readText(ctx.stateFile).trim();
|
|
541
747
|
if (current === "paused") {
|
|
748
|
+
resumePosition(ctx);
|
|
542
749
|
setState(ctx, "playing");
|
|
543
750
|
controlCurrentPlayback(ctx, "play");
|
|
544
751
|
} else {
|
|
752
|
+
freezePosition(ctx);
|
|
545
753
|
setState(ctx, "paused");
|
|
546
754
|
controlCurrentPlayback(ctx, "pause");
|
|
547
755
|
}
|
|
@@ -556,6 +764,36 @@ function handleControl(ctx, input) {
|
|
|
556
764
|
writeText(ctx.commandFile, "previous");
|
|
557
765
|
controlCurrentPlayback(ctx, "stop");
|
|
558
766
|
break;
|
|
767
|
+
case "seek":
|
|
768
|
+
seekCurrentPlayback(ctx, parseSeekPercent(controlInput));
|
|
769
|
+
break;
|
|
770
|
+
case "volume": {
|
|
771
|
+
const percent = parseVolumePercent(controlInput);
|
|
772
|
+
ctx.volume = percent;
|
|
773
|
+
if (ctx.current) {
|
|
774
|
+
writeStatus(
|
|
775
|
+
ctx,
|
|
776
|
+
ctx.current.state,
|
|
777
|
+
ctx.current.index,
|
|
778
|
+
ctx.current.count,
|
|
779
|
+
ctx.current.track,
|
|
780
|
+
ctx.current.player,
|
|
781
|
+
ctx.current.pid,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
const adjustedInPlace = setCurrentPlaybackVolume(ctx, percent);
|
|
785
|
+
emitPlayerEvent(
|
|
786
|
+
ctx,
|
|
787
|
+
"player.volume",
|
|
788
|
+
`Music player volume set to ${percent}%`,
|
|
789
|
+
{ adjusted_in_place: adjustedInPlace, percent },
|
|
790
|
+
);
|
|
791
|
+
if (ctx.child?.pid && !adjustedInPlace) {
|
|
792
|
+
writeText(ctx.commandFile, "replay");
|
|
793
|
+
controlCurrentPlayback(ctx, "stop");
|
|
794
|
+
}
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
559
797
|
case "stop":
|
|
560
798
|
ctx.stopping = true;
|
|
561
799
|
writeText(ctx.commandFile, "stop");
|
|
@@ -579,7 +817,124 @@ function readJsonFile(path, fallback) {
|
|
|
579
817
|
}
|
|
580
818
|
|
|
581
819
|
function writeJsonFile(path, value) {
|
|
582
|
-
|
|
820
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
821
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
822
|
+
renameSync(temporary, path);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function readPlaybackCheckpoint(path) {
|
|
826
|
+
if (!existsSync(path)) return { checkpoint: undefined, state: "new" };
|
|
827
|
+
let raw;
|
|
828
|
+
try {
|
|
829
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
830
|
+
} catch {
|
|
831
|
+
return { checkpoint: undefined, recovery: "invalid-json", state: "recovered" };
|
|
832
|
+
}
|
|
833
|
+
const checkpoint = normalizeCheckpoint(raw);
|
|
834
|
+
return checkpoint
|
|
835
|
+
? { checkpoint, state: "valid" }
|
|
836
|
+
: { checkpoint: undefined, recovery: "invalid-shape", state: "recovered" };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function normalizeCheckpoint(value) {
|
|
840
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
841
|
+
if (
|
|
842
|
+
value.version !== 1 ||
|
|
843
|
+
typeof value.source !== "string" ||
|
|
844
|
+
!["playing", "paused", "stopped"].includes(value.state) ||
|
|
845
|
+
!Array.isArray(value.tracks) ||
|
|
846
|
+
value.tracks.length === 0 ||
|
|
847
|
+
value.tracks.some((track) => typeof track !== "string" || !track)
|
|
848
|
+
) return undefined;
|
|
849
|
+
const index = Number(value.index);
|
|
850
|
+
if (!Number.isInteger(index) || index < 0 || index >= value.tracks.length)
|
|
851
|
+
return undefined;
|
|
852
|
+
return { ...value, index, tracks: value.tracks };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function writePlaybackCheckpoint(ctx, state, index, track) {
|
|
856
|
+
if (!ctx.playbackFile || !ctx.tracks?.length) return;
|
|
857
|
+
writeJsonFile(ctx.playbackFile, {
|
|
858
|
+
version: 1,
|
|
859
|
+
source: ctx.source,
|
|
860
|
+
tracks: ctx.tracks,
|
|
861
|
+
index,
|
|
862
|
+
track,
|
|
863
|
+
state,
|
|
864
|
+
loop: ctx.loop,
|
|
865
|
+
volume: ctx.volume,
|
|
866
|
+
seek_percent: ctx.seekPercent ?? 0,
|
|
867
|
+
player: ctx.player,
|
|
868
|
+
updated_at: new Date().toISOString(),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function writePlaybackEndpoint(ctx, path) {
|
|
873
|
+
writeJsonFile(join(ctx.stateDir, "playback-endpoint.json"), {
|
|
874
|
+
owner_mode: ctx.ownerMode,
|
|
875
|
+
path,
|
|
876
|
+
service_instance_id: ctx.serviceInstanceId,
|
|
877
|
+
service_pid: process.pid,
|
|
878
|
+
type: "named-pipe",
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function startPlaybackProtocolServer(ctx) {
|
|
883
|
+
const path = process.platform === "win32"
|
|
884
|
+
? `\\\\.\\pipe\\pi-music-player-${ctx.serviceInstanceId}`
|
|
885
|
+
: join(ctx.stateDir, "playback.sock");
|
|
886
|
+
if (process.platform !== "win32") rmSync(path, { force: true });
|
|
887
|
+
const server = createServer((socket) => {
|
|
888
|
+
let content = "";
|
|
889
|
+
let handled = false;
|
|
890
|
+
const respond = () => {
|
|
891
|
+
if (handled) return;
|
|
892
|
+
handled = true;
|
|
893
|
+
try {
|
|
894
|
+
const wire = JSON.parse(content.trim());
|
|
895
|
+
if (
|
|
896
|
+
!wire ||
|
|
897
|
+
typeof wire !== "object" ||
|
|
898
|
+
Array.isArray(wire) ||
|
|
899
|
+
wire.service_instance_id !== ctx.serviceInstanceId ||
|
|
900
|
+
typeof wire.action !== "string" ||
|
|
901
|
+
!CONTROL_COMMANDS.has(wire.action)
|
|
902
|
+
) throw new Error("invalid playback protocol command");
|
|
903
|
+
handleControl(ctx, wire.action, wire.input);
|
|
904
|
+
socket.end(`${JSON.stringify({ ok: true })}\n`);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
socket.end(`${JSON.stringify({
|
|
907
|
+
error: error instanceof Error ? error.message : String(error),
|
|
908
|
+
ok: false,
|
|
909
|
+
})}\n`);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
socket.setEncoding("utf8");
|
|
913
|
+
socket.on("data", (chunk) => {
|
|
914
|
+
content += chunk;
|
|
915
|
+
if (Buffer.byteLength(content, "utf8") > 4096) {
|
|
916
|
+
handled = true;
|
|
917
|
+
socket.destroy();
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (content.includes("\n")) respond();
|
|
921
|
+
});
|
|
922
|
+
socket.on("end", respond);
|
|
923
|
+
socket.resume();
|
|
924
|
+
});
|
|
925
|
+
await new Promise((resolveReady, rejectReady) => {
|
|
926
|
+
server.once("error", rejectReady);
|
|
927
|
+
server.listen(path, () => {
|
|
928
|
+
server.off("error", rejectReady);
|
|
929
|
+
writePlaybackEndpoint(ctx, path);
|
|
930
|
+
resolveReady();
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
return () => {
|
|
934
|
+
server.close();
|
|
935
|
+
rmSync(join(ctx.stateDir, "playback-endpoint.json"), { force: true });
|
|
936
|
+
if (process.platform !== "win32") rmSync(path, { force: true });
|
|
937
|
+
};
|
|
583
938
|
}
|
|
584
939
|
|
|
585
940
|
async function startControlServer(ctx, wakeControlLoop) {
|
|
@@ -598,7 +953,7 @@ async function startControlServer(ctx, wakeControlLoop) {
|
|
|
598
953
|
try {
|
|
599
954
|
const claimed = claimControl(ctx, JSON.parse(line));
|
|
600
955
|
if (!claimed) continue;
|
|
601
|
-
handleControl(ctx, claimed.
|
|
956
|
+
handleControl(ctx, claimed.action, claimed.input);
|
|
602
957
|
finalizeControl(ctx, claimed.id, "handled");
|
|
603
958
|
} catch (error) {
|
|
604
959
|
const id = (() => { try { return JSON.parse(line).id; } catch { return undefined; } })();
|
|
@@ -624,6 +979,7 @@ async function startControlServer(ctx, wakeControlLoop) {
|
|
|
624
979
|
});
|
|
625
980
|
return () => {
|
|
626
981
|
server.close();
|
|
982
|
+
rmSync(join(ctx.stateDir, "control-endpoint.json"), { force: true });
|
|
627
983
|
if (process.platform !== "win32") rmSync(path, { force: true });
|
|
628
984
|
};
|
|
629
985
|
}
|
|
@@ -634,10 +990,11 @@ function commandFromControl(control) {
|
|
|
634
990
|
return CONTROL_COMMANDS.has(action) ? action : undefined;
|
|
635
991
|
}
|
|
636
992
|
|
|
637
|
-
function appendControl(ctx, action) {
|
|
993
|
+
function appendControl(ctx, action, input) {
|
|
638
994
|
const run = readJsonFile(runJsonFile(ctx), {});
|
|
639
|
-
appendRunControlInStateDir(ctx.stateDir, {
|
|
995
|
+
return appendRunControlInStateDir(ctx.stateDir, {
|
|
640
996
|
action,
|
|
997
|
+
...(input !== undefined ? { input } : {}),
|
|
641
998
|
run_instance_id: run.run_instance_id,
|
|
642
999
|
});
|
|
643
1000
|
}
|
|
@@ -646,13 +1003,15 @@ function claimControl(ctx, wire) {
|
|
|
646
1003
|
const control = claimRunControlByIdInStateDir(
|
|
647
1004
|
ctx.stateDir, ctx.runInstanceId, wire.id,
|
|
648
1005
|
);
|
|
649
|
-
const
|
|
650
|
-
if (!
|
|
1006
|
+
const action = control ? commandFromControl(control) : undefined;
|
|
1007
|
+
if (!action && control) {
|
|
651
1008
|
updateRunControlStatusInStateDir(ctx.stateDir, control.id, "failed", {
|
|
652
1009
|
error: "Unsupported music-player Control",
|
|
653
1010
|
}, ["claimed"]);
|
|
654
1011
|
}
|
|
655
|
-
return
|
|
1012
|
+
return action && control
|
|
1013
|
+
? { action, id: control.id, input: control.input }
|
|
1014
|
+
: undefined;
|
|
656
1015
|
}
|
|
657
1016
|
|
|
658
1017
|
function finalizeControl(ctx, id, status, error) {
|
|
@@ -700,8 +1059,19 @@ function startControlLoop(ctx) {
|
|
|
700
1059
|
const signature = controlsSignature(ctx);
|
|
701
1060
|
if (dirty || signature !== lastSignature) {
|
|
702
1061
|
dirty = false;
|
|
1062
|
+
if (ctx.runInstanceId) {
|
|
1063
|
+
await processRunControlsInStateDir(
|
|
1064
|
+
ctx.stateDir,
|
|
1065
|
+
ctx.runInstanceId,
|
|
1066
|
+
(control) => {
|
|
1067
|
+
const action = commandFromControl(control);
|
|
1068
|
+
if (!action) throw new Error("Unsupported music-player Control");
|
|
1069
|
+
handleControl(ctx, action, control.input);
|
|
1070
|
+
},
|
|
1071
|
+
8,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
703
1074
|
lastSignature = controlsSignature(ctx);
|
|
704
|
-
continue;
|
|
705
1075
|
}
|
|
706
1076
|
await sleep(250);
|
|
707
1077
|
}
|
|
@@ -709,24 +1079,39 @@ function startControlLoop(ctx) {
|
|
|
709
1079
|
return { close, promise, wake: () => { dirty = true; } };
|
|
710
1080
|
}
|
|
711
1081
|
|
|
712
|
-
function playOne(ctx, player,
|
|
1082
|
+
function playOne(ctx, player, track, index, count) {
|
|
713
1083
|
return new Promise((resolveDone) => {
|
|
714
1084
|
if (ctx.stopping) {
|
|
715
1085
|
resolveDone();
|
|
716
1086
|
return;
|
|
717
1087
|
}
|
|
718
1088
|
rmSync(ctx.playerControlFile, { force: true });
|
|
719
|
-
|
|
1089
|
+
ctx.durationSeconds = probeDurationSeconds(track);
|
|
1090
|
+
ctx.positionSeconds = ctx.seekSeconds ?? 0;
|
|
1091
|
+
ctx.positionStartedAtMs = Date.now();
|
|
1092
|
+
const [command, args] = playerCommand(
|
|
1093
|
+
ctx,
|
|
1094
|
+
player,
|
|
1095
|
+
ctx.volume,
|
|
1096
|
+
track,
|
|
1097
|
+
ctx.seekSeconds ?? 0,
|
|
1098
|
+
);
|
|
720
1099
|
writeStatus(ctx, "playing", index, count, track, player, "");
|
|
721
1100
|
const child = spawn(command, args, {
|
|
722
1101
|
detached: process.platform === "win32",
|
|
723
1102
|
stdio: ["ignore", "inherit", "inherit"],
|
|
724
1103
|
});
|
|
725
1104
|
ctx.child = child;
|
|
1105
|
+
ctx.pipeWireVolumeTarget = undefined;
|
|
726
1106
|
const pid = child.pid || "";
|
|
727
1107
|
if (pid) writeText(ctx.pidFile, String(pid));
|
|
728
1108
|
if (ctx.stopping) controlCurrentPlayback(ctx, "stop");
|
|
729
1109
|
writeStatus(ctx, "playing", index, count, track, player, pid);
|
|
1110
|
+
if (ctx.desiredState === "paused") {
|
|
1111
|
+
controlCurrentPlayback(ctx, "pause");
|
|
1112
|
+
freezePosition(ctx);
|
|
1113
|
+
writeStatus(ctx, "paused", index, count, track, player, pid);
|
|
1114
|
+
}
|
|
730
1115
|
emitTrackEvent(ctx, index, count, track, player);
|
|
731
1116
|
child.once("error", (error) => {
|
|
732
1117
|
console.error(
|
|
@@ -768,41 +1153,82 @@ async function playMain(args) {
|
|
|
768
1153
|
),
|
|
769
1154
|
);
|
|
770
1155
|
mkdirSync(stateDir, { recursive: true });
|
|
771
|
-
const
|
|
1156
|
+
const existingEndpoint = readJsonFile(
|
|
1157
|
+
join(stateDir, "playback-endpoint.json"),
|
|
1158
|
+
{},
|
|
1159
|
+
);
|
|
1160
|
+
if (isLocalProcessAlive(Number(existingEndpoint.service_pid))) {
|
|
1161
|
+
if (actorAdapterEnabled && existingEndpoint.owner_mode === "standalone") {
|
|
1162
|
+
fail(`standalone playback service already owns state: ${stateDir}`, 3);
|
|
1163
|
+
}
|
|
1164
|
+
if (!actorAdapterEnabled) {
|
|
1165
|
+
fail(`active playback service already owns state: ${stateDir}`, 3);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const startupRun = actorAdapterEnabled
|
|
1169
|
+
? readJsonFile(runJsonFile({ stateDir }), {})
|
|
1170
|
+
: {};
|
|
1171
|
+
const source = expandPath(sourceArg);
|
|
1172
|
+
const loop = parseBool(loopArg);
|
|
1173
|
+
const volume = normalizeVolume(volumeArg);
|
|
1174
|
+
const player = selectPlayer(playerArg);
|
|
1175
|
+
const playbackFile = join(stateDir, "playback.json");
|
|
1176
|
+
const checkpointLoad = readPlaybackCheckpoint(playbackFile);
|
|
1177
|
+
const checkpoint = checkpointLoad.checkpoint;
|
|
1178
|
+
const checkpointTracksUsable =
|
|
1179
|
+
checkpoint?.source === source &&
|
|
1180
|
+
checkpoint.player === player &&
|
|
1181
|
+
checkpoint.tracks.every((track) => isUrl(track) || exists(track));
|
|
1182
|
+
const tracks = checkpointTracksUsable ? checkpoint.tracks : loadPlaylist(source);
|
|
1183
|
+
let index = checkpointTracksUsable ? checkpoint.index : 0;
|
|
772
1184
|
const ctx = {
|
|
773
1185
|
commandFile: join(stateDir, "command.txt"),
|
|
774
1186
|
current: undefined,
|
|
775
1187
|
controlsFile: join(stateDir, "controls.jsonl"),
|
|
1188
|
+
desiredState:
|
|
1189
|
+
checkpointTracksUsable && checkpoint.state === "paused"
|
|
1190
|
+
? "paused"
|
|
1191
|
+
: "playing",
|
|
1192
|
+
index,
|
|
1193
|
+
loop,
|
|
776
1194
|
pidFile: join(stateDir, "current.pid"),
|
|
1195
|
+
playbackFile,
|
|
1196
|
+
ownerMode: actorAdapterEnabled ? "actor" : "standalone",
|
|
1197
|
+
player,
|
|
777
1198
|
playerControlFile: join(stateDir, "player-control.txt"),
|
|
1199
|
+
positionSeconds: 0,
|
|
1200
|
+
positionStartedAtMs: undefined,
|
|
778
1201
|
runInstanceId: typeof startupRun.run_instance_id === "string"
|
|
779
1202
|
? startupRun.run_instance_id
|
|
780
1203
|
: undefined,
|
|
1204
|
+
seekPercent: 0,
|
|
1205
|
+
seekSeconds: 0,
|
|
1206
|
+
serviceInstanceId: randomUUID(),
|
|
1207
|
+
source,
|
|
781
1208
|
stateDir,
|
|
782
1209
|
stateFile: join(stateDir, "player-state.txt"),
|
|
783
1210
|
statusFile: join(stateDir, "status.txt"),
|
|
784
1211
|
statusJsonFile: join(stateDir, "player.json"),
|
|
785
1212
|
stopping: false,
|
|
1213
|
+
tracks,
|
|
1214
|
+
volume,
|
|
786
1215
|
};
|
|
787
1216
|
rmSync(ctx.commandFile, { force: true });
|
|
788
1217
|
rmSync(ctx.pidFile, { force: true });
|
|
789
1218
|
rmSync(ctx.playerControlFile, { force: true });
|
|
790
|
-
const loop = parseBool(loopArg);
|
|
791
|
-
const volume = normalizeVolume(volumeArg);
|
|
792
|
-
const player = selectPlayer(playerArg);
|
|
793
|
-
const tracks = loadPlaylist(sourceArg);
|
|
794
1219
|
let controlLoop;
|
|
795
1220
|
let closeControlServer;
|
|
1221
|
+
let closePlaybackProtocolServer;
|
|
796
1222
|
const cleanup = () => {
|
|
797
1223
|
ctx.stopping = true;
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
1224
|
+
if (ctx.current) {
|
|
1225
|
+
freezePosition(ctx);
|
|
1226
|
+
writeStatus(ctx, "stopped", index, tracks.length, tracks[index] ?? "", player, "");
|
|
1227
|
+
} else writeText(ctx.stateFile, "stopped");
|
|
1228
|
+
if (ctx.child?.pid) controlCurrentPlayback(ctx, "stop");
|
|
804
1229
|
controlLoop?.close();
|
|
805
1230
|
closeControlServer?.();
|
|
1231
|
+
closePlaybackProtocolServer?.();
|
|
806
1232
|
rmSync(ctx.pidFile, { force: true });
|
|
807
1233
|
rmSync(ctx.playerControlFile, { force: true });
|
|
808
1234
|
};
|
|
@@ -820,30 +1246,56 @@ async function playMain(args) {
|
|
|
820
1246
|
});
|
|
821
1247
|
try {
|
|
822
1248
|
controlLoop = startControlLoop(ctx);
|
|
1249
|
+
closePlaybackProtocolServer = await startPlaybackProtocolServer(ctx);
|
|
823
1250
|
closeControlServer = await startControlServer(ctx, controlLoop.wake);
|
|
824
|
-
|
|
1251
|
+
if (checkpointLoad.recovery) {
|
|
1252
|
+
console.error(
|
|
1253
|
+
`music-player: checkpoint_recovery=${checkpointLoad.recovery} state_dir=${stateDir}`,
|
|
1254
|
+
);
|
|
1255
|
+
emitPlayerEvent(
|
|
1256
|
+
ctx,
|
|
1257
|
+
"player.checkpoint-recovered",
|
|
1258
|
+
"Ignored malformed playback checkpoint and rebuilt the queue",
|
|
1259
|
+
{ reason: checkpointLoad.recovery },
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
setState(ctx, ctx.desiredState);
|
|
1263
|
+
const checkpointState = checkpointTracksUsable
|
|
1264
|
+
? "resumed"
|
|
1265
|
+
: checkpointLoad.recovery
|
|
1266
|
+
? "recovered"
|
|
1267
|
+
: checkpoint
|
|
1268
|
+
? "rebuilt"
|
|
1269
|
+
: "new";
|
|
825
1270
|
console.error(
|
|
826
|
-
`music-player: player=${player} loop=${loop} volume=${volume} tracks=${tracks.length} state_dir=${stateDir}`,
|
|
1271
|
+
`music-player: player=${player} loop=${loop} volume=${ctx.volume} tracks=${tracks.length} checkpoint=${checkpointState} start_index=${index} state_dir=${stateDir}`,
|
|
827
1272
|
);
|
|
828
|
-
let index = 0;
|
|
829
1273
|
const count = tracks.length;
|
|
830
1274
|
while (!ctx.stopping) {
|
|
831
1275
|
const track = tracks[index];
|
|
832
|
-
await playOne(ctx, player,
|
|
1276
|
+
await playOne(ctx, player, track, index, count);
|
|
833
1277
|
const command = readAndClearCommand(ctx);
|
|
834
1278
|
if (command === "stop") {
|
|
1279
|
+
freezePosition(ctx);
|
|
835
1280
|
writeStatus(ctx, "stopped", index, count, track, player, "");
|
|
836
1281
|
emitStoppedEvent(ctx, "message");
|
|
837
1282
|
return;
|
|
838
1283
|
}
|
|
839
1284
|
if (command === "previous" || command === "prev") {
|
|
1285
|
+
ctx.seekPercent = 0;
|
|
1286
|
+
ctx.seekSeconds = 0;
|
|
840
1287
|
index = (index - 1 + count) % count;
|
|
841
1288
|
continue;
|
|
842
1289
|
}
|
|
843
1290
|
if (command === "next") {
|
|
1291
|
+
ctx.seekPercent = 0;
|
|
1292
|
+
ctx.seekSeconds = 0;
|
|
844
1293
|
index = (index + 1) % count;
|
|
845
1294
|
continue;
|
|
846
1295
|
}
|
|
1296
|
+
if (command === "seek" || command === "replay") continue;
|
|
1297
|
+
ctx.seekPercent = 0;
|
|
1298
|
+
ctx.seekSeconds = 0;
|
|
847
1299
|
if (index + 1 >= count) {
|
|
848
1300
|
if (loop) index = 0;
|
|
849
1301
|
else break;
|
|
@@ -851,6 +1303,7 @@ async function playMain(args) {
|
|
|
851
1303
|
index += 1;
|
|
852
1304
|
}
|
|
853
1305
|
}
|
|
1306
|
+
freezePosition(ctx);
|
|
854
1307
|
writeStatus(ctx, "stopped", index, tracks.length, "", player, "");
|
|
855
1308
|
emitStoppedEvent(ctx, "complete");
|
|
856
1309
|
} finally {
|
|
@@ -859,26 +1312,112 @@ async function playMain(args) {
|
|
|
859
1312
|
}
|
|
860
1313
|
}
|
|
861
1314
|
|
|
862
|
-
function
|
|
1315
|
+
function projectCurrentProgress(status, nowMs = Date.now()) {
|
|
1316
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) return status;
|
|
1317
|
+
const duration = Number(status.duration_seconds);
|
|
1318
|
+
let position = Number(status.position_seconds);
|
|
1319
|
+
const updatedAtMs = Number(status.position_updated_at_ms);
|
|
1320
|
+
if (!Number.isFinite(position)) position = 0;
|
|
1321
|
+
if (status.state === "playing" && Number.isFinite(updatedAtMs)) {
|
|
1322
|
+
position += Math.max(0, nowMs - updatedAtMs) / 1000;
|
|
1323
|
+
}
|
|
1324
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
1325
|
+
return { ...status, progress_percent: null };
|
|
1326
|
+
}
|
|
1327
|
+
position = Math.min(Math.max(position, 0), duration);
|
|
1328
|
+
return {
|
|
1329
|
+
...status,
|
|
1330
|
+
progress_percent: Math.round((position / duration) * 100),
|
|
1331
|
+
position_seconds: position,
|
|
1332
|
+
position_updated_at_ms: nowMs,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function actorControlAvailability(stateDir) {
|
|
1337
|
+
const run = readJsonFile(join(stateDir, "run.json"), {});
|
|
1338
|
+
const result = readJsonFile(join(stateDir, "result.json"), {});
|
|
1339
|
+
const endpoint = readJsonFile(join(stateDir, "control-endpoint.json"), {});
|
|
1340
|
+
const playerStatus = readJsonFile(join(stateDir, "player.json"), {});
|
|
1341
|
+
const pid = Number(run.pid || 0);
|
|
1342
|
+
const hasProcessIdentity = pid > 0 || run.process_identity !== undefined;
|
|
1343
|
+
const processIdentity = pid > 0
|
|
1344
|
+
? verifyRunProcessIdentity(pid, run.process_identity)
|
|
1345
|
+
: { valid: false };
|
|
1346
|
+
const available =
|
|
1347
|
+
typeof run.run_instance_id === "string" &&
|
|
1348
|
+
typeof result.completedAt !== "string" &&
|
|
1349
|
+
(!hasProcessIdentity || (pid > 0 && isAlive(pid) && processIdentity.valid === true)) &&
|
|
1350
|
+
endpoint.run_instance_id === run.run_instance_id &&
|
|
1351
|
+
["playing", "paused"].includes(playerStatus.state);
|
|
1352
|
+
return {
|
|
1353
|
+
available,
|
|
1354
|
+
runInstanceId: typeof run.run_instance_id === "string"
|
|
1355
|
+
? run.run_instance_id
|
|
1356
|
+
: undefined,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
async function controlMain(args) {
|
|
863
1361
|
const stateDir = expandPath(args[0] || "");
|
|
864
1362
|
const command = args[1] || "status";
|
|
1363
|
+
const rawInput = args[2];
|
|
865
1364
|
if (!stateDir) {
|
|
866
1365
|
usage();
|
|
867
1366
|
process.exit(2);
|
|
868
1367
|
}
|
|
869
1368
|
mkdirSync(stateDir, { recursive: true });
|
|
870
1369
|
if (command === "status") {
|
|
871
|
-
const statusFile = join(stateDir, "
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1370
|
+
const statusFile = join(stateDir, "player.json");
|
|
1371
|
+
const status = exists(statusFile)
|
|
1372
|
+
? readJsonFile(statusFile, { state: "unknown" })
|
|
1373
|
+
: { state: "unknown" };
|
|
1374
|
+
const actor = actorControlAvailability(stateDir);
|
|
1375
|
+
process.stdout.write(`${JSON.stringify({
|
|
1376
|
+
...projectCurrentProgress(status),
|
|
1377
|
+
actor_available: actor.available,
|
|
1378
|
+
...(actor.runInstanceId
|
|
1379
|
+
? { actor_run_instance_id: actor.runInstanceId }
|
|
1380
|
+
: {}),
|
|
1381
|
+
})}\n`);
|
|
875
1382
|
return;
|
|
876
1383
|
}
|
|
877
|
-
|
|
1384
|
+
if (!actorControlAvailability(stateDir).available) {
|
|
1385
|
+
fail(`Run playback is not active: ${stateDir}`, 3);
|
|
1386
|
+
}
|
|
1387
|
+
let input;
|
|
1388
|
+
if (command === "seek" || command === "volume") {
|
|
1389
|
+
try {
|
|
1390
|
+
input = {
|
|
1391
|
+
percent: command === "seek"
|
|
1392
|
+
? parseSeekPercent(rawInput)
|
|
1393
|
+
: parseVolumePercent(rawInput),
|
|
1394
|
+
};
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
fail(error instanceof Error ? error.message : String(error), 2);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
const queued = appendControl(
|
|
878
1400
|
{ controlsFile: join(stateDir, "controls.jsonl"), stateDir },
|
|
879
1401
|
command,
|
|
1402
|
+
input,
|
|
880
1403
|
);
|
|
881
|
-
|
|
1404
|
+
const deadline = Date.now() + 5_000;
|
|
1405
|
+
while (Date.now() < deadline) {
|
|
1406
|
+
const current = readRunControlJournalFromStateDir(stateDir).records.find(
|
|
1407
|
+
(record) => record.id === queued.id,
|
|
1408
|
+
);
|
|
1409
|
+
if (current?.status === "handled") {
|
|
1410
|
+
console.log(
|
|
1411
|
+
`music-player: command=${command} handled control_id=${queued.id} state_dir=${stateDir}`,
|
|
1412
|
+
);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (current?.status === "failed") {
|
|
1416
|
+
fail(current.error || `Control ${queued.id} failed`, 3);
|
|
1417
|
+
}
|
|
1418
|
+
await sleep(50);
|
|
1419
|
+
}
|
|
1420
|
+
fail(`Control ${queued.id} did not become terminal`, 3);
|
|
882
1421
|
}
|
|
883
1422
|
|
|
884
1423
|
const [mode, ...rest] = process.argv.slice(2);
|
|
@@ -888,13 +1427,17 @@ const directControlCommands = new Set([
|
|
|
888
1427
|
"toggle",
|
|
889
1428
|
"next",
|
|
890
1429
|
"previous",
|
|
1430
|
+
"seek",
|
|
1431
|
+
"volume",
|
|
891
1432
|
"stop",
|
|
892
1433
|
"status",
|
|
893
1434
|
]);
|
|
894
|
-
if (mode === "play") await playMain(rest);
|
|
895
|
-
else if (mode === "control") controlMain(rest);
|
|
896
|
-
else if (
|
|
897
|
-
controlMain([rest[0], mode
|
|
1435
|
+
if (mode === "play" || mode === "serve") await playMain(rest);
|
|
1436
|
+
else if (mode === "control") await controlMain(rest);
|
|
1437
|
+
else if (mode === "seek" || mode === "volume") {
|
|
1438
|
+
await controlMain([rest[0], mode, rest[1]]);
|
|
1439
|
+
} else if (directControlCommands.has(mode)) {
|
|
1440
|
+
await controlMain([rest.at(-1), mode === "resume" ? "play" : mode]);
|
|
898
1441
|
} else if (!mode || mode === "-h" || mode === "--help" || mode === "help") {
|
|
899
1442
|
usage();
|
|
900
1443
|
process.exit(mode ? 0 : 2);
|