@groovenet/player 0.1.3 → 0.1.4
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/MPRIS.md +97 -0
- package/OWNTONE.md +145 -0
- package/README.md +14 -0
- package/SERVER_PLAYERS.md +215 -0
- package/build/daemon.d.ts.map +1 -1
- package/build/daemon.js +3 -0
- package/build/daemon.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/mpris.d.ts +11 -0
- package/build/mpris.d.ts.map +1 -0
- package/build/mpris.js +145 -0
- package/build/mpris.js.map +1 -0
- package/build/mprisBin.d.ts +3 -0
- package/build/mprisBin.d.ts.map +1 -0
- package/build/mprisBin.js +26 -0
- package/build/mprisBin.js.map +1 -0
- package/build/mprisMapping.d.ts +12 -0
- package/build/mprisMapping.d.ts.map +1 -0
- package/build/mprisMapping.js +35 -0
- package/build/mprisMapping.js.map +1 -0
- package/build/mpvBackend.d.ts.map +1 -1
- package/build/mpvBackend.js +2 -1
- package/build/mpvBackend.js.map +1 -1
- package/build/ownToneBackend.d.ts +62 -0
- package/build/ownToneBackend.d.ts.map +1 -0
- package/build/ownToneBackend.js +349 -0
- package/build/ownToneBackend.js.map +1 -0
- package/build/player.d.ts +1 -0
- package/build/player.d.ts.map +1 -1
- package/build/player.js +11 -0
- package/build/player.js.map +1 -1
- package/build/protocol.d.ts.map +1 -1
- package/build/protocol.js +1 -0
- package/build/protocol.js.map +1 -1
- package/build/resolver.d.ts +3 -1
- package/build/resolver.d.ts.map +1 -1
- package/build/resolver.js +30 -1
- package/build/resolver.js.map +1 -1
- package/build/server/bin.d.ts +3 -0
- package/build/server/bin.d.ts.map +1 -0
- package/build/server/bin.js +7 -0
- package/build/server/bin.js.map +1 -0
- package/build/server/http.d.ts +5 -0
- package/build/server/http.d.ts.map +1 -0
- package/build/server/http.js +129 -0
- package/build/server/http.js.map +1 -0
- package/build/server/manager.d.ts +45 -0
- package/build/server/manager.d.ts.map +1 -0
- package/build/server/manager.js +423 -0
- package/build/server/manager.js.map +1 -0
- package/build/server/observer.d.ts +9 -0
- package/build/server/observer.d.ts.map +1 -0
- package/build/server/observer.js +29 -0
- package/build/server/observer.js.map +1 -0
- package/build/server/runtime.d.ts +16 -0
- package/build/server/runtime.d.ts.map +1 -0
- package/build/server/runtime.js +78 -0
- package/build/server/runtime.js.map +1 -0
- package/build/server/store.d.ts +33 -0
- package/build/server/store.d.ts.map +1 -0
- package/build/server/store.js +108 -0
- package/build/server/store.js.map +1 -0
- package/build/types.d.ts +3 -1
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/owntone/Dockerfile +27 -0
- package/owntone/smoke.mjs +135 -0
- package/owntone/terminal-v1.patch +96 -0
- package/package.json +24 -4
- package/scripts/mpris-smoke.mjs +54 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { ServerPlayerManager } from "./manager.js";
|
|
3
|
+
/** The worker API is private to Compose. Public clients go through the app. */
|
|
4
|
+
export declare function playerHttpServer(manager: ServerPlayerManager, key: string): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
5
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AAMpF,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAyBnD,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,MAAM,6EAwDzE"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { ZodError } from "zod";
|
|
5
|
+
import { createServerPlayerSchema, playerIdSchema, serverPlayerCommandSchema } from "@groovenet/contracts";
|
|
6
|
+
import { PlayerError } from "../types.js";
|
|
7
|
+
import { ServerPlayerError } from "./store.js";
|
|
8
|
+
function authorized(header, key) {
|
|
9
|
+
const actual = Buffer.from(header ?? "");
|
|
10
|
+
const expected = Buffer.from(`Bearer ${key}`);
|
|
11
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
12
|
+
}
|
|
13
|
+
async function body(request) {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let bytes = 0;
|
|
16
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
17
|
+
const buffer = Buffer.from(chunk);
|
|
18
|
+
bytes += buffer.length;
|
|
19
|
+
if (bytes > 2 * 1024 * 1024) {
|
|
20
|
+
request.resume();
|
|
21
|
+
throw new ServerPlayerError("BODY_TOO_LARGE", "Player command exceeds 2 MB", 413);
|
|
22
|
+
}
|
|
23
|
+
chunks.push(buffer);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(Buffer.concat(chunks).toString());
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new ServerPlayerError("INVALID_JSON", "Invalid JSON request", 400);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function json(response, status, value) {
|
|
33
|
+
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
34
|
+
response.end(JSON.stringify(value));
|
|
35
|
+
}
|
|
36
|
+
/** The worker API is private to Compose. Public clients go through the app. */
|
|
37
|
+
export function playerHttpServer(manager, key) {
|
|
38
|
+
if (key.length < 32)
|
|
39
|
+
throw new Error("PLAYER_API_KEY must contain at least 32 characters");
|
|
40
|
+
const instance = randomUUID();
|
|
41
|
+
let sequence = 0;
|
|
42
|
+
const server = createServer((request, response) => {
|
|
43
|
+
void (async () => {
|
|
44
|
+
if (!authorized(request.headers.authorization, key)) {
|
|
45
|
+
json(response, 401, { error: "Unauthorized", code: "UNAUTHORIZED" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const path = new URL(request.url ?? "/", "http://worker").pathname;
|
|
49
|
+
if (request.method === "GET" && path === "/health") {
|
|
50
|
+
json(response, 200, { status: "ok", instance });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (request.method === "GET" && path === "/engines") {
|
|
54
|
+
json(response, 200, { engines: manager.engineIds() });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (path === "/players") {
|
|
58
|
+
if (request.method === "GET") {
|
|
59
|
+
json(response, 200, { players: manager.list() });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (request.method === "POST") {
|
|
63
|
+
json(response, 201, await manager.create(createServerPlayerSchema.parse(await body(request))));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const match = /^\/players\/([^/]+)(?:\/(commands|devices|events))?$/.exec(path);
|
|
68
|
+
if (!match) {
|
|
69
|
+
json(response, 404, { error: "Not found" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const id = playerIdSchema.parse(match[1]);
|
|
73
|
+
const player = manager.get(id);
|
|
74
|
+
if (!match[2] && request.method === "GET") {
|
|
75
|
+
json(response, 200, player);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (match[2] === "devices" && request.method === "GET") {
|
|
79
|
+
json(response, 200, { devices: await manager.devices(id) });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (match[2] === "commands" && request.method === "POST") {
|
|
83
|
+
json(response, 200, await manager.command(id, serverPlayerCommandSchema.parse(await body(request))));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (match[2] === "events" && request.method === "GET") {
|
|
87
|
+
response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
|
88
|
+
const send = (value) => {
|
|
89
|
+
if (value.id !== id || response.destroyed)
|
|
90
|
+
return;
|
|
91
|
+
const event = JSON.stringify({ type: "snapshot", player: value, worker_instance: instance, sequence: ++sequence });
|
|
92
|
+
// A slow/disconnected client is dropped; it never blocks the player.
|
|
93
|
+
response.write(`event: snapshot\ndata: ${event}\n\n`);
|
|
94
|
+
if (response.writableLength > 64 * 1024 * 1024)
|
|
95
|
+
response.destroy();
|
|
96
|
+
};
|
|
97
|
+
const position = (value) => {
|
|
98
|
+
if (value.player_id === id && !response.destroyed) {
|
|
99
|
+
response.write(`event: position\ndata: ${JSON.stringify({ ...value, type: "position", worker_instance: instance, sequence: ++sequence })}\n\n`);
|
|
100
|
+
if (response.writableLength > 64 * 1024 * 1024)
|
|
101
|
+
response.destroy();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const heartbeat = setInterval(() => { if (!response.destroyed && !response.write(": heartbeat\n\n"))
|
|
105
|
+
response.destroy(); }, 15000);
|
|
106
|
+
heartbeat.unref();
|
|
107
|
+
const cleanup = () => { clearInterval(heartbeat); manager.off("snapshot", send); manager.off("position", position); };
|
|
108
|
+
response.once("close", cleanup);
|
|
109
|
+
manager.on("snapshot", send);
|
|
110
|
+
manager.on("position", position);
|
|
111
|
+
send(player);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
json(response, 405, { error: "Method not allowed" });
|
|
115
|
+
})().catch((error) => {
|
|
116
|
+
if (response.headersSent) {
|
|
117
|
+
response.destroy();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const status = error instanceof ServerPlayerError ? error.status : error instanceof ZodError ? 400 : error instanceof PlayerError ? 422 : 502;
|
|
121
|
+
const code = error instanceof ServerPlayerError ? error.code : error instanceof ZodError ? "INVALID_ARGUMENT" : error instanceof PlayerError ? error.code : "PLAYER_OPERATION_FAILED";
|
|
122
|
+
json(response, status, { error: error instanceof ServerPlayerError ? error.message : "Player operation failed", code });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
server.requestTimeout = 15000;
|
|
126
|
+
server.headersTimeout = 10000;
|
|
127
|
+
return server;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { type CreateServerPlayer, type ServerPlayer, type ServerPlayerCommand, type ServerPlayerCommandResult, type ServerPlayerDevice, type SpeakerObservation } from "@groovenet/contracts";
|
|
3
|
+
import { type PlaybackBackend } from "../types.js";
|
|
4
|
+
import { type PlayerStore } from "./store.js";
|
|
5
|
+
export interface PlayerEngine {
|
|
6
|
+
libraryRoot: string;
|
|
7
|
+
create(outputId?: string): PlaybackBackend;
|
|
8
|
+
}
|
|
9
|
+
export type SpeakerObserver = (outputId: string) => Promise<SpeakerObservation>;
|
|
10
|
+
export declare const unknownSpeaker: () => SpeakerObservation;
|
|
11
|
+
/** Owns all player lifecycles; no HTTP connection owns a backend. */
|
|
12
|
+
export declare class ServerPlayerManager extends EventEmitter {
|
|
13
|
+
private readonly store;
|
|
14
|
+
private readonly engines;
|
|
15
|
+
private readonly observe;
|
|
16
|
+
private players;
|
|
17
|
+
private runtimes;
|
|
18
|
+
private generation;
|
|
19
|
+
private tail;
|
|
20
|
+
private stopping;
|
|
21
|
+
private checkpointAt;
|
|
22
|
+
constructor(store: PlayerStore, engines: Record<string, PlayerEngine>, observe?: SpeakerObserver);
|
|
23
|
+
private serial;
|
|
24
|
+
initialize(): Promise<void>;
|
|
25
|
+
engineIds(): string[];
|
|
26
|
+
list(): ServerPlayer[];
|
|
27
|
+
get(id: string): ServerPlayer;
|
|
28
|
+
create(input: CreateServerPlayer): Promise<ServerPlayer>;
|
|
29
|
+
devices(id: string): Promise<ServerPlayerDevice[]>;
|
|
30
|
+
command(id: string, input: ServerPlayerCommand): Promise<ServerPlayerCommandResult>;
|
|
31
|
+
private play;
|
|
32
|
+
private enqueueEvent;
|
|
33
|
+
private detach;
|
|
34
|
+
private stop;
|
|
35
|
+
private transfer;
|
|
36
|
+
shutdown(): Promise<void>;
|
|
37
|
+
private requireAvailable;
|
|
38
|
+
private engine;
|
|
39
|
+
private active;
|
|
40
|
+
private revision;
|
|
41
|
+
private available;
|
|
42
|
+
private save;
|
|
43
|
+
private publish;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/server/manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,EAAuD,KAAK,kBAAkB,EAAE,KAAK,YAAY,EAAE,KAAK,mBAAmB,EAAE,KAAK,yBAAyB,EAAE,KAAK,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAEnP,OAAO,EAAe,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,eAAe,CAAC;CAC5C;AACD,MAAM,MAAM,eAAe,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;AAChF,eAAO,MAAM,cAAc,QAAO,kBAA0H,CAAC;AAK7J,qEAAqE;AACrE,qBAAa,mBAAoB,SAAQ,YAAY;IAQvC,OAAO,CAAC,QAAQ,CAAC,KAAK;IAAe,OAAO,CAAC,QAAQ,CAAC,OAAO;IAAgC,OAAO,CAAC,QAAQ,CAAC,OAAO;IAPjI,OAAO,CAAC,OAAO,CAAmC;IAClD,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,IAAI,CAAuC;IACnD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,YAAY,CAA6B;IAEjD,YAA6B,KAAK,EAAE,WAAW,EAAmB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EAAmB,OAAO,GAAE,eAA8C,EAAc;IAE/L,OAAO,CAAC,MAAM;IAMR,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAShC;IAED,SAAS,IAAI,MAAM,EAAE,CAA6C;IAClE,IAAI,IAAI,YAAY,EAAE,CAAkD;IACxE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,CAI5B;IAED,MAAM,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAUvD;IAEK,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAYvD;IAED,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAwFlF;YAEa,IAAI;IA2ElB,OAAO,CAAC,YAAY;YAcN,MAAM;YAYN,IAAI;YAOJ,QAAQ;IA2BhB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAQ9B;YAEa,gBAAgB;IAM9B,OAAO,CAAC,MAAM;IAKd,OAAO,CAAC,MAAM;IAKd,OAAO,CAAC,QAAQ;IAGhB,OAAO,CAAC,SAAS;YACH,IAAI;IAMlB,OAAO,CAAC,OAAO;CAChB"}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createServerPlayerSchema, serverPlayerCommandSchema } from "@groovenet/contracts";
|
|
4
|
+
import { audioSource } from "../resolver.js";
|
|
5
|
+
import { PlayerError } from "../types.js";
|
|
6
|
+
import { ServerPlayerError } from "./store.js";
|
|
7
|
+
export const unknownSpeaker = () => ({ availability: "unknown", observed_at: null, source: "unconfigured", sender_identity: "unknown" });
|
|
8
|
+
const errorValue = (error) => error instanceof PlayerError || error instanceof ServerPlayerError ? { code: error.code, message: error.message } : { code: "PLAYER_OPERATION_FAILED", message: "Player operation failed" };
|
|
9
|
+
const clone = (value) => structuredClone(value);
|
|
10
|
+
/** Owns all player lifecycles; no HTTP connection owns a backend. */
|
|
11
|
+
export class ServerPlayerManager extends EventEmitter {
|
|
12
|
+
store;
|
|
13
|
+
engines;
|
|
14
|
+
observe;
|
|
15
|
+
players = new Map();
|
|
16
|
+
runtimes = new Map();
|
|
17
|
+
generation = 0;
|
|
18
|
+
tail = Promise.resolve();
|
|
19
|
+
stopping = false;
|
|
20
|
+
checkpointAt = new Map();
|
|
21
|
+
constructor(store, engines, observe = async () => unknownSpeaker()) {
|
|
22
|
+
super();
|
|
23
|
+
this.store = store;
|
|
24
|
+
this.engines = engines;
|
|
25
|
+
this.observe = observe;
|
|
26
|
+
}
|
|
27
|
+
serial(action) {
|
|
28
|
+
const result = this.tail.then(action);
|
|
29
|
+
this.tail = result.catch(() => { });
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
async initialize() {
|
|
33
|
+
// No engine object or network connection is created during restoration.
|
|
34
|
+
for (const saved of await this.store.list()) {
|
|
35
|
+
const player = clone(saved);
|
|
36
|
+
player.assigned_output = null;
|
|
37
|
+
player.state.status = "stopped";
|
|
38
|
+
player.state.checkpoint = true;
|
|
39
|
+
this.players.set(player.id, await this.store.save(player, saved.revision));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
engineIds() { return Object.keys(this.engines).sort(); }
|
|
43
|
+
list() { return [...this.players.values()].map(clone); }
|
|
44
|
+
get(id) {
|
|
45
|
+
const player = this.players.get(id);
|
|
46
|
+
if (!player)
|
|
47
|
+
throw new ServerPlayerError("PLAYER_NOT_FOUND", "Player does not exist", 404);
|
|
48
|
+
return clone(player);
|
|
49
|
+
}
|
|
50
|
+
create(input) {
|
|
51
|
+
return this.serial(async () => {
|
|
52
|
+
this.available();
|
|
53
|
+
const parsed = createServerPlayerSchema.parse(input);
|
|
54
|
+
if (!this.engines[parsed.engine_id])
|
|
55
|
+
throw new ServerPlayerError("ENGINE_NOT_CONFIGURED", "Engine is not configured on this server", 400);
|
|
56
|
+
const player = await this.store.create(parsed, { queue: [], current_entry_id: null, status: "stopped", position: 0, duration: null, volume: 50, desired_output: null, checkpoint: false, error: null });
|
|
57
|
+
this.players.set(player.id, player);
|
|
58
|
+
this.publish(player);
|
|
59
|
+
return clone(player);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async devices(id) {
|
|
63
|
+
const player = this.get(id);
|
|
64
|
+
const runtime = this.runtimes.get(id);
|
|
65
|
+
const backend = runtime?.backend ?? this.engine(player).create();
|
|
66
|
+
try {
|
|
67
|
+
const outputs = await backend.listDevices();
|
|
68
|
+
return await Promise.all(outputs.map(async (output) => ({ ...output,
|
|
69
|
+
assigned_player_id: this.list().find((p) => p.assigned_output === output.id)?.id ?? null,
|
|
70
|
+
desired_by: this.list().filter((p) => p.state.desired_output === output.id).map((p) => p.id),
|
|
71
|
+
observed: await this.observe(output.id),
|
|
72
|
+
})));
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (!runtime)
|
|
76
|
+
await backend.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
command(id, input) {
|
|
80
|
+
return this.serial(async () => {
|
|
81
|
+
this.available();
|
|
82
|
+
const command = serverPlayerCommandSchema.parse(input);
|
|
83
|
+
let player = this.get(id);
|
|
84
|
+
this.revision(player, command.expected_revision);
|
|
85
|
+
if (command.type === "output.transfer")
|
|
86
|
+
return this.transfer(player, command);
|
|
87
|
+
try {
|
|
88
|
+
switch (command.type) {
|
|
89
|
+
case "rename":
|
|
90
|
+
player.name = command.name;
|
|
91
|
+
break;
|
|
92
|
+
case "queue.add": {
|
|
93
|
+
const at = command.at ?? player.state.queue.length;
|
|
94
|
+
if (at > player.state.queue.length || player.state.queue.length + command.tracks.length > 10000)
|
|
95
|
+
throw new ServerPlayerError("INVALID_QUEUE", "Queue insertion is out of range", 400);
|
|
96
|
+
const added = [];
|
|
97
|
+
for (const ref of command.tracks) {
|
|
98
|
+
const track = await this.store.resolve(ref);
|
|
99
|
+
if (!track)
|
|
100
|
+
throw new ServerPlayerError("TRACK_NOT_FOUND", "Queued track does not exist or is ambiguous", 404);
|
|
101
|
+
added.push({ ...ref, id: randomUUID(), title: track.title, artist: track.artist });
|
|
102
|
+
}
|
|
103
|
+
player.state.queue.splice(at, 0, ...added);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "queue.remove": {
|
|
107
|
+
const index = player.state.queue.findIndex((entry) => entry.id === command.entry_id);
|
|
108
|
+
if (index < 0)
|
|
109
|
+
throw new ServerPlayerError("ENTRY_NOT_FOUND", "Queue entry does not exist", 404);
|
|
110
|
+
if (command.entry_id === player.state.current_entry_id)
|
|
111
|
+
await this.stop(player);
|
|
112
|
+
player.state.queue.splice(index, 1);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "queue.move": {
|
|
116
|
+
const index = player.state.queue.findIndex((entry) => entry.id === command.entry_id);
|
|
117
|
+
if (index < 0 || command.to >= player.state.queue.length)
|
|
118
|
+
throw new ServerPlayerError("INVALID_QUEUE", "Queue move is out of range", 400);
|
|
119
|
+
player.state.queue.splice(command.to, 0, player.state.queue.splice(index, 1)[0]);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case "queue.clear":
|
|
123
|
+
await this.stop(player);
|
|
124
|
+
player.state.queue = [];
|
|
125
|
+
break;
|
|
126
|
+
case "output.select":
|
|
127
|
+
if (this.runtimes.has(id))
|
|
128
|
+
await this.stop(player, true);
|
|
129
|
+
player.state.desired_output = command.output_id;
|
|
130
|
+
// Remembering a desired output does not reserve or connect it.
|
|
131
|
+
break;
|
|
132
|
+
case "play": {
|
|
133
|
+
const entryId = command.entry_id ?? player.state.current_entry_id ?? player.state.queue[0]?.id;
|
|
134
|
+
await this.play(player, entryId, command);
|
|
135
|
+
return { player: this.get(id) };
|
|
136
|
+
}
|
|
137
|
+
case "resume":
|
|
138
|
+
if (this.runtimes.has(id)) {
|
|
139
|
+
const backend = this.runtimes.get(id).backend;
|
|
140
|
+
if (await backend.outputConnection?.() !== "connected")
|
|
141
|
+
await this.requireAvailable(player.state.desired_output, command);
|
|
142
|
+
await backend.pause(false);
|
|
143
|
+
player.state.status = "playing";
|
|
144
|
+
player.state.checkpoint = false;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
await this.play(player, player.state.current_entry_id ?? undefined, command);
|
|
148
|
+
return { player: this.get(id) };
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "pause":
|
|
152
|
+
await this.active(id).backend.pause(true);
|
|
153
|
+
player.state.status = "paused";
|
|
154
|
+
break;
|
|
155
|
+
case "stop":
|
|
156
|
+
await this.stop(player);
|
|
157
|
+
break;
|
|
158
|
+
case "next":
|
|
159
|
+
case "previous": {
|
|
160
|
+
const index = player.state.queue.findIndex((entry) => entry.id === player.state.current_entry_id);
|
|
161
|
+
const next = command.type === "next" ? index + 1 : Math.max(0, index - 1);
|
|
162
|
+
if (next >= player.state.queue.length) {
|
|
163
|
+
await this.stop(player);
|
|
164
|
+
player.state.status = "ended";
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
await this.play(player, player.state.queue[next]?.id, command, false);
|
|
168
|
+
return { player: this.get(id) };
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "seek":
|
|
173
|
+
if (!player.state.current_entry_id)
|
|
174
|
+
throw new ServerPlayerError("NO_CURRENT_TRACK", "No current track", 400);
|
|
175
|
+
if (this.runtimes.has(id))
|
|
176
|
+
await this.active(id).backend.seek(command.seconds);
|
|
177
|
+
player.state.position = command.seconds;
|
|
178
|
+
player.state.checkpoint = !this.runtimes.has(id);
|
|
179
|
+
break;
|
|
180
|
+
case "volume":
|
|
181
|
+
if (this.runtimes.has(id))
|
|
182
|
+
await this.active(id).backend.setVolume(command.volume);
|
|
183
|
+
player.state.volume = command.volume;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
// Queue/settings edits do not recover failed playback. Keep its cause
|
|
187
|
+
// visible until a transport command successfully leaves the error state.
|
|
188
|
+
if (player.state.status !== "error")
|
|
189
|
+
player.state.error = null;
|
|
190
|
+
player = await this.save(player);
|
|
191
|
+
return { player: clone(player) };
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
// Validation failures do not mutate authoritative state. Engine operations
|
|
195
|
+
// persist an actionable error; they never advance or silently retry.
|
|
196
|
+
if (!(error instanceof ServerPlayerError)) {
|
|
197
|
+
player = this.get(id);
|
|
198
|
+
player.state.status = "error";
|
|
199
|
+
player.state.error = errorValue(error);
|
|
200
|
+
await this.save(player);
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async play(player, entryId, policy, resumeCheckpoint = true) {
|
|
207
|
+
const entry = player.state.queue.find((entry) => entry.id === entryId);
|
|
208
|
+
if (!entry)
|
|
209
|
+
throw new ServerPlayerError("ENTRY_NOT_FOUND", "Queue is empty or the entry does not exist", 400);
|
|
210
|
+
const output = player.state.desired_output;
|
|
211
|
+
if (!output)
|
|
212
|
+
throw new ServerPlayerError("OUTPUT_REQUIRED", "Select a desired output before playing", 400);
|
|
213
|
+
const owner = this.list().find((p) => p.assigned_output === output && p.id !== player.id);
|
|
214
|
+
if (owner)
|
|
215
|
+
throw new ServerPlayerError("OUTPUT_ASSIGNED", "Speaker belongs to another player; use explicit transfer");
|
|
216
|
+
const needsReconnect = (player.state.status === "paused" || player.state.status === "error") && await this.runtimes.get(player.id)?.backend.outputConnection?.() !== "connected";
|
|
217
|
+
if (player.assigned_output !== output || needsReconnect)
|
|
218
|
+
await this.requireAvailable(output, policy);
|
|
219
|
+
const track = await this.store.resolve(entry);
|
|
220
|
+
const engine = this.engine(player);
|
|
221
|
+
const source = track ? audioSource(track, { apiBase: "http://unused/api", audioLibraryRoot: engine.libraryRoot }) : null;
|
|
222
|
+
if (!source)
|
|
223
|
+
throw new ServerPlayerError("NO_AUDIO", "Current track has no indexed audio source", 422);
|
|
224
|
+
const position = resumeCheckpoint && player.state.current_entry_id === entry.id ? player.state.position : 0;
|
|
225
|
+
await this.detach(player);
|
|
226
|
+
player.assigned_output = output;
|
|
227
|
+
player.state.current_entry_id = entry.id;
|
|
228
|
+
player.state.position = position;
|
|
229
|
+
player.state.duration = null;
|
|
230
|
+
player.state.status = "loading";
|
|
231
|
+
player.state.checkpoint = false;
|
|
232
|
+
player.state.error = null;
|
|
233
|
+
// Persist the selected entry before engine effects. A crash never replays a
|
|
234
|
+
// completed entry or automatically starts this pending load on restoration.
|
|
235
|
+
player = await this.save(player);
|
|
236
|
+
this.available();
|
|
237
|
+
const backend = engine.create(output);
|
|
238
|
+
const runtime = { backend, generation: ++this.generation };
|
|
239
|
+
this.runtimes.set(player.id, runtime);
|
|
240
|
+
backend.on("position", (position, duration) => this.enqueueEvent(player.id, runtime, async (current) => {
|
|
241
|
+
if (!Number.isFinite(position) || position < 0)
|
|
242
|
+
return;
|
|
243
|
+
current.state.position = position;
|
|
244
|
+
current.state.duration = duration !== null && Number.isFinite(duration) && duration >= 0 ? duration : null;
|
|
245
|
+
current.state.checkpoint = false;
|
|
246
|
+
this.players.set(current.id, current);
|
|
247
|
+
this.emit("position", { player_id: current.id, revision: current.revision, current_entry_id: current.state.current_entry_id, position: current.state.position, duration: current.state.duration, observed_at: new Date().toISOString() });
|
|
248
|
+
if (Date.now() - (this.checkpointAt.get(current.id) ?? 0) >= 5000) {
|
|
249
|
+
await this.store.checkpoint(current);
|
|
250
|
+
this.checkpointAt.set(current.id, Date.now());
|
|
251
|
+
}
|
|
252
|
+
}));
|
|
253
|
+
backend.on("ended", () => this.enqueueEvent(player.id, runtime, async (current) => {
|
|
254
|
+
const index = current.state.queue.findIndex((entry) => entry.id === current.state.current_entry_id);
|
|
255
|
+
const next = current.state.queue[index + 1];
|
|
256
|
+
if (next) {
|
|
257
|
+
current.state.current_entry_id = next.id;
|
|
258
|
+
current.state.position = 0;
|
|
259
|
+
current.state.duration = null;
|
|
260
|
+
current.state.status = "loading";
|
|
261
|
+
const advanced = await this.save(current);
|
|
262
|
+
await this.play(advanced, next.id, { allow_unknown: true, takeover: false }, false);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
await this.stop(current);
|
|
266
|
+
current.state.status = "ended";
|
|
267
|
+
await this.save(current);
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
backend.on("error", (error) => this.enqueueEvent(player.id, runtime, async (current) => {
|
|
271
|
+
current.state.status = "error";
|
|
272
|
+
current.state.error = errorValue(error);
|
|
273
|
+
try {
|
|
274
|
+
await this.detach(current);
|
|
275
|
+
}
|
|
276
|
+
catch { /* retain the uncertain claim */ }
|
|
277
|
+
await this.save(current);
|
|
278
|
+
}));
|
|
279
|
+
try {
|
|
280
|
+
await backend.load(source);
|
|
281
|
+
if (position > 0)
|
|
282
|
+
await backend.seek(position);
|
|
283
|
+
await backend.setVolume(player.state.volume);
|
|
284
|
+
player.state.status = "playing";
|
|
285
|
+
await this.save(player);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
player = this.get(player.id);
|
|
289
|
+
player.state.status = "error";
|
|
290
|
+
player.state.error = errorValue(error);
|
|
291
|
+
try {
|
|
292
|
+
await this.detach(player);
|
|
293
|
+
}
|
|
294
|
+
catch { /* don't transfer an uncertain claim */ }
|
|
295
|
+
await this.save(player);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
enqueueEvent(id, runtime, action) {
|
|
300
|
+
void this.serial(async () => {
|
|
301
|
+
if (this.stopping || this.runtimes.get(id)?.generation !== runtime.generation)
|
|
302
|
+
return;
|
|
303
|
+
try {
|
|
304
|
+
await action(this.get(id));
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
const current = this.get(id);
|
|
308
|
+
current.state.status = "error";
|
|
309
|
+
current.state.error = errorValue(error);
|
|
310
|
+
try {
|
|
311
|
+
await this.detach(current);
|
|
312
|
+
}
|
|
313
|
+
catch { /* preserve uncertain claim */ }
|
|
314
|
+
await this.save(current);
|
|
315
|
+
}
|
|
316
|
+
}).catch((error) => { this.emit("fault", error); });
|
|
317
|
+
}
|
|
318
|
+
async detach(player) {
|
|
319
|
+
const runtime = this.runtimes.get(player.id);
|
|
320
|
+
if (runtime) {
|
|
321
|
+
// Invalidate callbacks before awaiting close, including duplicate EOFs.
|
|
322
|
+
this.runtimes.delete(player.id);
|
|
323
|
+
await runtime.backend.close();
|
|
324
|
+
}
|
|
325
|
+
else if (player.assigned_output !== null) {
|
|
326
|
+
throw new ServerPlayerError("RELEASE_UNCERTAIN", "Previous engine release is uncertain; stop that engine and restart the worker before reassigning", 503);
|
|
327
|
+
}
|
|
328
|
+
player.assigned_output = null;
|
|
329
|
+
}
|
|
330
|
+
async stop(player, preserveCurrent = false) {
|
|
331
|
+
await this.detach(player);
|
|
332
|
+
player.state.status = preserveCurrent && player.state.current_entry_id ? "paused" : "stopped";
|
|
333
|
+
player.state.checkpoint = preserveCurrent && player.state.current_entry_id !== null;
|
|
334
|
+
if (!preserveCurrent) {
|
|
335
|
+
player.state.current_entry_id = null;
|
|
336
|
+
player.state.position = 0;
|
|
337
|
+
player.state.duration = null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async transfer(target, command) {
|
|
341
|
+
if (target.id === command.from_player_id)
|
|
342
|
+
throw new ServerPlayerError("INVALID_TRANSFER", "Transfer requires two different players", 400);
|
|
343
|
+
let source = this.get(command.from_player_id);
|
|
344
|
+
this.revision(source, command.from_revision);
|
|
345
|
+
if (source.assigned_output !== command.output_id)
|
|
346
|
+
throw new ServerPlayerError("OUTPUT_NOT_ASSIGNED", "Source player does not own this speaker");
|
|
347
|
+
let detached = false;
|
|
348
|
+
try {
|
|
349
|
+
await this.stop(source, true);
|
|
350
|
+
source = await this.save(source);
|
|
351
|
+
detached = true;
|
|
352
|
+
if (this.runtimes.has(target.id)) {
|
|
353
|
+
await this.stop(target, true);
|
|
354
|
+
target = await this.save(target);
|
|
355
|
+
}
|
|
356
|
+
target.state.desired_output = command.output_id;
|
|
357
|
+
target = await this.save(target);
|
|
358
|
+
if (command.play)
|
|
359
|
+
await this.play(target, target.state.current_entry_id ?? target.state.queue[0]?.id, command);
|
|
360
|
+
return { player: this.get(target.id), transfer: { from_player: this.get(source.id), detached, attached: command.play, error: null } };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (!detached) {
|
|
364
|
+
source.state.status = "error";
|
|
365
|
+
source.state.error = errorValue(error);
|
|
366
|
+
try {
|
|
367
|
+
await this.save(source);
|
|
368
|
+
}
|
|
369
|
+
catch { /* keep the transfer result truthful */ }
|
|
370
|
+
}
|
|
371
|
+
// The source may already be detached. Report each phase; do not reconnect
|
|
372
|
+
// it to pretend the operation was atomic or compete with an external app.
|
|
373
|
+
return { player: this.get(target.id), transfer: { from_player: this.get(source.id), detached, attached: false, error: errorValue(error) } };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async shutdown() {
|
|
377
|
+
this.stopping = true;
|
|
378
|
+
await this.serial(async () => {
|
|
379
|
+
for (const player of this.list()) {
|
|
380
|
+
try {
|
|
381
|
+
await this.stop(player, true);
|
|
382
|
+
await this.save(player);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
this.emit("fault", error);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
async requireAvailable(output, policy) {
|
|
391
|
+
const observed = await this.observe(output);
|
|
392
|
+
if (observed.availability === "occupied" && !policy.takeover)
|
|
393
|
+
throw new ServerPlayerError("SPEAKER_OCCUPIED", "Observed speaker activity requires explicit takeover");
|
|
394
|
+
if (observed.availability === "unknown" && !policy.allow_unknown)
|
|
395
|
+
throw new ServerPlayerError("AVAILABILITY_UNKNOWN", "Speaker availability is unknown; explicitly acknowledge it before connecting");
|
|
396
|
+
}
|
|
397
|
+
engine(player) {
|
|
398
|
+
const engine = this.engines[player.engine_id];
|
|
399
|
+
if (!engine)
|
|
400
|
+
throw new ServerPlayerError("ENGINE_NOT_CONFIGURED", "Player engine is not configured", 503);
|
|
401
|
+
return engine;
|
|
402
|
+
}
|
|
403
|
+
active(id) {
|
|
404
|
+
const runtime = this.runtimes.get(id);
|
|
405
|
+
if (!runtime)
|
|
406
|
+
throw new ServerPlayerError("PLAYER_INACTIVE", "Player has no active engine session; explicitly play to start one");
|
|
407
|
+
return runtime;
|
|
408
|
+
}
|
|
409
|
+
revision(player, expected) {
|
|
410
|
+
if (player.revision !== expected)
|
|
411
|
+
throw new ServerPlayerError("REVISION_CONFLICT", "Player changed; refresh its state before retrying");
|
|
412
|
+
}
|
|
413
|
+
available() { if (this.stopping)
|
|
414
|
+
throw new ServerPlayerError("WORKER_STOPPING", "Player worker is stopping", 503); }
|
|
415
|
+
async save(player) {
|
|
416
|
+
const saved = await this.store.save(player, player.revision);
|
|
417
|
+
this.players.set(player.id, saved);
|
|
418
|
+
this.publish(saved);
|
|
419
|
+
return clone(saved);
|
|
420
|
+
}
|
|
421
|
+
publish(player) { this.emit("snapshot", clone(player)); }
|
|
422
|
+
}
|
|
423
|
+
//# sourceMappingURL=manager.js.map
|