@llblab/pi-actors 0.12.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 +72 -0
- package/BACKLOG.md +38 -0
- package/CHANGELOG.md +179 -0
- package/README.md +338 -0
- package/docs/README.md +21 -0
- package/docs/actor-messages.md +149 -0
- package/docs/async-runs.md +335 -0
- package/docs/command-templates.md +424 -0
- package/docs/component-recipes.md +148 -0
- package/docs/recipe-library.md +176 -0
- package/docs/task-first-recipes.md +233 -0
- package/docs/template-recipes.md +285 -0
- package/docs/tool-registry.md +142 -0
- package/index.ts +198 -0
- package/lib/actor-messages.ts +120 -0
- package/lib/async-runs.ts +688 -0
- package/lib/command-templates.ts +795 -0
- package/lib/config.ts +266 -0
- package/lib/execution.ts +720 -0
- package/lib/file-state.ts +24 -0
- package/lib/identity.ts +29 -0
- package/lib/observability.ts +525 -0
- package/lib/output.ts +123 -0
- package/lib/paths.ts +35 -0
- package/lib/prompts.ts +75 -0
- package/lib/recipe-references.ts +586 -0
- package/lib/registry.ts +302 -0
- package/lib/runtime.ts +101 -0
- package/lib/schema.ts +402 -0
- package/lib/temp.ts +44 -0
- package/lib/tools.ts +651 -0
- package/package.json +52 -0
- package/recipes/music-player.json +25 -0
- package/recipes/pipeline-architect-coordinator.json +88 -0
- package/recipes/pipeline-artifact-report.json +52 -0
- package/recipes/pipeline-artifact-write.json +66 -0
- package/recipes/pipeline-async-run-ops.json +67 -0
- package/recipes/pipeline-checkpoint-continuation.json +57 -0
- package/recipes/pipeline-development-tasking.json +73 -0
- package/recipes/pipeline-docs-maintenance.json +72 -0
- package/recipes/pipeline-media-library.json +51 -0
- package/recipes/pipeline-quorum-review.json +72 -0
- package/recipes/pipeline-release-readiness.json +83 -0
- package/recipes/pipeline-repo-health.json +81 -0
- package/recipes/pipeline-research-synthesis.json +87 -0
- package/recipes/pipeline-review-readiness.json +49 -0
- package/recipes/subagent-artifact.json +26 -0
- package/recipes/subagent-checkpoint.json +27 -0
- package/recipes/subagent-conflict-report.json +25 -0
- package/recipes/subagent-contradiction-map.json +26 -0
- package/recipes/subagent-critic.json +28 -0
- package/recipes/subagent-evidence-map.json +26 -0
- package/recipes/subagent-followup.json +27 -0
- package/recipes/subagent-judge.json +26 -0
- package/recipes/subagent-merge.json +26 -0
- package/recipes/subagent-message.json +29 -0
- package/recipes/subagent-normalize.json +24 -0
- package/recipes/subagent-plan.json +26 -0
- package/recipes/subagent-prompt.json +22 -0
- package/recipes/subagent-quorum.json +41 -0
- package/recipes/subagent-review-coordinator.json +107 -0
- package/recipes/subagent-review.json +30 -0
- package/recipes/subagent-task-card.json +28 -0
- package/recipes/subagent-tools.json +17 -0
- package/recipes/subagent-verify.json +27 -0
- package/recipes/subagents-prompts.json +32 -0
- package/recipes/utility-actor-message.json +24 -0
- package/recipes/utility-artifact-manifest.json +17 -0
- package/recipes/utility-artifact-write.json +17 -0
- package/recipes/utility-changelog-head.json +12 -0
- package/recipes/utility-changelog-section.json +14 -0
- package/recipes/utility-git-log.json +12 -0
- package/recipes/utility-git-status.json +10 -0
- package/recipes/utility-jsonl-tail.json +11 -0
- package/recipes/utility-markdown-index.json +15 -0
- package/recipes/utility-package-summary.json +12 -0
- package/recipes/utility-playlist-build.json +18 -0
- package/recipes/utility-playlist-scan.json +12 -0
- package/recipes/utility-run-state-files.json +14 -0
- package/recipes/utility-run-summary.json +12 -0
- package/recipes/utility-validate-recipe.json +14 -0
- package/recipes/utility-validation-wrapper.json +14 -0
- package/scripts/async-runner.mjs +170 -0
- package/scripts/music-player.mjs +637 -0
- package/scripts/recipe-utils.mjs +273 -0
- package/scripts/validate-recipe.mjs +89 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
constants,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
writeSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import {
|
|
20
|
+
basename,
|
|
21
|
+
delimiter,
|
|
22
|
+
dirname,
|
|
23
|
+
extname,
|
|
24
|
+
isAbsolute,
|
|
25
|
+
join,
|
|
26
|
+
resolve,
|
|
27
|
+
} from "node:path";
|
|
28
|
+
|
|
29
|
+
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".wav", ".flac", ".m4a"]);
|
|
30
|
+
const PLAYLIST_EXTENSIONS = new Set([".m3u", ".m3u8", ".txt"]);
|
|
31
|
+
const CONTROL_COMMANDS = new Set([
|
|
32
|
+
"play",
|
|
33
|
+
"resume",
|
|
34
|
+
"pause",
|
|
35
|
+
"toggle",
|
|
36
|
+
"next",
|
|
37
|
+
"previous",
|
|
38
|
+
"prev",
|
|
39
|
+
"stop",
|
|
40
|
+
"status",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function usage() {
|
|
44
|
+
console.error(`Usage:
|
|
45
|
+
music-player.mjs play <source-file-dir-url-playlist-or-list> [loop=true] [volume=70] [player=auto] [state-dir]
|
|
46
|
+
music-player.mjs <pause|resume|toggle|next|previous|stop|status> <state-dir>
|
|
47
|
+
music-player.mjs control <state-dir> <play|pause|toggle|next|previous|stop|status>
|
|
48
|
+
|
|
49
|
+
Runs a small foreground music player so pi-actors can own it as an actor run.
|
|
50
|
+
Actor message bodies are adapted to newline-delimited commands at <state-dir>/control.fifo.
|
|
51
|
+
Prefer message to=run:<run> type=player.<command> body=<command>, or use direct control commands below.
|
|
52
|
+
Supported players: auto, mpv, ffplay, cvlc, play.
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fail(message, code = 1) {
|
|
57
|
+
console.error(`music-player: ${message}`);
|
|
58
|
+
process.exit(code);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureUnixFifoSupport() {
|
|
62
|
+
if (process.platform === "win32") {
|
|
63
|
+
fail(
|
|
64
|
+
"Unix FIFO controls are not available on native Windows; use WSL/Linux/macOS or a recipe-specific Windows transport.",
|
|
65
|
+
70,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sleep(ms) {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function expandPath(value) {
|
|
75
|
+
if (value === "~") return homedir();
|
|
76
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isUrl(value) {
|
|
81
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exists(path) {
|
|
85
|
+
try {
|
|
86
|
+
return existsSync(path);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isDirectory(path) {
|
|
93
|
+
try {
|
|
94
|
+
return statSync(path).isDirectory();
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isFile(path) {
|
|
101
|
+
try {
|
|
102
|
+
return statSync(path).isFile();
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isFifo(path) {
|
|
109
|
+
try {
|
|
110
|
+
const mode = statSync(path).mode & constants.S_IFMT;
|
|
111
|
+
return mode === constants.S_IFIFO;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function have(command) {
|
|
118
|
+
const paths = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
119
|
+
const extensions =
|
|
120
|
+
process.platform === "win32"
|
|
121
|
+
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
122
|
+
: [""];
|
|
123
|
+
for (const dir of paths) {
|
|
124
|
+
for (const extension of extensions) {
|
|
125
|
+
const candidate = join(dir, `${command}${extension}`);
|
|
126
|
+
try {
|
|
127
|
+
accessSync(candidate, constants.X_OK);
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
// Keep searching PATH.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseBool(value) {
|
|
138
|
+
switch (String(value).toLowerCase()) {
|
|
139
|
+
case "1":
|
|
140
|
+
case "true":
|
|
141
|
+
case "yes":
|
|
142
|
+
case "y":
|
|
143
|
+
case "on":
|
|
144
|
+
return true;
|
|
145
|
+
case "0":
|
|
146
|
+
case "false":
|
|
147
|
+
case "no":
|
|
148
|
+
case "n":
|
|
149
|
+
case "off":
|
|
150
|
+
return false;
|
|
151
|
+
default:
|
|
152
|
+
fail(`invalid loop value: ${value}`, 2);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeVolume(value) {
|
|
157
|
+
if (!/^\d+$/.test(String(value))) fail("volume must be an integer 0..100", 2);
|
|
158
|
+
return Math.min(Number(value), 100);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function selectPlayer(requested) {
|
|
162
|
+
let selected = requested;
|
|
163
|
+
if (selected === "auto") {
|
|
164
|
+
selected = ["mpv", "ffplay", "cvlc", "play"].find(have) || selected;
|
|
165
|
+
}
|
|
166
|
+
if (!["mpv", "ffplay", "cvlc", "play"].includes(selected)) {
|
|
167
|
+
fail(`unsupported player: ${requested}`, 2);
|
|
168
|
+
}
|
|
169
|
+
if (!have(selected)) fail(`player not found: ${selected}`, 127);
|
|
170
|
+
return selected;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function addTrack(tracks, item) {
|
|
174
|
+
const track = expandPath(item.trim());
|
|
175
|
+
if (!track) return;
|
|
176
|
+
if (isUrl(track) || exists(track)) {
|
|
177
|
+
tracks.push(track);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.error(`music-player: source entry not found: ${track}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function collectAudioFiles(dir, result = []) {
|
|
184
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
const path = join(dir, entry.name);
|
|
187
|
+
if (entry.isDirectory()) {
|
|
188
|
+
collectAudioFiles(path, result);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
entry.isFile() &&
|
|
193
|
+
AUDIO_EXTENSIONS.has(extname(entry.name).toLowerCase())
|
|
194
|
+
) {
|
|
195
|
+
result.push(path);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function loadPlaylist(source) {
|
|
202
|
+
const sourceArg = expandPath(source);
|
|
203
|
+
const tracks = [];
|
|
204
|
+
if (sourceArg.includes("|")) {
|
|
205
|
+
for (const item of sourceArg.split("|")) addTrack(tracks, item);
|
|
206
|
+
} else if (isUrl(sourceArg)) {
|
|
207
|
+
tracks.push(sourceArg);
|
|
208
|
+
} else if (isDirectory(sourceArg)) {
|
|
209
|
+
tracks.push(
|
|
210
|
+
...collectAudioFiles(sourceArg).sort((a, b) => a.localeCompare(b)),
|
|
211
|
+
);
|
|
212
|
+
} else if (
|
|
213
|
+
isFile(sourceArg) &&
|
|
214
|
+
PLAYLIST_EXTENSIONS.has(extname(sourceArg).toLowerCase())
|
|
215
|
+
) {
|
|
216
|
+
const baseDir = dirname(resolve(sourceArg));
|
|
217
|
+
const lines = readFileSync(sourceArg, "utf8").split("\n");
|
|
218
|
+
for (const rawLine of lines) {
|
|
219
|
+
const line = rawLine.replace(/\r$/, "").trim();
|
|
220
|
+
if (!line || line.startsWith("#")) continue;
|
|
221
|
+
if (isUrl(line) || isAbsolute(line) || line.startsWith("~"))
|
|
222
|
+
addTrack(tracks, line);
|
|
223
|
+
else addTrack(tracks, join(baseDir, line));
|
|
224
|
+
}
|
|
225
|
+
} else if (exists(sourceArg)) {
|
|
226
|
+
tracks.push(sourceArg);
|
|
227
|
+
} else {
|
|
228
|
+
fail(`source not found: ${sourceArg}`, 66);
|
|
229
|
+
}
|
|
230
|
+
if (tracks.length === 0) fail(`source has no playable tracks: ${sourceArg}`, 66);
|
|
231
|
+
return tracks;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function playerCommand(player, volume, track) {
|
|
235
|
+
switch (player) {
|
|
236
|
+
case "mpv":
|
|
237
|
+
return [
|
|
238
|
+
"mpv",
|
|
239
|
+
[
|
|
240
|
+
"--no-video",
|
|
241
|
+
"--really-quiet",
|
|
242
|
+
"--force-window=no",
|
|
243
|
+
`--volume=${volume}`,
|
|
244
|
+
track,
|
|
245
|
+
],
|
|
246
|
+
];
|
|
247
|
+
case "ffplay":
|
|
248
|
+
return [
|
|
249
|
+
"ffplay",
|
|
250
|
+
[
|
|
251
|
+
"-nodisp",
|
|
252
|
+
"-hide_banner",
|
|
253
|
+
"-loglevel",
|
|
254
|
+
"warning",
|
|
255
|
+
"-autoexit",
|
|
256
|
+
"-volume",
|
|
257
|
+
String(volume),
|
|
258
|
+
track,
|
|
259
|
+
],
|
|
260
|
+
];
|
|
261
|
+
case "cvlc":
|
|
262
|
+
return [
|
|
263
|
+
"cvlc",
|
|
264
|
+
[
|
|
265
|
+
"--intf",
|
|
266
|
+
"dummy",
|
|
267
|
+
"--no-video",
|
|
268
|
+
"--play-and-exit",
|
|
269
|
+
"--volume",
|
|
270
|
+
String(Math.floor((volume * 256) / 100)),
|
|
271
|
+
track,
|
|
272
|
+
],
|
|
273
|
+
];
|
|
274
|
+
case "play":
|
|
275
|
+
return ["play", ["-q", track]];
|
|
276
|
+
default:
|
|
277
|
+
fail(`unsupported player: ${player}`, 2);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeText(path, value, flag = "w") {
|
|
282
|
+
writeFileSync(path, value, { encoding: "utf8", flag });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readText(path) {
|
|
286
|
+
try {
|
|
287
|
+
return readFileSync(path, "utf8");
|
|
288
|
+
} catch {
|
|
289
|
+
return "";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function emitTrackEvent(ctx, index, count, track, player) {
|
|
294
|
+
const title = track.split(/[\\/]/).filter(Boolean).pop() || track;
|
|
295
|
+
writeText(
|
|
296
|
+
ctx.eventFile,
|
|
297
|
+
`${JSON.stringify({
|
|
298
|
+
body: { count, index, player, track },
|
|
299
|
+
data: { count, index, player, track },
|
|
300
|
+
delivery: "log",
|
|
301
|
+
event: "player.track",
|
|
302
|
+
from: `run:${basename(ctx.stateDir)}`,
|
|
303
|
+
level: "info",
|
|
304
|
+
summary: `Now playing: ${title}`,
|
|
305
|
+
to: "coordinator",
|
|
306
|
+
ts: new Date().toISOString(),
|
|
307
|
+
type: "player.track",
|
|
308
|
+
})}\n`,
|
|
309
|
+
"a",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function writeStatus(ctx, state, index, count, track, player, pid = "") {
|
|
314
|
+
const updatedAt = new Date().toISOString();
|
|
315
|
+
writeText(
|
|
316
|
+
ctx.statusFile,
|
|
317
|
+
`state=${state}\nindex=${index}\ncount=${count}\ntrack=${track}\nplayer=${player}\npid=${pid}\nupdated_at=${updatedAt}\n`,
|
|
318
|
+
);
|
|
319
|
+
writeText(
|
|
320
|
+
ctx.statusJsonFile,
|
|
321
|
+
`${JSON.stringify({ state, index, count, track, player, pid: String(pid), updated_at: updatedAt })}\n`,
|
|
322
|
+
);
|
|
323
|
+
ctx.current = { count, index, pid: String(pid), player, state, track };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function setState(ctx, state) {
|
|
327
|
+
writeText(ctx.stateFile, state);
|
|
328
|
+
if (ctx.current) {
|
|
329
|
+
writeStatus(
|
|
330
|
+
ctx,
|
|
331
|
+
state,
|
|
332
|
+
ctx.current.index,
|
|
333
|
+
ctx.current.count,
|
|
334
|
+
ctx.current.track,
|
|
335
|
+
ctx.current.player,
|
|
336
|
+
ctx.current.pid,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sendSignalToCurrent(ctx, signal) {
|
|
342
|
+
const pid = Number(readText(ctx.pidFile).trim());
|
|
343
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
344
|
+
try {
|
|
345
|
+
process.kill(pid, signal);
|
|
346
|
+
return;
|
|
347
|
+
} catch {
|
|
348
|
+
// Fall through to process-group fallback.
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
process.kill(-pid, signal);
|
|
352
|
+
} catch {
|
|
353
|
+
// Best effort control signal.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function handleControl(ctx, input) {
|
|
358
|
+
const command = input.trim().toLowerCase();
|
|
359
|
+
if (!command) return;
|
|
360
|
+
if (!CONTROL_COMMANDS.has(command)) {
|
|
361
|
+
console.error(`music-player: unknown control command: ${command}`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
switch (command) {
|
|
365
|
+
case "play":
|
|
366
|
+
case "resume":
|
|
367
|
+
setState(ctx, "playing");
|
|
368
|
+
sendSignalToCurrent(ctx, "SIGCONT");
|
|
369
|
+
break;
|
|
370
|
+
case "pause":
|
|
371
|
+
setState(ctx, "paused");
|
|
372
|
+
sendSignalToCurrent(ctx, "SIGSTOP");
|
|
373
|
+
break;
|
|
374
|
+
case "toggle": {
|
|
375
|
+
const current = readText(ctx.stateFile).trim();
|
|
376
|
+
if (current === "paused") {
|
|
377
|
+
setState(ctx, "playing");
|
|
378
|
+
sendSignalToCurrent(ctx, "SIGCONT");
|
|
379
|
+
} else {
|
|
380
|
+
setState(ctx, "paused");
|
|
381
|
+
sendSignalToCurrent(ctx, "SIGSTOP");
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "next":
|
|
386
|
+
writeText(ctx.commandFile, "next");
|
|
387
|
+
sendSignalToCurrent(ctx, "SIGTERM");
|
|
388
|
+
break;
|
|
389
|
+
case "previous":
|
|
390
|
+
case "prev":
|
|
391
|
+
writeText(ctx.commandFile, "previous");
|
|
392
|
+
sendSignalToCurrent(ctx, "SIGTERM");
|
|
393
|
+
break;
|
|
394
|
+
case "stop":
|
|
395
|
+
writeText(ctx.commandFile, "stop");
|
|
396
|
+
sendSignalToCurrent(ctx, "SIGTERM");
|
|
397
|
+
break;
|
|
398
|
+
case "status":
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function makeFifo(path) {
|
|
404
|
+
rmSync(path, { force: true });
|
|
405
|
+
const result = spawnSync("mkfifo", [path], { encoding: "utf8" });
|
|
406
|
+
if (result.status !== 0) {
|
|
407
|
+
const error =
|
|
408
|
+
result.stderr.trim() || result.error?.message || "mkfifo failed";
|
|
409
|
+
fail(error, result.status || 1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function startControlLoop(ctx) {
|
|
414
|
+
makeFifo(ctx.controlFifo);
|
|
415
|
+
const fd = openSync(ctx.controlFifo, constants.O_RDWR | constants.O_NONBLOCK);
|
|
416
|
+
let carry = "";
|
|
417
|
+
let closed = false;
|
|
418
|
+
const close = () => {
|
|
419
|
+
if (closed) return;
|
|
420
|
+
closed = true;
|
|
421
|
+
try {
|
|
422
|
+
closeSync(fd);
|
|
423
|
+
} catch {}
|
|
424
|
+
};
|
|
425
|
+
const promise = (async () => {
|
|
426
|
+
const buffer = Buffer.alloc(4096);
|
|
427
|
+
while (!ctx.stopping) {
|
|
428
|
+
try {
|
|
429
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, null);
|
|
430
|
+
if (bytes > 0) {
|
|
431
|
+
carry += buffer.subarray(0, bytes).toString("utf8");
|
|
432
|
+
const lines = carry.split("\n");
|
|
433
|
+
carry = lines.pop() || "";
|
|
434
|
+
for (const line of lines) handleControl(ctx, line);
|
|
435
|
+
} else {
|
|
436
|
+
await sleep(50);
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (ctx.stopping || closed) break;
|
|
440
|
+
if (["EAGAIN", "EWOULDBLOCK"].includes(error.code)) {
|
|
441
|
+
await sleep(50);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
console.error(`music-player: control loop error: ${error.message}`);
|
|
445
|
+
await sleep(250);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
})();
|
|
449
|
+
return { close, promise };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function playOne(ctx, player, volume, track, index, count) {
|
|
453
|
+
return new Promise((resolveDone) => {
|
|
454
|
+
const [command, args] = playerCommand(player, volume, track);
|
|
455
|
+
writeStatus(ctx, "playing", index, count, track, player, "");
|
|
456
|
+
const child = spawn(command, args, {
|
|
457
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
458
|
+
});
|
|
459
|
+
ctx.child = child;
|
|
460
|
+
const pid = child.pid || "";
|
|
461
|
+
if (pid) writeText(ctx.pidFile, String(pid));
|
|
462
|
+
writeStatus(ctx, "playing", index, count, track, player, pid);
|
|
463
|
+
emitTrackEvent(ctx, index, count, track, player);
|
|
464
|
+
child.once("error", (error) => {
|
|
465
|
+
console.error(
|
|
466
|
+
`music-player: failed to start ${command}: ${error.message}`,
|
|
467
|
+
);
|
|
468
|
+
resolveDone();
|
|
469
|
+
});
|
|
470
|
+
child.once("exit", () => {
|
|
471
|
+
rmSync(ctx.pidFile, { force: true });
|
|
472
|
+
ctx.child = undefined;
|
|
473
|
+
resolveDone();
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function readAndClearCommand(ctx) {
|
|
479
|
+
const command = readText(ctx.commandFile).trim();
|
|
480
|
+
writeText(ctx.commandFile, "");
|
|
481
|
+
return command;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function playMain(args) {
|
|
485
|
+
ensureUnixFifoSupport();
|
|
486
|
+
const [
|
|
487
|
+
sourceArg,
|
|
488
|
+
loopArg = "true",
|
|
489
|
+
volumeArg = "70",
|
|
490
|
+
playerArg = "auto",
|
|
491
|
+
rawStateDir,
|
|
492
|
+
] = args;
|
|
493
|
+
if (!sourceArg || sourceArg === "-h" || sourceArg === "--help") {
|
|
494
|
+
usage();
|
|
495
|
+
process.exit(2);
|
|
496
|
+
}
|
|
497
|
+
const stateDir = expandPath(
|
|
498
|
+
rawStateDir ||
|
|
499
|
+
join(
|
|
500
|
+
process.env.TMPDIR || "/tmp",
|
|
501
|
+
`pi-actors-music-player-${process.pid}`,
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
mkdirSync(stateDir, { recursive: true });
|
|
505
|
+
const ctx = {
|
|
506
|
+
commandFile: join(stateDir, "command.txt"),
|
|
507
|
+
controlFifo: join(stateDir, "control.fifo"),
|
|
508
|
+
current: undefined,
|
|
509
|
+
eventFile: join(stateDir, "outbox.jsonl"),
|
|
510
|
+
pidFile: join(stateDir, "current.pid"),
|
|
511
|
+
stateDir,
|
|
512
|
+
stateFile: join(stateDir, "player-state.txt"),
|
|
513
|
+
statusFile: join(stateDir, "status.txt"),
|
|
514
|
+
statusJsonFile: join(stateDir, "player.json"),
|
|
515
|
+
stopping: false,
|
|
516
|
+
};
|
|
517
|
+
rmSync(ctx.controlFifo, { force: true });
|
|
518
|
+
rmSync(ctx.commandFile, { force: true });
|
|
519
|
+
rmSync(ctx.pidFile, { force: true });
|
|
520
|
+
const loop = parseBool(loopArg);
|
|
521
|
+
const volume = normalizeVolume(volumeArg);
|
|
522
|
+
const player = selectPlayer(playerArg);
|
|
523
|
+
const tracks = loadPlaylist(sourceArg);
|
|
524
|
+
let controlLoop;
|
|
525
|
+
const cleanup = () => {
|
|
526
|
+
ctx.stopping = true;
|
|
527
|
+
writeText(ctx.stateFile, "stopped");
|
|
528
|
+
if (ctx.child?.pid) {
|
|
529
|
+
try {
|
|
530
|
+
process.kill(ctx.child.pid, "SIGTERM");
|
|
531
|
+
} catch {}
|
|
532
|
+
}
|
|
533
|
+
controlLoop?.close();
|
|
534
|
+
rmSync(ctx.pidFile, { force: true });
|
|
535
|
+
rmSync(ctx.controlFifo, { force: true });
|
|
536
|
+
};
|
|
537
|
+
process.once("SIGTERM", () => {
|
|
538
|
+
cleanup();
|
|
539
|
+
process.exit(143);
|
|
540
|
+
});
|
|
541
|
+
process.once("SIGINT", () => {
|
|
542
|
+
cleanup();
|
|
543
|
+
process.exit(130);
|
|
544
|
+
});
|
|
545
|
+
process.once("SIGHUP", () => {
|
|
546
|
+
cleanup();
|
|
547
|
+
process.exit(129);
|
|
548
|
+
});
|
|
549
|
+
try {
|
|
550
|
+
controlLoop = startControlLoop(ctx);
|
|
551
|
+
setState(ctx, "playing");
|
|
552
|
+
console.error(
|
|
553
|
+
`music-player: player=${player} loop=${loop} volume=${volume} tracks=${tracks.length} state_dir=${stateDir}`,
|
|
554
|
+
);
|
|
555
|
+
let index = 0;
|
|
556
|
+
const count = tracks.length;
|
|
557
|
+
while (!ctx.stopping) {
|
|
558
|
+
const track = tracks[index];
|
|
559
|
+
await playOne(ctx, player, volume, track, index, count);
|
|
560
|
+
const command = readAndClearCommand(ctx);
|
|
561
|
+
if (command === "stop") {
|
|
562
|
+
writeStatus(ctx, "stopped", index, count, track, player, "");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (command === "previous" || command === "prev") {
|
|
566
|
+
index = (index - 1 + count) % count;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (command === "next") {
|
|
570
|
+
index = (index + 1) % count;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (index + 1 >= count) {
|
|
574
|
+
if (loop) index = 0;
|
|
575
|
+
else break;
|
|
576
|
+
} else {
|
|
577
|
+
index += 1;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
writeStatus(ctx, "stopped", index, tracks.length, "", player, "");
|
|
581
|
+
} finally {
|
|
582
|
+
cleanup();
|
|
583
|
+
await Promise.race([controlLoop?.promise ?? Promise.resolve(), sleep(100)]);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function controlMain(args) {
|
|
588
|
+
const stateDir = expandPath(args[0] || "");
|
|
589
|
+
const command = args[1] || "status";
|
|
590
|
+
if (!stateDir) {
|
|
591
|
+
usage();
|
|
592
|
+
process.exit(2);
|
|
593
|
+
}
|
|
594
|
+
mkdirSync(stateDir, { recursive: true });
|
|
595
|
+
if (command === "status") {
|
|
596
|
+
const statusFile = join(stateDir, "status.txt");
|
|
597
|
+
process.stdout.write(
|
|
598
|
+
exists(statusFile) ? readText(statusFile) : "state=unknown\n",
|
|
599
|
+
);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
ensureUnixFifoSupport();
|
|
603
|
+
const fifo = join(stateDir, "control.fifo");
|
|
604
|
+
if (!isFifo(fifo)) fail(`control fifo not found: ${fifo}`, 75);
|
|
605
|
+
let fd;
|
|
606
|
+
try {
|
|
607
|
+
fd = openSync(fifo, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
608
|
+
writeSync(fd, command.endsWith("\n") ? command : `${command}\n`);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
fail(`control fifo is not ready: ${fifo}: ${error.message}`, 75);
|
|
611
|
+
} finally {
|
|
612
|
+
if (fd !== undefined) closeSync(fd);
|
|
613
|
+
}
|
|
614
|
+
console.log(`music-player: command=${command} sent state_dir=${stateDir}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const [mode, ...rest] = process.argv.slice(2);
|
|
618
|
+
const directControlCommands = new Set([
|
|
619
|
+
"pause",
|
|
620
|
+
"resume",
|
|
621
|
+
"toggle",
|
|
622
|
+
"next",
|
|
623
|
+
"previous",
|
|
624
|
+
"prev",
|
|
625
|
+
"stop",
|
|
626
|
+
"status",
|
|
627
|
+
]);
|
|
628
|
+
if (mode === "play") await playMain(rest);
|
|
629
|
+
else if (mode === "control") controlMain(rest);
|
|
630
|
+
else if (directControlCommands.has(mode)) {
|
|
631
|
+
controlMain([rest[0], mode === "resume" ? "play" : mode]);
|
|
632
|
+
} else if (!mode || mode === "-h" || mode === "--help" || mode === "help") {
|
|
633
|
+
usage();
|
|
634
|
+
process.exit(mode ? 0 : 2);
|
|
635
|
+
} else {
|
|
636
|
+
await playMain([mode, ...rest]);
|
|
637
|
+
}
|