@llblab/pi-actors 0.48.0 → 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.
Files changed (66) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +4 -1
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +4 -4
  5. package/dist/lib/async-runs.d.ts +7 -0
  6. package/dist/lib/async-runs.js +63 -8
  7. package/dist/lib/extension-runtime.js +3 -0
  8. package/dist/lib/file-state.js +15 -1
  9. package/dist/lib/inspector.js +4 -0
  10. package/dist/lib/observability.js +2 -5
  11. package/dist/lib/recipes-discovery.js +3 -1
  12. package/dist/lib/recipes-references.d.ts +3 -0
  13. package/dist/lib/recipes-references.js +15 -0
  14. package/dist/lib/registry.d.ts +2 -0
  15. package/dist/lib/registry.js +8 -4
  16. package/dist/lib/runs-start.d.ts +2 -1
  17. package/dist/lib/runs-start.js +11 -5
  18. package/dist/lib/tools-local.js +12 -8
  19. package/dist/lib/tools.d.ts +1 -0
  20. package/dist/lib/tools.js +1 -0
  21. package/dist/skills/actors/SKILL.md +3 -3
  22. package/dist/skills/actors/references/persistent-tools.md +3 -1
  23. package/dist/skills/actors/references/recipes.md +2 -2
  24. package/dist/skills/actors/references/runs.md +2 -0
  25. package/dist/skills/actors/scripts/validate-recipe.mjs +12 -0
  26. package/dist/skills/music-player/SKILL.md +53 -0
  27. package/dist/skills/music-player/genapps/music-player.mjs +360 -0
  28. package/{skills/media/recipes/player.json → dist/skills/music-player/recipes/playback.json} +4 -3
  29. package/dist/skills/music-player/scripts/playback-client.mjs +143 -0
  30. package/dist/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
  31. package/docs/async-runs.md +1 -1
  32. package/docs/recipe-library.md +2 -5
  33. package/docs/template-recipes.md +7 -0
  34. package/docs/tool-registry.md +2 -2
  35. package/lib/async-runs.ts +92 -15
  36. package/lib/extension-runtime.ts +4 -0
  37. package/lib/file-state.ts +14 -1
  38. package/lib/inspector.ts +4 -0
  39. package/lib/observability.ts +2 -5
  40. package/lib/recipes-discovery.ts +2 -1
  41. package/lib/recipes-references.ts +20 -0
  42. package/lib/registry.ts +8 -6
  43. package/lib/runs-start.ts +16 -6
  44. package/lib/tools-local.ts +10 -8
  45. package/lib/tools.ts +2 -0
  46. package/package.json +1 -1
  47. package/skills/actors/SKILL.md +3 -3
  48. package/skills/actors/references/persistent-tools.md +3 -1
  49. package/skills/actors/references/recipes.md +2 -2
  50. package/skills/actors/references/runs.md +2 -0
  51. package/skills/actors/scripts/validate-recipe.mjs +12 -0
  52. package/skills/music-player/SKILL.md +53 -0
  53. package/skills/music-player/genapps/music-player.mjs +360 -0
  54. package/{dist/skills/media/recipes/player.json → skills/music-player/recipes/playback.json} +4 -3
  55. package/skills/music-player/scripts/playback-client.mjs +143 -0
  56. package/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
  57. package/dist/skills/media/SKILL.md +0 -44
  58. package/dist/skills/media/recipes/library.json +0 -45
  59. package/dist/skills/media/recipes/playlist-build.json +0 -15
  60. package/dist/skills/media/recipes/playlist-scan.json +0 -11
  61. package/dist/skills/media/scripts/media-utils.mjs +0 -47
  62. package/skills/media/SKILL.md +0 -44
  63. package/skills/media/recipes/library.json +0 -45
  64. package/skills/media/recipes/playlist-build.json +0 -15
  65. package/skills/media/recipes/playlist-scan.json +0 -11
  66. 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-player controlled-service helper.
4
+ * Packaged persistent local music playback controlled-service helper.
5
5
  *
6
- * This script backs the standard music-player recipe. It scans local music
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 { appendRunControlInStateDir, claimRunControlByIdInStateDir,
59
- updateRunControlStatusInStateDir } = await importRuntimeModule("runs-controls");
60
- const { appendRunTraceEvent } = await importRuntimeModule("runs-trace");
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
- music-player.mjs play <source-file-dir-url-playlist-or-list> [loop=true] [volume=70] [player=auto] [state-dir]
87
- music-player.mjs <pause|resume|toggle|next|previous|stop|status> <state-dir>
88
- music-player.mjs control <state-dir> <play|pause|toggle|next|previous|stop|status>
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
- if (!/^\d+$/.test(String(value))) fail("volume must be an integer 0..100", 2);
181
- return Math.min(Number(value), 100);
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 playerCommand(ctx, player, volume, track) {
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 ["play", ["-q", track]];
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 = { count, index, pid: String(pid), player, state, track };
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 handleControl(ctx, input) {
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
- writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
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.command);
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 command = control ? commandFromControl(control) : undefined;
650
- if (!command && control) {
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 command && control ? { command, id: control.id } : undefined;
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, volume, track, index, count) {
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
- const [command, args] = playerCommand(ctx, player, volume, track);
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 startupRun = readJsonFile(runJsonFile({ stateDir }), {});
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
- writeText(ctx.stateFile, "stopped");
799
- if (ctx.child?.pid) {
800
- try {
801
- process.kill(ctx.child.pid, "SIGTERM");
802
- } catch {}
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
- setState(ctx, "playing");
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, volume, track, index, count);
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 controlMain(args) {
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, "status.txt");
872
- process.stdout.write(
873
- exists(statusFile) ? readText(statusFile) : "state=unknown\n",
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
- appendControl(
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
- console.log(`music-player: command=${command} queued state_dir=${stateDir}`);
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 (directControlCommands.has(mode)) {
897
- controlMain([rest[0], mode === "resume" ? "play" : 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);