@groovenet/player 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/MPRIS.md +97 -0
  2. package/OWNTONE.md +145 -0
  3. package/README.md +17 -0
  4. package/SERVER_PLAYERS.md +348 -0
  5. package/build/daemon.d.ts.map +1 -1
  6. package/build/daemon.js +3 -0
  7. package/build/daemon.js.map +1 -1
  8. package/build/index.d.ts +1 -0
  9. package/build/index.d.ts.map +1 -1
  10. package/build/index.js +1 -0
  11. package/build/index.js.map +1 -1
  12. package/build/mpris.d.ts +11 -0
  13. package/build/mpris.d.ts.map +1 -0
  14. package/build/mpris.js +145 -0
  15. package/build/mpris.js.map +1 -0
  16. package/build/mprisBin.d.ts +3 -0
  17. package/build/mprisBin.d.ts.map +1 -0
  18. package/build/mprisBin.js +26 -0
  19. package/build/mprisBin.js.map +1 -0
  20. package/build/mprisMapping.d.ts +12 -0
  21. package/build/mprisMapping.d.ts.map +1 -0
  22. package/build/mprisMapping.js +35 -0
  23. package/build/mprisMapping.js.map +1 -0
  24. package/build/mpvBackend.d.ts.map +1 -1
  25. package/build/mpvBackend.js +2 -1
  26. package/build/mpvBackend.js.map +1 -1
  27. package/build/ownToneBackend.d.ts +62 -0
  28. package/build/ownToneBackend.d.ts.map +1 -0
  29. package/build/ownToneBackend.js +349 -0
  30. package/build/ownToneBackend.js.map +1 -0
  31. package/build/player.d.ts +1 -0
  32. package/build/player.d.ts.map +1 -1
  33. package/build/player.js +11 -0
  34. package/build/player.js.map +1 -1
  35. package/build/protocol.d.ts.map +1 -1
  36. package/build/protocol.js +1 -0
  37. package/build/protocol.js.map +1 -1
  38. package/build/resolver.d.ts +3 -1
  39. package/build/resolver.d.ts.map +1 -1
  40. package/build/resolver.js +30 -1
  41. package/build/resolver.js.map +1 -1
  42. package/build/server/bin.d.ts +3 -0
  43. package/build/server/bin.d.ts.map +1 -0
  44. package/build/server/bin.js +7 -0
  45. package/build/server/bin.js.map +1 -0
  46. package/build/server/http.d.ts +5 -0
  47. package/build/server/http.d.ts.map +1 -0
  48. package/build/server/http.js +129 -0
  49. package/build/server/http.js.map +1 -0
  50. package/build/server/manager.d.ts +61 -0
  51. package/build/server/manager.d.ts.map +1 -0
  52. package/build/server/manager.js +555 -0
  53. package/build/server/manager.js.map +1 -0
  54. package/build/server/observer.d.ts +10 -0
  55. package/build/server/observer.d.ts.map +1 -0
  56. package/build/server/observer.js +47 -0
  57. package/build/server/observer.js.map +1 -0
  58. package/build/server/runtime.d.ts +18 -0
  59. package/build/server/runtime.d.ts.map +1 -0
  60. package/build/server/runtime.js +101 -0
  61. package/build/server/runtime.js.map +1 -0
  62. package/build/server/store.d.ts +47 -0
  63. package/build/server/store.d.ts.map +1 -0
  64. package/build/server/store.js +120 -0
  65. package/build/server/store.js.map +1 -0
  66. package/build/types.d.ts +3 -1
  67. package/build/types.d.ts.map +1 -1
  68. package/build/types.js.map +1 -1
  69. package/owntone/Dockerfile +27 -0
  70. package/owntone/smoke.mjs +135 -0
  71. package/owntone/terminal-v1.patch +96 -0
  72. package/package.json +24 -4
  73. package/scripts/mpris-smoke.mjs +54 -0
