@llblab/pi-actors 0.22.5 → 0.24.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 +125 -59
- package/BACKLOG.md +88 -403
- package/CHANGELOG.md +39 -0
- package/README.md +14 -1
- package/dist/fixtures/protocol/actor-message-branch.json +13 -0
- package/dist/fixtures/protocol/artifact-manifest.json +9 -0
- package/dist/fixtures/protocol/mailbox-contract.json +15 -0
- package/dist/fixtures/protocol/recipe-summary.json +16 -0
- package/dist/fixtures/protocol/room-message.json +11 -0
- package/dist/fixtures/protocol/room-roster.json +11 -0
- package/dist/fixtures/protocol/run-inbox-message.json +9 -0
- package/dist/fixtures/protocol/run-outbox-event.json +9 -0
- package/dist/fixtures/protocol/run-state.json +10 -0
- package/dist/index.js +18 -2
- package/dist/lib/actor-inspector-tui.d.ts +4 -0
- package/dist/lib/actor-inspector-tui.js +58 -44
- package/dist/lib/actor-rooms.d.ts +16 -0
- package/dist/lib/actor-rooms.js +123 -28
- package/dist/lib/actor-worker.d.ts +13 -0
- package/dist/lib/actor-worker.js +86 -0
- package/dist/lib/async-runner.d.ts +5 -0
- package/dist/lib/async-runner.js +134 -0
- package/dist/lib/async-runs.d.ts +34 -2
- package/dist/lib/async-runs.js +224 -50
- package/dist/lib/build-dist.d.ts +5 -0
- package/dist/lib/build-dist.js +24 -0
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/conformance.d.ts +12 -0
- package/dist/lib/conformance.js +28 -0
- package/dist/lib/coordinator.d.ts +5 -0
- package/dist/lib/coordinator.js +557 -0
- package/dist/lib/limits.d.ts +11 -0
- package/dist/lib/limits.js +11 -0
- package/dist/lib/locker.d.ts +5 -0
- package/dist/lib/locker.js +310 -0
- package/dist/lib/mailbox-loop.d.ts +41 -0
- package/dist/lib/mailbox-loop.js +62 -0
- package/dist/lib/observability.d.ts +2 -2
- package/dist/lib/observability.js +57 -57
- package/dist/lib/output.js +4 -5
- package/dist/lib/prompts.d.ts +1 -1
- package/dist/lib/prompts.js +1 -1
- package/dist/lib/recipe-discovery.js +1 -0
- package/dist/lib/recipe-references.d.ts +11 -2
- package/dist/lib/recipe-references.js +29 -4
- package/dist/lib/recipe-usage.d.ts +2 -1
- package/dist/lib/recipe-usage.js +15 -3
- package/dist/lib/recipe-utils.d.ts +5 -0
- package/dist/lib/recipe-utils.js +385 -0
- package/dist/lib/runtime-notifier.js +3 -7
- package/dist/lib/runtime.js +12 -2
- package/dist/lib/state-readers.d.ts +21 -0
- package/dist/lib/state-readers.js +74 -0
- package/dist/lib/tools.js +198 -39
- package/dist/lib/validate-recipe.d.ts +6 -0
- package/dist/lib/validate-recipe.js +104 -0
- package/dist/recipes/actor-worker.json +35 -0
- package/dist/recipes/coordinator-locker.json +45 -0
- package/dist/recipes/lens-swarm.json +66 -0
- package/dist/recipes/locker.json +45 -0
- package/dist/recipes/music-player.json +38 -0
- package/dist/recipes/pipeline-architect-coordinator.json +95 -0
- package/dist/recipes/pipeline-artifact-bundle.json +100 -0
- package/dist/recipes/pipeline-artifact-report.json +58 -0
- package/dist/recipes/pipeline-artifact-write.json +72 -0
- package/dist/recipes/pipeline-async-run-ops.json +70 -0
- package/dist/recipes/pipeline-checkpoint-continuation.json +67 -0
- package/dist/recipes/pipeline-development-tasking.json +81 -0
- package/dist/recipes/pipeline-docs-maintenance.json +80 -0
- package/dist/recipes/pipeline-media-library.json +59 -0
- package/dist/recipes/pipeline-quorum-review.json +79 -0
- package/dist/recipes/pipeline-release-readiness.json +110 -0
- package/dist/recipes/pipeline-release-summary.json +88 -0
- package/dist/recipes/pipeline-repo-health.json +89 -0
- package/dist/recipes/pipeline-research-synthesis.json +94 -0
- package/dist/recipes/pipeline-review-readiness.json +54 -0
- package/dist/recipes/pipeline-room-swarm.json +50 -0
- package/dist/recipes/subagent-artifact.json +32 -0
- package/dist/recipes/subagent-checkpoint.json +33 -0
- package/dist/recipes/subagent-conflict-report.json +32 -0
- package/dist/recipes/subagent-contradiction-map.json +33 -0
- package/dist/recipes/subagent-critic.json +35 -0
- package/dist/recipes/subagent-evidence-map.json +33 -0
- package/dist/recipes/subagent-followup.json +33 -0
- package/dist/recipes/subagent-judge.json +33 -0
- package/dist/recipes/subagent-merge.json +33 -0
- package/dist/recipes/subagent-message.json +34 -0
- package/dist/recipes/subagent-normalize.json +31 -0
- package/dist/recipes/subagent-plan.json +33 -0
- package/dist/recipes/subagent-prompt.json +28 -0
- package/dist/recipes/subagent-quorum.json +43 -0
- package/dist/recipes/subagent-review-coordinator.json +114 -0
- package/dist/recipes/subagent-review.json +37 -0
- package/dist/recipes/subagent-task-card.json +35 -0
- package/dist/recipes/subagent-tools.json +27 -0
- package/dist/recipes/subagent-verify.json +34 -0
- package/dist/recipes/subagents-prompts.json +51 -0
- package/dist/recipes/utility-actor-message.json +23 -0
- package/dist/recipes/utility-artifact-manifest.json +16 -0
- package/dist/recipes/utility-artifact-write.json +16 -0
- package/dist/recipes/utility-changelog-head.json +11 -0
- package/dist/recipes/utility-changelog-section.json +13 -0
- package/dist/recipes/utility-coordinator-lock-snapshot.json +13 -0
- package/dist/recipes/utility-git-log.json +11 -0
- package/dist/recipes/utility-git-status.json +9 -0
- package/dist/recipes/utility-jsonl-tail.json +10 -0
- package/dist/recipes/utility-markdown-index.json +14 -0
- package/dist/recipes/utility-package-summary.json +11 -0
- package/dist/recipes/utility-playlist-build.json +17 -0
- package/dist/recipes/utility-playlist-scan.json +11 -0
- package/dist/recipes/utility-run-ops-snapshot.json +17 -0
- package/dist/recipes/utility-run-state-files.json +13 -0
- package/dist/recipes/utility-run-summary.json +11 -0
- package/dist/recipes/utility-skill-summary.json +13 -0
- package/dist/recipes/utility-validate-recipe.json +13 -0
- package/dist/recipes/utility-validation-wrapper.json +13 -0
- package/dist/scripts/actor-worker.mjs +31 -0
- package/dist/scripts/async-runner.mjs +31 -0
- package/dist/scripts/build-dist.mjs +25 -0
- package/dist/scripts/conformance.mjs +33 -0
- package/dist/scripts/coordinator.mjs +31 -0
- package/dist/scripts/locker.mjs +33 -0
- package/dist/scripts/music-player.mjs +964 -0
- package/dist/scripts/recipe-utils.mjs +31 -0
- package/dist/scripts/validate-recipe.mjs +34 -0
- package/dist/skills/actors/SKILL.md +375 -0
- package/dist/skills/swarm/SKILL.md +467 -0
- package/dist/skills/swarm/references/development-swarm.md +596 -0
- package/docs/actor-messages.md +2 -2
- package/docs/async-runs.md +13 -1
- package/docs/template-recipes.md +2 -2
- package/docs/tool-registry.md +0 -1
- package/fixtures/protocol/actor-message-branch.json +13 -0
- package/fixtures/protocol/artifact-manifest.json +9 -0
- package/fixtures/protocol/mailbox-contract.json +15 -0
- package/fixtures/protocol/recipe-summary.json +16 -0
- package/fixtures/protocol/room-message.json +11 -0
- package/fixtures/protocol/room-roster.json +11 -0
- package/fixtures/protocol/run-inbox-message.json +9 -0
- package/fixtures/protocol/run-outbox-event.json +9 -0
- package/fixtures/protocol/run-state.json +10 -0
- package/index.ts +21 -0
- package/lib/actor-inspector-tui.ts +138 -59
- package/lib/actor-rooms.ts +241 -60
- package/lib/actor-worker.ts +118 -0
- package/lib/async-runner.ts +173 -0
- package/lib/async-runs.ts +302 -53
- package/lib/build-dist.ts +30 -0
- package/lib/command-templates.ts +5 -5
- package/lib/conformance.ts +46 -0
- package/lib/coordinator.ts +649 -0
- package/lib/limits.ts +12 -0
- package/lib/locker.ts +340 -0
- package/lib/mailbox-loop.ts +148 -0
- package/lib/observability.ts +34 -23
- package/lib/output.ts +4 -6
- package/lib/prompts.ts +1 -1
- package/lib/recipe-discovery.ts +1 -0
- package/lib/recipe-references.ts +57 -6
- package/lib/recipe-usage.ts +31 -4
- package/lib/recipe-utils.ts +486 -0
- package/lib/runtime-notifier.ts +4 -6
- package/lib/runtime.ts +31 -7
- package/lib/state-readers.ts +93 -0
- package/lib/tools.ts +297 -58
- package/lib/validate-recipe.ts +110 -0
- package/package.json +11 -2
- package/recipes/actor-worker.json +35 -0
- package/recipes/pipeline-quorum-review.json +12 -7
- package/scripts/actor-worker.mjs +31 -0
- package/scripts/async-runner.mjs +11 -195
- package/scripts/build-dist.mjs +25 -0
- package/scripts/conformance.mjs +33 -0
- package/scripts/coordinator.mjs +19 -616
- package/scripts/locker.mjs +23 -322
- package/scripts/music-player.mjs +21 -2
- package/scripts/recipe-utils.mjs +20 -467
- package/scripts/validate-recipe.mjs +23 -113
- package/skills/actors/SKILL.md +7 -4
- package/skills/swarm/SKILL.md +3 -1
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Packaged local music-player actor helper.
|
|
5
|
+
*
|
|
6
|
+
* This script backs the standard music-player recipe. It scans local music
|
|
7
|
+
* sources, builds playback queues, launches an available backend player, and
|
|
8
|
+
* consumes run-mailbox control messages such as play, pause, next, previous,
|
|
9
|
+
* stop, and status.
|
|
10
|
+
*
|
|
11
|
+
* Keep the helper focused on one maintained player actor implementation; recipe
|
|
12
|
+
* metadata and invocation arguments choose source paths and backend behavior.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import {
|
|
18
|
+
accessSync,
|
|
19
|
+
constants,
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
statSync,
|
|
26
|
+
watch,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import {
|
|
31
|
+
basename,
|
|
32
|
+
delimiter,
|
|
33
|
+
dirname,
|
|
34
|
+
extname,
|
|
35
|
+
isAbsolute,
|
|
36
|
+
join,
|
|
37
|
+
resolve,
|
|
38
|
+
} from "node:path";
|
|
39
|
+
|
|
40
|
+
const AUDIO_EXTENSIONS = new Set([
|
|
41
|
+
".aac",
|
|
42
|
+
".aif",
|
|
43
|
+
".aiff",
|
|
44
|
+
".flac",
|
|
45
|
+
".m4a",
|
|
46
|
+
".mp3",
|
|
47
|
+
".ogg",
|
|
48
|
+
".wav",
|
|
49
|
+
]);
|
|
50
|
+
const PLAYLIST_EXTENSIONS = new Set([".m3u", ".m3u8", ".txt"]);
|
|
51
|
+
const CONTROL_COMMANDS = new Set([
|
|
52
|
+
"play",
|
|
53
|
+
"resume",
|
|
54
|
+
"pause",
|
|
55
|
+
"toggle",
|
|
56
|
+
"next",
|
|
57
|
+
"previous",
|
|
58
|
+
"prev",
|
|
59
|
+
"stop",
|
|
60
|
+
"status",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function usage() {
|
|
64
|
+
console.error(`Usage:
|
|
65
|
+
music-player.mjs play <source-file-dir-url-playlist-or-list> [loop=true] [volume=70] [player=auto] [state-dir]
|
|
66
|
+
music-player.mjs <pause|resume|toggle|next|previous|stop|status> <state-dir>
|
|
67
|
+
music-player.mjs control <state-dir> <play|pause|toggle|next|previous|stop|status>
|
|
68
|
+
|
|
69
|
+
Runs a small foreground music player so pi-actors can own it as an actor run.
|
|
70
|
+
Actor message bodies are adapted to queued mailbox commands in <state-dir>/inbox.jsonl.
|
|
71
|
+
Prefer message to=run:<run> type=player.<command> body=<command>, or use direct control commands below.
|
|
72
|
+
Supported players: auto, mpv, afplay, ffplay, cvlc, play, wmp.
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fail(message, code = 1) {
|
|
77
|
+
console.error(`music-player: ${message}`);
|
|
78
|
+
process.exit(code);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleep(ms) {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function expandPath(value) {
|
|
86
|
+
if (value === "~") return homedir();
|
|
87
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isUrl(value) {
|
|
92
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function exists(path) {
|
|
96
|
+
try {
|
|
97
|
+
return existsSync(path);
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isDirectory(path) {
|
|
104
|
+
try {
|
|
105
|
+
return statSync(path).isDirectory();
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isFile(path) {
|
|
112
|
+
try {
|
|
113
|
+
return statSync(path).isFile();
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function have(command) {
|
|
120
|
+
const paths = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
121
|
+
const extensions =
|
|
122
|
+
process.platform === "win32"
|
|
123
|
+
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
124
|
+
: [""];
|
|
125
|
+
for (const dir of paths) {
|
|
126
|
+
for (const extension of extensions) {
|
|
127
|
+
const candidate = join(dir, `${command}${extension}`);
|
|
128
|
+
try {
|
|
129
|
+
accessSync(candidate, constants.X_OK);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
// Keep searching PATH.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseBool(value) {
|
|
140
|
+
switch (String(value).toLowerCase()) {
|
|
141
|
+
case "1":
|
|
142
|
+
case "true":
|
|
143
|
+
case "yes":
|
|
144
|
+
case "y":
|
|
145
|
+
case "on":
|
|
146
|
+
return true;
|
|
147
|
+
case "0":
|
|
148
|
+
case "false":
|
|
149
|
+
case "no":
|
|
150
|
+
case "n":
|
|
151
|
+
case "off":
|
|
152
|
+
return false;
|
|
153
|
+
default:
|
|
154
|
+
fail(`invalid loop value: ${value}`, 2);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeVolume(value) {
|
|
159
|
+
if (!/^\d+$/.test(String(value))) fail("volume must be an integer 0..100", 2);
|
|
160
|
+
return Math.min(Number(value), 100);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function powershellCommand() {
|
|
164
|
+
return have("powershell") ? "powershell.exe" : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function windowsMediaPlayerExecutable() {
|
|
168
|
+
if (process.platform !== "win32") return undefined;
|
|
169
|
+
const roots = [
|
|
170
|
+
process.env.ProgramFiles,
|
|
171
|
+
process.env["ProgramFiles(x86)"],
|
|
172
|
+
process.env.SystemDrive
|
|
173
|
+
? join(process.env.SystemDrive, "Program Files")
|
|
174
|
+
: undefined,
|
|
175
|
+
process.env.SystemDrive
|
|
176
|
+
? join(process.env.SystemDrive, "Program Files (x86)")
|
|
177
|
+
: undefined,
|
|
178
|
+
];
|
|
179
|
+
for (const root of roots.filter(Boolean)) {
|
|
180
|
+
const candidate = join(root, "Windows Media Player", "wmplayer.exe");
|
|
181
|
+
if (exists(candidate)) return candidate;
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function havePlayer(player) {
|
|
187
|
+
if (player === "wmp") {
|
|
188
|
+
return (
|
|
189
|
+
process.platform === "win32" &&
|
|
190
|
+
Boolean(powershellCommand()) &&
|
|
191
|
+
Boolean(windowsMediaPlayerExecutable())
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return have(player);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function selectPlayer(requested) {
|
|
198
|
+
let selected = requested;
|
|
199
|
+
if (selected === "auto") {
|
|
200
|
+
let candidates;
|
|
201
|
+
if (process.platform === "win32") {
|
|
202
|
+
candidates = ["wmp", "mpv", "ffplay", "cvlc"];
|
|
203
|
+
} else if (process.platform === "darwin") {
|
|
204
|
+
candidates = ["mpv", "afplay", "ffplay", "cvlc", "play"];
|
|
205
|
+
} else {
|
|
206
|
+
candidates = ["mpv", "ffplay", "cvlc", "play"];
|
|
207
|
+
}
|
|
208
|
+
selected = candidates.find(havePlayer) || selected;
|
|
209
|
+
}
|
|
210
|
+
if (!["mpv", "afplay", "ffplay", "cvlc", "play", "wmp"].includes(selected)) {
|
|
211
|
+
fail(`unsupported player: ${requested}`, 2);
|
|
212
|
+
}
|
|
213
|
+
if (selected === "wmp" && !havePlayer(selected)) {
|
|
214
|
+
fail(
|
|
215
|
+
"player not found: wmp requires native Windows, powershell.exe, and wmplayer.exe under Program Files/Windows Media Player",
|
|
216
|
+
127,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (!havePlayer(selected)) fail(`player not found: ${selected}`, 127);
|
|
220
|
+
return selected;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function addTrack(tracks, item) {
|
|
224
|
+
const track = expandPath(item.trim());
|
|
225
|
+
if (!track) return;
|
|
226
|
+
if (isUrl(track) || exists(track)) {
|
|
227
|
+
tracks.push(track);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
console.error(`music-player: source entry not found: ${track}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function collectAudioFiles(dir, result = []) {
|
|
234
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
const path = join(dir, entry.name);
|
|
237
|
+
if (entry.isDirectory()) {
|
|
238
|
+
collectAudioFiles(path, result);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
entry.isFile() &&
|
|
243
|
+
AUDIO_EXTENSIONS.has(extname(entry.name).toLowerCase())
|
|
244
|
+
) {
|
|
245
|
+
result.push(path);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function loadPlaylist(source) {
|
|
252
|
+
const sourceArg = expandPath(source);
|
|
253
|
+
const tracks = [];
|
|
254
|
+
if (sourceArg.includes("|")) {
|
|
255
|
+
for (const item of sourceArg.split("|")) addTrack(tracks, item);
|
|
256
|
+
} else if (isUrl(sourceArg)) {
|
|
257
|
+
tracks.push(sourceArg);
|
|
258
|
+
} else if (isDirectory(sourceArg)) {
|
|
259
|
+
tracks.push(
|
|
260
|
+
...collectAudioFiles(sourceArg).sort((a, b) => a.localeCompare(b)),
|
|
261
|
+
);
|
|
262
|
+
} else if (
|
|
263
|
+
isFile(sourceArg) &&
|
|
264
|
+
PLAYLIST_EXTENSIONS.has(extname(sourceArg).toLowerCase())
|
|
265
|
+
) {
|
|
266
|
+
const baseDir = dirname(resolve(sourceArg));
|
|
267
|
+
const lines = readFileSync(sourceArg, "utf8").split("\n");
|
|
268
|
+
for (const rawLine of lines) {
|
|
269
|
+
const line = rawLine.replace(/\r$/, "").trim();
|
|
270
|
+
if (!line || line.startsWith("#")) continue;
|
|
271
|
+
if (isUrl(line) || isAbsolute(line) || line.startsWith("~"))
|
|
272
|
+
addTrack(tracks, line);
|
|
273
|
+
else addTrack(tracks, join(baseDir, line));
|
|
274
|
+
}
|
|
275
|
+
} else if (exists(sourceArg)) {
|
|
276
|
+
tracks.push(sourceArg);
|
|
277
|
+
} else {
|
|
278
|
+
fail(`source not found: ${sourceArg}`, 66);
|
|
279
|
+
}
|
|
280
|
+
if (tracks.length === 0)
|
|
281
|
+
fail(`source has no playable tracks: ${sourceArg}`, 66);
|
|
282
|
+
return tracks;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function windowsMediaPlayerCommand(ctx, volume, track) {
|
|
286
|
+
const command = powershellCommand();
|
|
287
|
+
const wmplayer = windowsMediaPlayerExecutable();
|
|
288
|
+
if (!command || !wmplayer) {
|
|
289
|
+
fail(
|
|
290
|
+
"Windows Media Player backend requires powershell.exe and wmplayer.exe",
|
|
291
|
+
127,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const script = String.raw`
|
|
295
|
+
$ErrorActionPreference = 'Stop'
|
|
296
|
+
$track = $args[0]
|
|
297
|
+
$volume = [int]$args[1]
|
|
298
|
+
$controlFile = $args[2]
|
|
299
|
+
$wmplayerExe = $args[3]
|
|
300
|
+
if (-not (Test-Path -LiteralPath $wmplayerExe)) { throw "wmplayer.exe not found: $wmplayerExe" }
|
|
301
|
+
$player = New-Object -ComObject WMPlayer.OCX
|
|
302
|
+
$player.settings.volume = [Math]::Min([Math]::Max($volume, 0), 100)
|
|
303
|
+
$player.URL = $track
|
|
304
|
+
$player.controls.play()
|
|
305
|
+
try {
|
|
306
|
+
while ($true) {
|
|
307
|
+
Start-Sleep -Milliseconds 100
|
|
308
|
+
if (Test-Path -LiteralPath $controlFile) {
|
|
309
|
+
$control = (Get-Content -LiteralPath $controlFile -Raw -ErrorAction SilentlyContinue).Trim().ToLowerInvariant()
|
|
310
|
+
Clear-Content -LiteralPath $controlFile -ErrorAction SilentlyContinue
|
|
311
|
+
switch ($control) {
|
|
312
|
+
'play' { $player.controls.play() }
|
|
313
|
+
'pause' { $player.controls.pause() }
|
|
314
|
+
'stop' { $player.controls.stop(); break }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if ($player.playState -eq 1 -or $player.playState -eq 8) { break }
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
$player.close()
|
|
321
|
+
}
|
|
322
|
+
`;
|
|
323
|
+
return [
|
|
324
|
+
command,
|
|
325
|
+
[
|
|
326
|
+
"-NoLogo",
|
|
327
|
+
"-NoProfile",
|
|
328
|
+
"-NonInteractive",
|
|
329
|
+
"-ExecutionPolicy",
|
|
330
|
+
"Bypass",
|
|
331
|
+
"-Command",
|
|
332
|
+
script,
|
|
333
|
+
track,
|
|
334
|
+
String(volume),
|
|
335
|
+
ctx.playerControlFile,
|
|
336
|
+
wmplayer,
|
|
337
|
+
],
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function playerCommand(ctx, player, volume, track) {
|
|
342
|
+
switch (player) {
|
|
343
|
+
case "mpv":
|
|
344
|
+
return [
|
|
345
|
+
"mpv",
|
|
346
|
+
[
|
|
347
|
+
"--no-video",
|
|
348
|
+
"--really-quiet",
|
|
349
|
+
"--force-window=no",
|
|
350
|
+
`--volume=${volume}`,
|
|
351
|
+
track,
|
|
352
|
+
],
|
|
353
|
+
];
|
|
354
|
+
case "afplay":
|
|
355
|
+
return [
|
|
356
|
+
"afplay",
|
|
357
|
+
["-v", String(Math.min(Math.max(volume / 100, 0), 1)), track],
|
|
358
|
+
];
|
|
359
|
+
case "ffplay":
|
|
360
|
+
return [
|
|
361
|
+
"ffplay",
|
|
362
|
+
[
|
|
363
|
+
"-nodisp",
|
|
364
|
+
"-hide_banner",
|
|
365
|
+
"-loglevel",
|
|
366
|
+
"warning",
|
|
367
|
+
"-autoexit",
|
|
368
|
+
"-volume",
|
|
369
|
+
String(volume),
|
|
370
|
+
track,
|
|
371
|
+
],
|
|
372
|
+
];
|
|
373
|
+
case "cvlc":
|
|
374
|
+
return [
|
|
375
|
+
"cvlc",
|
|
376
|
+
[
|
|
377
|
+
"--intf",
|
|
378
|
+
"dummy",
|
|
379
|
+
"--no-video",
|
|
380
|
+
"--play-and-exit",
|
|
381
|
+
"--volume",
|
|
382
|
+
String(Math.floor((volume * 256) / 100)),
|
|
383
|
+
track,
|
|
384
|
+
],
|
|
385
|
+
];
|
|
386
|
+
case "play":
|
|
387
|
+
return ["play", ["-q", track]];
|
|
388
|
+
case "wmp":
|
|
389
|
+
return windowsMediaPlayerCommand(ctx, volume, track);
|
|
390
|
+
default:
|
|
391
|
+
fail(`unsupported player: ${player}`, 2);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function writeText(path, value, flag = "w") {
|
|
396
|
+
writeFileSync(path, value, { encoding: "utf8", flag });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readText(path) {
|
|
400
|
+
try {
|
|
401
|
+
return readFileSync(path, "utf8");
|
|
402
|
+
} catch {
|
|
403
|
+
return "";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function emitTrackEvent(ctx, index, count, track, player) {
|
|
408
|
+
const title = track.split(/[\\/]/).filter(Boolean).pop() || track;
|
|
409
|
+
writeText(
|
|
410
|
+
ctx.eventFile,
|
|
411
|
+
`${JSON.stringify({
|
|
412
|
+
body: { count, index, player, track },
|
|
413
|
+
data: { count, index, player, track },
|
|
414
|
+
delivery: "log",
|
|
415
|
+
event: "player.track",
|
|
416
|
+
from: `run:${basename(ctx.stateDir)}`,
|
|
417
|
+
level: "info",
|
|
418
|
+
summary: `Now playing: ${title}`,
|
|
419
|
+
to: "coordinator",
|
|
420
|
+
ts: new Date().toISOString(),
|
|
421
|
+
type: "player.track",
|
|
422
|
+
})}\n`,
|
|
423
|
+
"a",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function writeStatus(ctx, state, index, count, track, player, pid = "") {
|
|
428
|
+
const updatedAt = new Date().toISOString();
|
|
429
|
+
writeText(
|
|
430
|
+
ctx.statusFile,
|
|
431
|
+
`state=${state}\nindex=${index}\ncount=${count}\ntrack=${track}\nplayer=${player}\npid=${pid}\nupdated_at=${updatedAt}\n`,
|
|
432
|
+
);
|
|
433
|
+
writeText(
|
|
434
|
+
ctx.statusJsonFile,
|
|
435
|
+
`${JSON.stringify({ state, index, count, track, player, pid: String(pid), updated_at: updatedAt })}\n`,
|
|
436
|
+
);
|
|
437
|
+
ctx.current = { count, index, pid: String(pid), player, state, track };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function setState(ctx, state) {
|
|
441
|
+
writeText(ctx.stateFile, state);
|
|
442
|
+
if (ctx.current) {
|
|
443
|
+
writeStatus(
|
|
444
|
+
ctx,
|
|
445
|
+
state,
|
|
446
|
+
ctx.current.index,
|
|
447
|
+
ctx.current.count,
|
|
448
|
+
ctx.current.track,
|
|
449
|
+
ctx.current.player,
|
|
450
|
+
ctx.current.pid,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function sendSignalToCurrent(ctx, signal) {
|
|
456
|
+
const pid = Number(readText(ctx.pidFile).trim());
|
|
457
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
458
|
+
if (process.platform !== "win32") {
|
|
459
|
+
try {
|
|
460
|
+
process.kill(-pid, signal);
|
|
461
|
+
return;
|
|
462
|
+
} catch {
|
|
463
|
+
// Fall through to the direct child fallback.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
process.kill(pid, signal);
|
|
468
|
+
} catch {
|
|
469
|
+
// Best effort control signal.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function controlCurrentPlayback(ctx, command) {
|
|
474
|
+
if (ctx.current?.player === "wmp") {
|
|
475
|
+
writeText(ctx.playerControlFile, command);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
switch (command) {
|
|
479
|
+
case "play":
|
|
480
|
+
sendSignalToCurrent(ctx, "SIGCONT");
|
|
481
|
+
break;
|
|
482
|
+
case "pause":
|
|
483
|
+
sendSignalToCurrent(ctx, "SIGSTOP");
|
|
484
|
+
break;
|
|
485
|
+
case "stop":
|
|
486
|
+
sendSignalToCurrent(ctx, "SIGTERM");
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function handleControl(ctx, input) {
|
|
492
|
+
const command = input.trim().toLowerCase();
|
|
493
|
+
if (!command) return;
|
|
494
|
+
if (!CONTROL_COMMANDS.has(command)) {
|
|
495
|
+
console.error(`music-player: unknown control command: ${command}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
switch (command) {
|
|
499
|
+
case "play":
|
|
500
|
+
case "resume":
|
|
501
|
+
setState(ctx, "playing");
|
|
502
|
+
controlCurrentPlayback(ctx, "play");
|
|
503
|
+
break;
|
|
504
|
+
case "pause":
|
|
505
|
+
setState(ctx, "paused");
|
|
506
|
+
controlCurrentPlayback(ctx, "pause");
|
|
507
|
+
break;
|
|
508
|
+
case "toggle": {
|
|
509
|
+
const current = readText(ctx.stateFile).trim();
|
|
510
|
+
if (current === "paused") {
|
|
511
|
+
setState(ctx, "playing");
|
|
512
|
+
controlCurrentPlayback(ctx, "play");
|
|
513
|
+
} else {
|
|
514
|
+
setState(ctx, "paused");
|
|
515
|
+
controlCurrentPlayback(ctx, "pause");
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
case "next":
|
|
520
|
+
writeText(ctx.commandFile, "next");
|
|
521
|
+
controlCurrentPlayback(ctx, "stop");
|
|
522
|
+
break;
|
|
523
|
+
case "previous":
|
|
524
|
+
case "prev":
|
|
525
|
+
writeText(ctx.commandFile, "previous");
|
|
526
|
+
controlCurrentPlayback(ctx, "stop");
|
|
527
|
+
break;
|
|
528
|
+
case "stop":
|
|
529
|
+
writeText(ctx.commandFile, "stop");
|
|
530
|
+
controlCurrentPlayback(ctx, "stop");
|
|
531
|
+
break;
|
|
532
|
+
case "status":
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function runJsonFile(ctx) {
|
|
538
|
+
return join(ctx.stateDir, "run.json");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function readJsonFile(path, fallback) {
|
|
542
|
+
try {
|
|
543
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
544
|
+
} catch {
|
|
545
|
+
return fallback;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function writeJsonFile(path, value) {
|
|
550
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function updateRunControlMetadata(ctx) {
|
|
554
|
+
const path = runJsonFile(ctx);
|
|
555
|
+
const run = readJsonFile(path, undefined);
|
|
556
|
+
if (!run || typeof run !== "object") return;
|
|
557
|
+
writeJsonFile(path, {
|
|
558
|
+
...run,
|
|
559
|
+
control: { path: ctx.inboxFile, type: "mailbox" },
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function acquireInboxLock(ctx) {
|
|
564
|
+
const lockDir = join(ctx.stateDir, ".inbox.lock");
|
|
565
|
+
const started = Date.now();
|
|
566
|
+
while (true) {
|
|
567
|
+
try {
|
|
568
|
+
mkdirSync(lockDir);
|
|
569
|
+
writeJsonFile(join(lockDir, "owner.json"), {
|
|
570
|
+
created_at: new Date().toISOString(),
|
|
571
|
+
pid: process.pid,
|
|
572
|
+
});
|
|
573
|
+
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
574
|
+
} catch (error) {
|
|
575
|
+
try {
|
|
576
|
+
const stat = statSync(lockDir);
|
|
577
|
+
if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) {
|
|
578
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (Date.now() - started > 5000) throw error;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function readInboxMessages(ctx) {
|
|
590
|
+
if (!exists(ctx.inboxFile)) return [];
|
|
591
|
+
return readFileSync(ctx.inboxFile, "utf8")
|
|
592
|
+
.split("\n")
|
|
593
|
+
.filter((line) => line.trim())
|
|
594
|
+
.map((line) => {
|
|
595
|
+
try {
|
|
596
|
+
return JSON.parse(line);
|
|
597
|
+
} catch {
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
.filter(Boolean);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function writeInboxMessages(ctx, messages) {
|
|
605
|
+
writeFileSync(
|
|
606
|
+
ctx.inboxFile,
|
|
607
|
+
messages.length
|
|
608
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
609
|
+
: "",
|
|
610
|
+
"utf8",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function commandFromInboxMessage(message) {
|
|
615
|
+
if (
|
|
616
|
+
typeof message.body === "string" &&
|
|
617
|
+
CONTROL_COMMANDS.has(message.body.trim())
|
|
618
|
+
) {
|
|
619
|
+
return message.body.trim();
|
|
620
|
+
}
|
|
621
|
+
if (message.body && typeof message.body === "object") {
|
|
622
|
+
const command = String(message.body.command ?? "").trim();
|
|
623
|
+
if (CONTROL_COMMANDS.has(command)) return command;
|
|
624
|
+
}
|
|
625
|
+
if (typeof message.type === "string") {
|
|
626
|
+
if (message.type === "control.stop" || message.type === "control.cancel")
|
|
627
|
+
return "stop";
|
|
628
|
+
const match = /^player\.(.+)$/.exec(message.type);
|
|
629
|
+
if (match && CONTROL_COMMANDS.has(match[1])) return match[1];
|
|
630
|
+
}
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function runtimeWakeFile(ctx) {
|
|
635
|
+
return join(ctx.stateDir, "wake.jsonl");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function notifyMailboxWake(ctx, reason = "run.message") {
|
|
639
|
+
try {
|
|
640
|
+
writeText(
|
|
641
|
+
runtimeWakeFile(ctx),
|
|
642
|
+
`${JSON.stringify({
|
|
643
|
+
actor: `run:${basename(ctx.stateDir)}`,
|
|
644
|
+
id: randomUUID(),
|
|
645
|
+
metadata: { command: "music-player" },
|
|
646
|
+
reason,
|
|
647
|
+
state_dir: ctx.stateDir,
|
|
648
|
+
ts: new Date().toISOString(),
|
|
649
|
+
})}\n`,
|
|
650
|
+
"a",
|
|
651
|
+
);
|
|
652
|
+
} catch {
|
|
653
|
+
// Wake records are advisory; the inbox remains the durable source of truth.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function appendInboxCommand(ctx, command) {
|
|
658
|
+
const release = acquireInboxLock(ctx);
|
|
659
|
+
try {
|
|
660
|
+
const ts = new Date().toISOString();
|
|
661
|
+
const messages = readInboxMessages(ctx);
|
|
662
|
+
messages.push({
|
|
663
|
+
body: command,
|
|
664
|
+
from: "coordinator",
|
|
665
|
+
id: randomUUID(),
|
|
666
|
+
queued_at: ts,
|
|
667
|
+
received_at: ts,
|
|
668
|
+
status: "queued",
|
|
669
|
+
to: `run:${basename(ctx.stateDir)}`,
|
|
670
|
+
type: `player.${command}`,
|
|
671
|
+
});
|
|
672
|
+
writeInboxMessages(ctx, messages);
|
|
673
|
+
} finally {
|
|
674
|
+
release();
|
|
675
|
+
}
|
|
676
|
+
notifyMailboxWake(ctx);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function claimInboxCommands(ctx) {
|
|
680
|
+
const release = acquireInboxLock(ctx);
|
|
681
|
+
try {
|
|
682
|
+
const messages = readInboxMessages(ctx);
|
|
683
|
+
const commands = [];
|
|
684
|
+
let changed = false;
|
|
685
|
+
const claimedAt = new Date().toISOString();
|
|
686
|
+
for (const message of messages) {
|
|
687
|
+
if (message.status !== "queued") continue;
|
|
688
|
+
const command = commandFromInboxMessage(message);
|
|
689
|
+
if (!command) {
|
|
690
|
+
message.failed_at = claimedAt;
|
|
691
|
+
message.status = "failed";
|
|
692
|
+
message.error = "Unsupported music-player command";
|
|
693
|
+
changed = true;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
message.claimed_at = claimedAt;
|
|
697
|
+
message.claimed_by = `run:${basename(ctx.stateDir)}`;
|
|
698
|
+
message.status = "claimed";
|
|
699
|
+
commands.push({ command, id: message.id });
|
|
700
|
+
changed = true;
|
|
701
|
+
}
|
|
702
|
+
if (changed) writeInboxMessages(ctx, messages);
|
|
703
|
+
return commands;
|
|
704
|
+
} finally {
|
|
705
|
+
release();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function finalizeInboxCommand(ctx, id, status, error) {
|
|
710
|
+
if (!id) return;
|
|
711
|
+
const release = acquireInboxLock(ctx);
|
|
712
|
+
try {
|
|
713
|
+
const messages = readInboxMessages(ctx);
|
|
714
|
+
const timestamp = new Date().toISOString();
|
|
715
|
+
let changed = false;
|
|
716
|
+
for (const message of messages) {
|
|
717
|
+
if (message.id !== id) continue;
|
|
718
|
+
message.status = status;
|
|
719
|
+
if (status === "handled") message.handled_at = timestamp;
|
|
720
|
+
else message.failed_at = timestamp;
|
|
721
|
+
if (error) message.error = error;
|
|
722
|
+
changed = true;
|
|
723
|
+
}
|
|
724
|
+
if (changed) writeInboxMessages(ctx, messages);
|
|
725
|
+
} finally {
|
|
726
|
+
release();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function inboxSignature(ctx) {
|
|
731
|
+
try {
|
|
732
|
+
const stat = statSync(ctx.inboxFile);
|
|
733
|
+
return `${stat.size}:${stat.mtimeMs}`;
|
|
734
|
+
} catch {
|
|
735
|
+
return "missing";
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function startControlLoop(ctx) {
|
|
740
|
+
let closed = false;
|
|
741
|
+
let dirty = true;
|
|
742
|
+
let watcher;
|
|
743
|
+
try {
|
|
744
|
+
watcher = watch(ctx.stateDir, { persistent: false }, (_eventType, file) => {
|
|
745
|
+
const name = file ? String(file) : "";
|
|
746
|
+
if (
|
|
747
|
+
!name ||
|
|
748
|
+
name === basename(ctx.inboxFile) ||
|
|
749
|
+
name === basename(runtimeWakeFile(ctx))
|
|
750
|
+
) {
|
|
751
|
+
dirty = true;
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
} catch {
|
|
755
|
+
// fs.watch is advisory; the signature poll below is the portable fallback.
|
|
756
|
+
}
|
|
757
|
+
const close = () => {
|
|
758
|
+
closed = true;
|
|
759
|
+
watcher?.close();
|
|
760
|
+
};
|
|
761
|
+
const promise = (async () => {
|
|
762
|
+
let lastSignature = "";
|
|
763
|
+
while (!ctx.stopping && !closed) {
|
|
764
|
+
const signature = inboxSignature(ctx);
|
|
765
|
+
if (dirty || signature !== lastSignature) {
|
|
766
|
+
dirty = false;
|
|
767
|
+
for (const { command, id } of claimInboxCommands(ctx)) {
|
|
768
|
+
try {
|
|
769
|
+
handleControl(ctx, command);
|
|
770
|
+
finalizeInboxCommand(ctx, id, "handled");
|
|
771
|
+
} catch (error) {
|
|
772
|
+
finalizeInboxCommand(ctx, id, "failed", error.message);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
lastSignature = inboxSignature(ctx);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
await sleep(250);
|
|
779
|
+
}
|
|
780
|
+
})();
|
|
781
|
+
return { close, promise };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function playOne(ctx, player, volume, track, index, count) {
|
|
785
|
+
return new Promise((resolveDone) => {
|
|
786
|
+
rmSync(ctx.playerControlFile, { force: true });
|
|
787
|
+
const [command, args] = playerCommand(ctx, player, volume, track);
|
|
788
|
+
writeStatus(ctx, "playing", index, count, track, player, "");
|
|
789
|
+
const child = spawn(command, args, {
|
|
790
|
+
detached: process.platform !== "win32",
|
|
791
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
792
|
+
});
|
|
793
|
+
ctx.child = child;
|
|
794
|
+
const pid = child.pid || "";
|
|
795
|
+
if (pid) writeText(ctx.pidFile, String(pid));
|
|
796
|
+
writeStatus(ctx, "playing", index, count, track, player, pid);
|
|
797
|
+
emitTrackEvent(ctx, index, count, track, player);
|
|
798
|
+
child.once("error", (error) => {
|
|
799
|
+
console.error(
|
|
800
|
+
`music-player: failed to start ${command}: ${error.message}`,
|
|
801
|
+
);
|
|
802
|
+
resolveDone();
|
|
803
|
+
});
|
|
804
|
+
child.once("exit", () => {
|
|
805
|
+
rmSync(ctx.pidFile, { force: true });
|
|
806
|
+
ctx.child = undefined;
|
|
807
|
+
resolveDone();
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function readAndClearCommand(ctx) {
|
|
813
|
+
const command = readText(ctx.commandFile).trim();
|
|
814
|
+
writeText(ctx.commandFile, "");
|
|
815
|
+
return command;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function playMain(args) {
|
|
819
|
+
const [
|
|
820
|
+
sourceArg,
|
|
821
|
+
loopArg = "true",
|
|
822
|
+
volumeArg = "70",
|
|
823
|
+
playerArg = "auto",
|
|
824
|
+
rawStateDir,
|
|
825
|
+
] = args;
|
|
826
|
+
if (!sourceArg || sourceArg === "-h" || sourceArg === "--help") {
|
|
827
|
+
usage();
|
|
828
|
+
process.exit(2);
|
|
829
|
+
}
|
|
830
|
+
const stateDir = expandPath(
|
|
831
|
+
rawStateDir ||
|
|
832
|
+
join(
|
|
833
|
+
process.env.TMPDIR || "/tmp",
|
|
834
|
+
`pi-actors-music-player-${process.pid}`,
|
|
835
|
+
),
|
|
836
|
+
);
|
|
837
|
+
mkdirSync(stateDir, { recursive: true });
|
|
838
|
+
const ctx = {
|
|
839
|
+
commandFile: join(stateDir, "command.txt"),
|
|
840
|
+
current: undefined,
|
|
841
|
+
eventFile: join(stateDir, "outbox.jsonl"),
|
|
842
|
+
inboxFile: join(stateDir, "inbox.jsonl"),
|
|
843
|
+
pidFile: join(stateDir, "current.pid"),
|
|
844
|
+
playerControlFile: join(stateDir, "player-control.txt"),
|
|
845
|
+
stateDir,
|
|
846
|
+
stateFile: join(stateDir, "player-state.txt"),
|
|
847
|
+
statusFile: join(stateDir, "status.txt"),
|
|
848
|
+
statusJsonFile: join(stateDir, "player.json"),
|
|
849
|
+
stopping: false,
|
|
850
|
+
};
|
|
851
|
+
rmSync(ctx.commandFile, { force: true });
|
|
852
|
+
rmSync(ctx.pidFile, { force: true });
|
|
853
|
+
rmSync(ctx.playerControlFile, { force: true });
|
|
854
|
+
const loop = parseBool(loopArg);
|
|
855
|
+
const volume = normalizeVolume(volumeArg);
|
|
856
|
+
const player = selectPlayer(playerArg);
|
|
857
|
+
const tracks = loadPlaylist(sourceArg);
|
|
858
|
+
let controlLoop;
|
|
859
|
+
const cleanup = () => {
|
|
860
|
+
ctx.stopping = true;
|
|
861
|
+
writeText(ctx.stateFile, "stopped");
|
|
862
|
+
if (ctx.child?.pid) {
|
|
863
|
+
try {
|
|
864
|
+
process.kill(ctx.child.pid, "SIGTERM");
|
|
865
|
+
} catch {}
|
|
866
|
+
}
|
|
867
|
+
controlLoop?.close();
|
|
868
|
+
rmSync(ctx.pidFile, { force: true });
|
|
869
|
+
rmSync(ctx.playerControlFile, { force: true });
|
|
870
|
+
};
|
|
871
|
+
process.once("SIGTERM", () => {
|
|
872
|
+
cleanup();
|
|
873
|
+
process.exit(143);
|
|
874
|
+
});
|
|
875
|
+
process.once("SIGINT", () => {
|
|
876
|
+
cleanup();
|
|
877
|
+
process.exit(130);
|
|
878
|
+
});
|
|
879
|
+
process.once("SIGHUP", () => {
|
|
880
|
+
cleanup();
|
|
881
|
+
process.exit(129);
|
|
882
|
+
});
|
|
883
|
+
try {
|
|
884
|
+
updateRunControlMetadata(ctx);
|
|
885
|
+
controlLoop = startControlLoop(ctx);
|
|
886
|
+
setState(ctx, "playing");
|
|
887
|
+
console.error(
|
|
888
|
+
`music-player: player=${player} loop=${loop} volume=${volume} tracks=${tracks.length} state_dir=${stateDir}`,
|
|
889
|
+
);
|
|
890
|
+
let index = 0;
|
|
891
|
+
const count = tracks.length;
|
|
892
|
+
while (!ctx.stopping) {
|
|
893
|
+
const track = tracks[index];
|
|
894
|
+
await playOne(ctx, player, volume, track, index, count);
|
|
895
|
+
const command = readAndClearCommand(ctx);
|
|
896
|
+
if (command === "stop") {
|
|
897
|
+
writeStatus(ctx, "stopped", index, count, track, player, "");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (command === "previous" || command === "prev") {
|
|
901
|
+
index = (index - 1 + count) % count;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (command === "next") {
|
|
905
|
+
index = (index + 1) % count;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (index + 1 >= count) {
|
|
909
|
+
if (loop) index = 0;
|
|
910
|
+
else break;
|
|
911
|
+
} else {
|
|
912
|
+
index += 1;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
writeStatus(ctx, "stopped", index, tracks.length, "", player, "");
|
|
916
|
+
} finally {
|
|
917
|
+
cleanup();
|
|
918
|
+
await Promise.race([controlLoop?.promise ?? Promise.resolve(), sleep(100)]);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function controlMain(args) {
|
|
923
|
+
const stateDir = expandPath(args[0] || "");
|
|
924
|
+
const command = args[1] || "status";
|
|
925
|
+
if (!stateDir) {
|
|
926
|
+
usage();
|
|
927
|
+
process.exit(2);
|
|
928
|
+
}
|
|
929
|
+
mkdirSync(stateDir, { recursive: true });
|
|
930
|
+
if (command === "status") {
|
|
931
|
+
const statusFile = join(stateDir, "status.txt");
|
|
932
|
+
process.stdout.write(
|
|
933
|
+
exists(statusFile) ? readText(statusFile) : "state=unknown\n",
|
|
934
|
+
);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
appendInboxCommand(
|
|
938
|
+
{ inboxFile: join(stateDir, "inbox.jsonl"), stateDir },
|
|
939
|
+
command,
|
|
940
|
+
);
|
|
941
|
+
console.log(`music-player: command=${command} queued state_dir=${stateDir}`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const [mode, ...rest] = process.argv.slice(2);
|
|
945
|
+
const directControlCommands = new Set([
|
|
946
|
+
"pause",
|
|
947
|
+
"resume",
|
|
948
|
+
"toggle",
|
|
949
|
+
"next",
|
|
950
|
+
"previous",
|
|
951
|
+
"prev",
|
|
952
|
+
"stop",
|
|
953
|
+
"status",
|
|
954
|
+
]);
|
|
955
|
+
if (mode === "play") await playMain(rest);
|
|
956
|
+
else if (mode === "control") controlMain(rest);
|
|
957
|
+
else if (directControlCommands.has(mode)) {
|
|
958
|
+
controlMain([rest[0], mode === "resume" ? "play" : mode]);
|
|
959
|
+
} else if (!mode || mode === "-h" || mode === "--help" || mode === "help") {
|
|
960
|
+
usage();
|
|
961
|
+
process.exit(mode ? 0 : 2);
|
|
962
|
+
} else {
|
|
963
|
+
await playMain([mode, ...rest]);
|
|
964
|
+
}
|