@@ -0,0 +1,47 @@
1
+ import { unknownSpeaker } from "./manager.js";
2
+ /** Exact attribute matches must identify one dedicated engine, never a shared host. */
3
+ export function homeAssistantObserver(options) {
4
+ const transport = options.fetch ?? globalThis.fetch;
5
+ const cache = new Map();
6
+ return async (outputId, context) => {
7
+ const mappedEntity = (key) => options.entities && Object.hasOwn(options.entities, key) ? options.entities[key] : undefined;
8
+ let entity = mappedEntity(outputId);
9
+ if (!entity && Object.keys(options.entities ?? {}).length > 0) {
10
+ try {
11
+ const name = context?.outputName ?? await context?.resolveOutputName?.();
12
+ entity = name ? mappedEntity(name) : undefined;
13
+ }
14
+ catch { /* Discovery failure leaves the output unmapped. */ }
15
+ }
16
+ if (!options.url || !options.token || !entity)
17
+ return { ...unknownSpeaker(), entity_id: entity ?? null };
18
+ const cached = cache.get(entity);
19
+ if (!context?.fresh && cached && cached.until > Date.now())
20
+ return structuredClone(cached.value);
21
+ let value = { availability: "unknown", observed_at: null, source: "home_assistant", entity_id: entity, sender_identity: "unknown", sender_engine_id: null };
22
+ try {
23
+ const response = await transport(new URL(`api/states/${encodeURIComponent(entity)}`, options.url.endsWith("/") ? options.url : `${options.url}/`), { headers: { Authorization: `Bearer ${options.token}` }, signal: AbortSignal.timeout(2000), redirect: "error" });
24
+ if (response.ok) {
25
+ const body = await response.json();
26
+ if (body && typeof body === "object" && "state" in body && typeof body.state === "string") {
27
+ const state = body.state.toLowerCase();
28
+ const attributes = "attributes" in body && body.attributes && typeof body.attributes === "object" ? body.attributes : {};
29
+ const senders = Object.entries(options.senders ?? {});
30
+ const matches = senders.filter(([, expected]) => Object.keys(expected).length > 0 && Object.entries(expected).every(([key, expectedValue]) => Object.hasOwn(attributes, key) && attributes[key] === expectedValue));
31
+ // Idle/unavailable metadata can outlive a session. Never identify it.
32
+ if (["playing", "paused", "buffering"].includes(state)) {
33
+ if (matches.length === 1)
34
+ value = { ...value, sender_identity: "groovenet", sender_engine_id: matches[0][0] };
35
+ else if (matches.length === 0 && senders.some(([, expected]) => Object.keys(expected).some(key => typeof attributes[key] === "string" && attributes[key] !== "")))
36
+ value.sender_identity = "foreign";
37
+ }
38
+ value = { ...value, availability: ["unknown", "unavailable"].includes(state) ? "unknown" : ["idle", "off", "standby"].includes(state) ? "idle" : "occupied", observed_at: new Date().toISOString() };
39
+ }
40
+ }
41
+ }
42
+ catch { /* Missing/stale observation is unknown, never free. */ }
43
+ cache.set(entity, { until: Date.now() + 5000, value });
44
+ return structuredClone(value);
45
+ };
46
+ }
47
+ //# sourceMappingURL=observer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observer.js","sourceRoot":"","sources":["../../src/server/observer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import { ServerPlayerManager } from "./manager.js";
2
+ import type { ServerPlay } from "./store.js";
3
+ import { PostgresPlayerStore } from "./store.js";
4
+ import type { Server } from "node:http";
5
+ import type { EventEmitter } from "node:events";
6
+ export declare function postServerPlay(appUrl: string, play: ServerPlay): Promise<void>;
7
+ export declare function runPlayerWorker(env?: NodeJS.ProcessEnv, dependencies?: {
8
+ store?: PostgresPlayerStore;
9
+ manager?: ServerPlayerManager;
10
+ server?: Server;
11
+ signals?: EventEmitter;
12
+ }): Promise<{
13
+ manager: ServerPlayerManager;
14
+ server: Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>;
15
+ store: PostgresPlayerStore;
16
+ shutdown: () => Promise<void>;
17
+ }>;
18
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/server/runtime.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,mBAAmB,EAAqB,MAAM,cAAc,CAAC;AACtE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAIjD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CASpF;AAED,wBAAsB,eAAe,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,YAAY,GAAE;IAAE,KAAK,CAAC,EAAE,mBAAmB,CAAC;IAAC,OAAO,CAAC,EAAE,mBAAmB,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,YAAY,CAAA;CAAO;;;;oBAwB/K,OAAO,CAAC,IAAI,CAAC;GAwCnC"}
@@ -0,0 +1,101 @@
1
+ import { Pool } from "pg";
2
+ import { z } from "zod";
3
+ import { OwnToneBackend } from "../ownToneBackend.js";
4
+ import { playerIdSchema } from "@groovenet/contracts";
5
+ import { ServerPlayerManager } from "./manager.js";
6
+ import { PostgresPlayerStore } from "./store.js";
7
+ import { homeAssistantObserver } from "./observer.js";
8
+ import { playerHttpServer } from "./http.js";
9
+ export async function postServerPlay(appUrl, play) {
10
+ const response = await fetch(new URL("/api/spins", appUrl), {
11
+ method: "POST", headers: { "Content-Type": "application/json" },
12
+ body: JSON.stringify({ friend_id: play.friendId, release_id: play.releaseId, medium: "digital", played_at: play.startedAt.toISOString(), context_type: "player", track_refs: [{ track_id: play.trackId, friend_id: play.friendId }] }),
13
+ signal: AbortSignal.timeout(5000),
14
+ });
15
+ if (response.ok)
16
+ return;
17
+ if (response.status === 409 && (await response.json()).error === "Digital play already logged")
18
+ return;
19
+ throw new Error(`Server play API returned ${response.status}`);
20
+ }
21
+ export async function runPlayerWorker(env = process.env, dependencies = {}) {
22
+ const connectionSchema = z.object({ apiUrl: z.url().refine((value) => { const url = new URL(value); return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash; }), libraryRoot: z.string().startsWith("/"), password: z.string().optional() }).strict();
23
+ const configured = z.record(playerIdSchema, connectionSchema).parse(JSON.parse(env.PLAYER_ENGINES_JSON ?? "{}"));
24
+ if (Object.keys(configured).length === 0)
25
+ throw new Error("PLAYER_ENGINES_JSON requires at least one dedicated engine");
26
+ const urls = Object.values(configured).map((engine) => new URL(engine.apiUrl).href.replace(/\/$/, ""));
27
+ if (new Set(urls).size !== urls.length)
28
+ throw new Error("Each player engine must have a different API URL");
29
+ const key = env.PLAYER_API_KEY ?? "";
30
+ if (key.length < 32)
31
+ throw new Error("PLAYER_API_KEY must contain at least 32 characters");
32
+ if (!env.DATABASE_URL)
33
+ throw new Error("DATABASE_URL is required");
34
+ const ownerFriendId = z.coerce.number().int().positive().parse(env.PLAYER_LIBRARY_FRIEND_ID);
35
+ const appUrl = z.url().parse(env.PLAYER_APP_URL ?? "http://app:3000");
36
+ const engines = Object.fromEntries(Object.entries(configured).map(([id, options]) => [id, { libraryRoot: options.libraryRoot, create: (outputId) => new OwnToneBackend({ apiUrl: options.apiUrl, password: options.password, outputId }) }]));
37
+ const entities = z.record(z.string(), z.string().regex(/^[a-z_]+\.[a-z0-9_]+$/)).parse(JSON.parse(env.PLAYER_HA_ENTITIES_JSON ?? "{}"));
38
+ const senders = z.record(playerIdSchema, z.record(z.string().min(1), z.string().min(1)).refine(value => Object.keys(value).length > 0)).parse(JSON.parse(env.PLAYER_HA_SENDERS_JSON ?? "{}"));
39
+ for (const engineId of Object.keys(senders))
40
+ if (!configured[engineId])
41
+ throw new Error("HA sender must reference a configured engine");
42
+ const store = dependencies.store ?? new PostgresPlayerStore(new Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 5000, query_timeout: 5000, statement_timeout: 5000 }));
43
+ const logPlay = (play) => postServerPlay(appUrl, play);
44
+ const manager = dependencies.manager ?? new ServerPlayerManager(store, engines, homeAssistantObserver({ url: env.PLAYER_HA_URL, token: env.PLAYER_HA_TOKEN, entities, senders }), { ownerFriendId, logPlay });
45
+ const server = dependencies.server ?? playerHttpServer(manager, key);
46
+ const signals = dependencies.signals ?? process;
47
+ let stopping = false;
48
+ let shutdownPromise;
49
+ let heartbeat;
50
+ let playRetry;
51
+ function shutdown() {
52
+ if (shutdownPromise)
53
+ return shutdownPromise;
54
+ stopping = true;
55
+ clearInterval(heartbeat);
56
+ clearInterval(playRetry);
57
+ signals.off("SIGINT", stop);
58
+ signals.off("SIGTERM", stop);
59
+ server.close();
60
+ server.closeAllConnections();
61
+ shutdownPromise = (async () => {
62
+ try {
63
+ await manager.shutdown();
64
+ }
65
+ finally {
66
+ manager.off("fault", failStop);
67
+ await store.release();
68
+ }
69
+ })();
70
+ return shutdownPromise;
71
+ }
72
+ const stop = () => { void shutdown().catch(() => { process.exitCode = 1; }); };
73
+ const failStop = () => { process.exitCode = 1; stop(); };
74
+ try {
75
+ await store.acquire(failStop);
76
+ manager.on("fault", failStop);
77
+ await manager.initialize();
78
+ if (stopping)
79
+ throw new Error("Player worker lost its lease during initialization");
80
+ let checking = false;
81
+ heartbeat = setInterval(() => {
82
+ if (checking)
83
+ return;
84
+ checking = true;
85
+ void store.heartbeat().catch(failStop).finally(() => { checking = false; });
86
+ }, 1000);
87
+ void manager.retryPlays();
88
+ playRetry = setInterval(() => { void manager.retryPlays(); }, 30_000);
89
+ signals.once("SIGINT", stop);
90
+ signals.once("SIGTERM", stop);
91
+ const port = z.coerce.number().int().min(1).max(65535).parse(env.PLAYER_WORKER_PORT ?? "13700");
92
+ server.once("error", failStop);
93
+ server.listen(port, "0.0.0.0", () => console.log(`Player worker listening on ${port}`));
94
+ return { manager, server, store, shutdown };
95
+ }
96
+ catch (error) {
97
+ await shutdown().catch(() => { });
98
+ throw error;
99
+ }
100
+ }
101
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../../src/server/runtime.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ import { Pool } from "pg";
2
+ import { type CreateServerPlayer, type PlayerTrackRef, type ServerPlayer, type ServerPlayerState } from "@groovenet/contracts";
3
+ import type { Track } from "@groovenet/client";
4
+ export declare class ServerPlayerError extends Error {
5
+ readonly code: string;
6
+ readonly status: number;
7
+ constructor(code: string, message: string, status?: number);
8
+ }
9
+ export interface ServerPlay {
10
+ entryId: string;
11
+ playerId: string;
12
+ trackId: string;
13
+ friendId: number;
14
+ releaseId: string;
15
+ startedAt: Date;
16
+ }
17
+ export interface PlayerStore {
18
+ list(): Promise<ServerPlayer[]>;
19
+ create(input: CreateServerPlayer, state: ServerPlayerState): Promise<ServerPlayer>;
20
+ save(player: ServerPlayer, expectedRevision: number): Promise<ServerPlayer>;
21
+ checkpoint(player: ServerPlayer): Promise<void>;
22
+ resolve(ref: PlayerTrackRef): Promise<Track | null>;
23
+ queuePlay(play: ServerPlay): Promise<void>;
24
+ pendingPlays(): Promise<ServerPlay[]>;
25
+ markPlayLogged(entryId: string): Promise<void>;
26
+ }
27
+ /** Only the elected worker writes this table; every command also uses CAS. */
28
+ export declare class PostgresPlayerStore implements PlayerStore {
29
+ readonly pool: Pool;
30
+ private lease?;
31
+ private onLost?;
32
+ constructor(pool: Pool);
33
+ acquire(onLost: () => void): Promise<void>;
34
+ heartbeat(): Promise<void>;
35
+ release(): Promise<void>;
36
+ private row;
37
+ list(): Promise<ServerPlayer[]>;
38
+ create(input: CreateServerPlayer, state: ServerPlayerState): Promise<ServerPlayer>;
39
+ save(player: ServerPlayer, expectedRevision: number): Promise<ServerPlayer>;
40
+ checkpoint(player: ServerPlayer): Promise<void>;
41
+ resolve(ref: PlayerTrackRef): Promise<Track | null>;
42
+ queuePlay(play: ServerPlay): Promise<void>;
43
+ pendingPlays(): Promise<ServerPlay[]>;
44
+ markPlayLogged(entryId: string): Promise<void>;
45
+ private mapError;
46
+ }
47
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/server/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,IAAI,CAAC;AAC3C,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,cAAc,EAAE,KAAK,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACnJ,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,qBAAa,iBAAkB,SAAQ,KAAK;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM;IAAmB,QAAQ,CAAC,MAAM;IAAnE,YAAqB,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAW,MAAM,SAAM,EAAqB;CAC/F;AACD,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,IAAI,CAAC;CACjB;AACD,MAAM,WAAW,WAAW;IAC1B,IAAI,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAChC,MAAM,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACnF,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAC5E,UAAU,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IACpD,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IACtC,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD;AAED,8EAA8E;AAC9E,qBAAa,mBAAoB,YAAW,WAAW;IAGzC,QAAQ,CAAC,IAAI,EAAE,IAAI;IAF/B,OAAO,CAAC,KAAK,CAAC,CAAa;IAC3B,OAAO,CAAC,MAAM,CAAC,CAAa;IAC5B,YAAqB,IAAI,EAAE,IAAI,EAAI;IAE7B,OAAO,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/C;IAEK,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAG/B;IAEK,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAiB7B;IAED,OAAO,CAAC,GAAG;IAIL,IAAI,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAGpC;IAEK,MAAM,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAKvF;IAEK,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAMhF;IAEK,UAAU,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAIpD;IAEK,OAAO,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,CAIxD;IAEK,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAI/C;IAEK,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAI1C;IAEK,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEnD;IAED,OAAO,CAAC,QAAQ;CAIjB"}
@@ -0,0 +1,120 @@
1
+ import { serverPlayerSchema } from "@groovenet/contracts";
2
+ export class ServerPlayerError extends Error {
3
+ code;
4
+ status;
5
+ constructor(code, message, status = 409) {
6
+ super(message);
7
+ this.code = code;
8
+ this.status = status;
9
+ }
10
+ }
11
+ /** Only the elected worker writes this table; every command also uses CAS. */
12
+ export class PostgresPlayerStore {
13
+ pool;
14
+ lease;
15
+ onLost;
16
+ constructor(pool) {
17
+ this.pool = pool;
18
+ }
19
+ async acquire(onLost) {
20
+ const client = await this.pool.connect();
21
+ try {
22
+ const result = await client.query("SELECT pg_try_advisory_lock(693, 1) AS acquired");
23
+ if (!result.rows[0]?.acquired)
24
+ throw new ServerPlayerError("WORKER_ALREADY_RUNNING", "Another player worker owns the engine lifecycles", 503);
25
+ this.lease = client;
26
+ this.onLost = onLost;
27
+ this.pool.on("error", onLost);
28
+ client.once("error", onLost);
29
+ client.once("end", onLost);
30
+ }
31
+ catch (error) {
32
+ client.release();
33
+ throw error;
34
+ }
35
+ }
36
+ async heartbeat() {
37
+ if (!this.lease)
38
+ throw new ServerPlayerError("WORKER_UNAVAILABLE", "Player worker has no database lease", 503);
39
+ await this.lease.query("SELECT 1");
40
+ }
41
+ async release() {
42
+ const lease = this.lease;
43
+ this.lease = undefined;
44
+ try {
45
+ if (lease) {
46
+ try {
47
+ await lease.query("SELECT pg_advisory_unlock(693, 1)");
48
+ }
49
+ finally {
50
+ lease.release();
51
+ }
52
+ }
53
+ }
54
+ finally {
55
+ if (this.onLost) {
56
+ this.pool.off("error", this.onLost);
57
+ lease?.off("error", this.onLost);
58
+ lease?.off("end", this.onLost);
59
+ this.onLost = undefined;
60
+ }
61
+ await this.pool.end();
62
+ }
63
+ }
64
+ row(row) {
65
+ return serverPlayerSchema.parse({ id: row.id, name: row.name, engine_id: row.engine_id, availability_policy: row.availability_policy, revision: Number(row.revision), assigned_output: row.assigned_output, state: row.state });
66
+ }
67
+ async list() {
68
+ const result = await this.pool.query("SELECT * FROM server_players ORDER BY id");
69
+ return result.rows.map((row) => this.row(row));
70
+ }
71
+ async create(input, state) {
72
+ try {
73
+ const result = await this.pool.query("INSERT INTO server_players(id, name, engine_id, state) VALUES ($1, $2, $3, $4::jsonb) RETURNING *", [input.id, input.name, input.engine_id, JSON.stringify(state)]);
74
+ return this.row(result.rows[0]);
75
+ }
76
+ catch (error) {
77
+ throw this.mapError(error);
78
+ }
79
+ }
80
+ async save(player, expectedRevision) {
81
+ try {
82
+ const result = await this.pool.query(`UPDATE server_players SET name=$2, assigned_output=$3, state=$4::jsonb, availability_policy=$5, revision=revision+1, updated_at=now() WHERE id=$1 AND revision=$6 RETURNING *`, [player.id, player.name, player.assigned_output, JSON.stringify(player.state), player.availability_policy, expectedRevision]);
83
+ if (!result.rows[0])
84
+ throw new ServerPlayerError("REVISION_CONFLICT", "Player changed; refresh its state before retrying");
85
+ return this.row(result.rows[0]);
86
+ }
87
+ catch (error) {
88
+ throw this.mapError(error);
89
+ }
90
+ }
91
+ async checkpoint(player) {
92
+ // Do not bump the edit revision for elapsed-time updates. CAS prevents a
93
+ // delayed checkpoint from overwriting a newer command or queue transition.
94
+ await this.pool.query("UPDATE server_players SET state=$2::jsonb, updated_at=now() WHERE id=$1 AND revision=$3", [player.id, JSON.stringify(player.state), player.revision]);
95
+ }
96
+ async resolve(ref) {
97
+ const result = await this.pool.query("SELECT t.* FROM tracks t WHERE track_id=$1 AND friend_id=$2 LIMIT 2", [ref.track_id, ref.friend_id]);
98
+ if (result.rows.length !== 1)
99
+ return null;
100
+ return result.rows[0];
101
+ }
102
+ async queuePlay(play) {
103
+ await this.pool.query(`INSERT INTO server_player_plays (entry_id, player_id, track_id, friend_id, release_id, started_at)
104
+ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (entry_id) DO NOTHING`, [play.entryId, play.playerId, play.trackId, play.friendId, play.releaseId, play.startedAt]);
105
+ }
106
+ async pendingPlays() {
107
+ const result = await this.pool.query(`SELECT entry_id, player_id, track_id, friend_id, release_id, started_at
108
+ FROM server_player_plays WHERE logged_at IS NULL ORDER BY started_at`);
109
+ return result.rows.map(row => ({ entryId: row.entry_id, playerId: row.player_id, trackId: row.track_id, friendId: row.friend_id, releaseId: row.release_id, startedAt: new Date(row.started_at) }));
110
+ }
111
+ async markPlayLogged(entryId) {
112
+ await this.pool.query("UPDATE server_player_plays SET logged_at=now() WHERE entry_id=$1 AND logged_at IS NULL", [entryId]);
113
+ }
114
+ mapError(error) {
115
+ if (error && typeof error === "object" && "code" in error && error.code === "23505")
116
+ return new ServerPlayerError("ASSIGNMENT_CONFLICT", "Player ID, engine, or speaker is already assigned");
117
+ return error;
118
+ }
119
+ }
120
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/server/store.ts"],"names":[],"mappings":"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"}
package/build/types.d.ts CHANGED
@@ -36,9 +36,11 @@ export interface PlaybackBackend {
36
36
  setVolume(volume: number): Promise<void>;
37
37
  listDevices(): Promise<AudioDevice[]>;
38
38
  setOutputDevice(deviceId: string): Promise<void>;
39
+ /** Transport evidence for server resume policy; selected alone is not connected. */
40
+ outputConnection?(): Promise<"connected" | "disconnected" | "unknown">;
39
41
  close(): Promise<void>;
40
42
  }
41
- export type PlayerErrorCode = "NO_AUDIO" | "MPV_NOT_FOUND" | "MPV_EXITED" | "MPV_IPC" | "STREAM_FAILED" | "DEVICE_UNAVAILABLE" | "INVALID_ARGUMENT";
43
+ export type PlayerErrorCode = "NO_AUDIO" | "MPV_NOT_FOUND" | "MPV_EXITED" | "MPV_IPC" | "STREAM_FAILED" | "DEVICE_UNAVAILABLE" | "INVALID_ARGUMENT" | "ENGINE_UNAVAILABLE" | "ENGINE_INCOMPATIBLE" | "ENGINE_AUTH" | "ENGINE_TIMEOUT" | "ENGINE_API" | "DEVICE_BUSY" | "DEVICE_AUTH" | "PLAYBACK_INTERRUPTED";
42
44
  export declare class PlayerError extends Error {
43
45
  readonly code: PlayerErrorCode;
44
46
  constructor(code: PlayerErrorCode, message: string, options?: ErrorOptions);
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,KAAK,CAAC;CACd;AAED,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;AAE9F,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,EAAE,UAAU,GAAG,IAAI,CAAC;IAC3B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC9D,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,KAAK,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,CAAC,CAAC,SAAS,MAAM,qBAAqB,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAC9F,GAAG,CAAC,CAAC,SAAS,MAAM,qBAAqB,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAC/F,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,KAAK,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IACtC,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,MAAM,eAAe,GACvB,UAAU,GACV,eAAe,GACf,YAAY,GACZ,SAAS,GACT,eAAe,GACf,oBAAoB,GACpB,kBAAkB,CAAC;AAEvB,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,IAAI,EAAE,eAAe;IADhC,YACW,IAAI,EAAE,eAAe,EAC9B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,YAAY,EAIvB;CACF"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,KAAK,CAAC;CACd;AAED,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;AAE9F,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,EAAE,UAAU,GAAG,IAAI,CAAC;IAC3B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC9D,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,KAAK,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,CAAC,CAAC,SAAS,MAAM,qBAAqB,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAC9F,GAAG,CAAC,CAAC,SAAS,MAAM,qBAAqB,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAC/F,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,KAAK,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IACtC,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,oFAAoF;IACpF,gBAAgB,CAAC,IAAI,OAAO,CAAC,WAAW,GAAG,cAAc,GAAG,SAAS,CAAC,CAAC;IACvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,MAAM,eAAe,GACvB,UAAU,GACV,eAAe,GACf,YAAY,GACZ,SAAS,GACT,eAAe,GACf,oBAAoB,GACpB,kBAAkB,GAClB,oBAAoB,GACpB,qBAAqB,GACrB,aAAa,GACb,gBAAgB,GAChB,YAAY,GACZ,aAAa,GACb,aAAa,GACb,sBAAsB,CAAC;AAE3B,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,IAAI,EAAE,eAAe;IADhC,YACW,IAAI,EAAE,eAAe,EAC9B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,YAAY,EAIvB;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAyDA,MAAM,OAAO,WAAY,SAAQ,KAAK;IAEzB,IAAI;IADf,YACW,IAAqB,EAC9B,OAAe,EACf,OAAsB;QAEtB,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;oBAJf,IAAI;QAKb,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAmEA,MAAM,OAAO,WAAY,SAAQ,KAAK;IAEzB,IAAI;IADf,YACW,IAAqB,EAC9B,OAAe,EACf,OAAsB;QAEtB,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;oBAJf,IAAI;QAKb,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF"}
@@ -0,0 +1,27 @@
1
+ # OwnTone is GPL-2.0; this image is built from the pinned upstream source plus
2
+ # terminal-v1.patch. Keep both available when distributing a derived image.
3
+ FROM alpine:3.22 AS build
4
+ RUN apk add --no-cache alsa-lib-dev autoconf automake avahi-dev bison confuse-dev \
5
+ curl curl-dev flex g++ gawk gcc gettext-dev gnutls-dev gperf json-c-dev \
6
+ libevent-dev libgcrypt-dev libplist-dev libsodium-dev libtool libunistring-dev \
7
+ libwebsockets-dev libxml2-dev make protobuf-c-dev sqlite-dev ffmpeg-dev patch
8
+ WORKDIR /source
9
+ RUN curl -fsSL https://codeload.github.com/owntone/owntone-server/tar.gz/d6fb3edf5831de38134ebd92fcf09a730ddd37aa -o source.tar.gz \
10
+ && echo 'b2c391e25295ea9d598dba70e23dcbe31635ae38f36ac570aa61f7c803d98f98 source.tar.gz' | sha256sum -c - \
11
+ && tar -xzf source.tar.gz --strip-components=1 && rm source.tar.gz
12
+ COPY terminal-v1.patch /source/terminal-v1.patch
13
+ RUN patch -p1 < terminal-v1.patch \
14
+ && autoreconf -fi -I /usr/share/gettext/m4 \
15
+ && ./configure --prefix=/usr --sysconfdir=/etc --localstatedir=/var \
16
+ --disable-install_systemd --disable-install_user --disable-webinterface \
17
+ && make -j2 && make DESTDIR=/output install \
18
+ && (cd src/libairptp && ./configure --prefix=/usr --enable-daemon && make -j2 && make DESTDIR=/output install) \
19
+ && rm -rf /output/var && make clean
20
+
21
+ FROM alpine:3.22
22
+ RUN apk add --no-cache alsa-lib avahi confuse curl dbus ffmpeg gnutls json-c \
23
+ libevent libgcrypt libplist libsodium libunistring libuuid libwebsockets libxml2 protobuf-c sqlite-libs
24
+ COPY --from=build /output/ /
25
+ # Corresponding source and patch are included for reproducibility/distribution.
26
+ COPY --from=build /source/ /usr/src/owntone/
27
+ ENTRYPOINT ["/usr/sbin/owntone", "-f", "-c", "/etc/owntone.conf"]
@@ -0,0 +1,135 @@
1
+ // Network-isolated engine + real adapter test. Docker is required; no speakers,
2
+ // production volumes, port publication or host packages are used.
3
+ // Build the player and engine image first, then: node owntone/smoke.mjs
4
+ import assert from "node:assert/strict";
5
+ import { execFile, execFileSync } from "node:child_process";
6
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { promisify } from "node:util";
10
+ import { OwnToneBackend } from "../build/ownToneBackend.js";
11
+
12
+ const exec = promisify(execFile);
13
+ const name = `groovenet-692-smoke-${process.pid}`;
14
+ const dir = mkdtempSync(join(tmpdir(), "groovenet-692-"));
15
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ const docker = (...args) => execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
17
+ const config = `general {
18
+ uid = "nobody"
19
+ db_path = "/tmp/engine/library.db"
20
+ cache_dir = "/tmp/engine"
21
+ logfile = "/dev/stderr"
22
+ loglevel = log
23
+ trusted_networks = { "localhost" }
24
+ bind_address = "127.0.0.1"
25
+ speaker_autoselect = no
26
+ }
27
+ library {
28
+ name = "Groovenet 692 isolated"
29
+ port = 13689
30
+ directories = { "/tmp/media" }
31
+ clear_queue_on_stop_disable = true
32
+ }
33
+ audio { type = "dummy" }
34
+ mpd { port = 0 }
35
+ `;
36
+ writeFileSync(join(dir, "engine.conf"), config);
37
+ const transport = async (input, init = {}) => {
38
+ const args = ["exec", name, "curl", "-sS", "--max-time", "5", "-X", init.method ?? "GET", "-w", "\n%{http_code}"];
39
+ for (const [key, value] of Object.entries(init.headers ?? {})) args.push("-H", `${key}: ${value}`);
40
+ if (init.body) args.push("-d", String(init.body));
41
+ args.push(String(input));
42
+ const { stdout } = await exec("docker", args, { signal: init.signal });
43
+ const split = stdout.lastIndexOf("\n");
44
+ const status = Number(stdout.slice(split + 1));
45
+ return new Response(status === 204 ? null : stdout.slice(0, split), { status });
46
+ };
47
+ const read = async (path) => (await transport(`http://127.0.0.1:13689/api/${path}`)).json();
48
+ const put = (path) => transport(`http://127.0.0.1:13689/api/${path}`, { method: "PUT" });
49
+ const wait = async (predicate, message) => {
50
+ const deadline = Date.now() + 12000;
51
+ while (Date.now() < deadline) {
52
+ if (await predicate()) return;
53
+ await delay(100);
54
+ }
55
+ throw new Error(`Timed out: ${message}`);
56
+ };
57
+ let backend;
58
+ try {
59
+ docker("run", "-d", "--name", name, "--network", "none", "--mount", `type=bind,src=${join(dir, "engine.conf")},dst=/tmp/engine.conf,readonly`, "--entrypoint", "/bin/sh", "groovenet-owntone:692-test", "-ec", `
60
+ mkdir -p /tmp/engine /tmp/media /run/dbus
61
+ chown nobody /tmp/engine /tmp/media
62
+ ffmpeg -y -v error -f lavfi -i 'sine=frequency=440:duration=3' -af volume=0.01 /tmp/media/short.wav
63
+ ffmpeg -y -v error -f lavfi -i 'sine=frequency=440:duration=15' -af volume=0.01 /tmp/media/long.wav
64
+ rm -f /run/dbus/pid /run/dbus/dbus.pid /run/avahi-daemon/pid
65
+ dbus-uuidgen --ensure
66
+ dbus-daemon --system --fork
67
+ avahi-daemon --daemonize --no-drop-root
68
+ exec /usr/sbin/owntone -f -c /tmp/engine.conf
69
+ `);
70
+ await wait(async () => {
71
+ try { return (await read("search?type=tracks&expression=media_kind%20is%20music")).tracks.items.length === 2; }
72
+ catch { return false; }
73
+ }, "fixture index");
74
+ const devices = await read("outputs");
75
+ assert.equal(devices.outputs.length, 1);
76
+ const outputId = devices.outputs[0].id;
77
+ backend = new OwnToneBackend({ apiUrl: "http://127.0.0.1:13689/api", outputId, fetch: transport, pollIntervalMs: 100 });
78
+ let ended = 0;
79
+ const errors = [];
80
+ const positions = [];
81
+ backend.on("ended", () => ended++).on("error", (error) => errors.push(error)).on("position", (position) => positions.push(position));
82
+ await backend.load({ url: "file:///tmp/media/short.wav" });
83
+ await wait(() => ended === 1 || errors.length > 0, "natural completion");
84
+ assert.deepEqual(errors, []);
85
+ assert.equal(ended, 1);
86
+ assert.equal((await read("player")).groovenet_terminal.reason, "eof");
87
+ console.log("PASS: natural EOF emitted exactly once through the real adapter");
88
+ await backend.load({ url: "file:///tmp/media/long.wav" });
89
+ await wait(() => positions.some((p) => p > 0.1), "position progress");
90
+ await backend.pause(true);
91
+ await backend.seek(5);
92
+ await backend.pause(false);
93
+ await backend.setVolume(5);
94
+ await put("player/stop");
95
+ await wait(() => errors.length > 0, "explicit external stop");
96
+ assert.equal(ended, 1);
97
+ assert.equal(errors.at(-1).code, "PLAYBACK_INTERRUPTED");
98
+ assert.equal((await read("player")).groovenet_terminal.reason, "interrupted");
99
+ console.log("PASS: external stop does not advance; pause/resume/absolute seek/volume accepted");
100
+ errors.length = 0;
101
+ await backend.load({ url: "file:///tmp/media/long.wav" });
102
+ await put("queue/clear");
103
+ await wait(() => errors.length > 0, "external queue replacement");
104
+ assert.equal(ended, 1);
105
+ assert.equal(errors.at(-1).code, "PLAYBACK_INTERRUPTED");
106
+ console.log("PASS: external queue replacement does not advance");
107
+ await backend.close();
108
+ backend = undefined;
109
+ // Restart the same container: its DB survives, but its signal epoch must not.
110
+ const beforeRestart = (await read("player")).groovenet_terminal.instance;
111
+ backend = new OwnToneBackend({ apiUrl: "http://127.0.0.1:13689/api", outputId, fetch: transport, pollIntervalMs: 100 });
112
+ let restartEnded = 0;
113
+ const restartErrors = [];
114
+ backend.on("ended", () => restartEnded++).on("error", (error) => restartErrors.push(error));
115
+ await backend.load({ url: "file:///tmp/media/long.wav" });
116
+ docker("restart", name);
117
+ await wait(async () => {
118
+ try { return (await read("player")).groovenet_terminal.instance !== beforeRestart; }
119
+ catch { return false; }
120
+ }, "new engine epoch");
121
+ await wait(() => restartErrors.some((error) => error.code === "PLAYBACK_INTERRUPTED"), "restart reconciliation");
122
+ assert.equal(restartEnded, 0);
123
+ assert.equal((await read("player")).state, "stop");
124
+ console.log("PASS: engine restart changes epoch and never replays or advances");
125
+ await backend.close();
126
+ backend = undefined;
127
+ console.log("PASS: isolated smoke complete (dummy output only; no audible hardware validation)");
128
+ } catch (error) {
129
+ try { execFileSync("docker", ["logs", "--tail", "50", name], { stdio: "inherit" }); } catch { /* not started */ }
130
+ throw error;
131
+ } finally {
132
+ await backend?.close().catch(() => {});
133
+ try { docker("rm", "-f", name); } catch { /* already gone */ }
134
+ rmSync(dir, { recursive: true, force: true });
135
+ }