@groovenet/player 0.1.2 → 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 +29 -0
- package/SERVER_PLAYERS.md +215 -0
- package/build/daemon.d.ts.map +1 -1
- package/build/daemon.js +42 -16
- package/build/daemon.js.map +1 -1
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -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 +3 -0
- package/build/mpvBackend.d.ts.map +1 -1
- package/build/mpvBackend.js +6 -0
- 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/paths.d.ts +4 -0
- package/build/paths.d.ts.map +1 -0
- package/build/paths.js +8 -0
- package/build/paths.js.map +1 -0
- package/build/playLogger.d.ts +39 -0
- package/build/playLogger.d.ts.map +1 -0
- package/build/playLogger.js +59 -0
- package/build/playLogger.js.map +1 -0
- package/build/playThreshold.d.ts +31 -0
- package/build/playThreshold.d.ts.map +1 -0
- package/build/playThreshold.js +40 -0
- package/build/playThreshold.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,33 @@
|
|
|
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 PlayerStore {
|
|
10
|
+
list(): Promise<ServerPlayer[]>;
|
|
11
|
+
create(input: CreateServerPlayer, state: ServerPlayerState): Promise<ServerPlayer>;
|
|
12
|
+
save(player: ServerPlayer, expectedRevision: number): Promise<ServerPlayer>;
|
|
13
|
+
checkpoint(player: ServerPlayer): Promise<void>;
|
|
14
|
+
resolve(ref: PlayerTrackRef): Promise<Track | null>;
|
|
15
|
+
}
|
|
16
|
+
/** Only the elected worker writes this table; every command also uses CAS. */
|
|
17
|
+
export declare class PostgresPlayerStore implements PlayerStore {
|
|
18
|
+
readonly pool: Pool;
|
|
19
|
+
private lease?;
|
|
20
|
+
private onLost?;
|
|
21
|
+
constructor(pool: Pool);
|
|
22
|
+
acquire(onLost: () => void): Promise<void>;
|
|
23
|
+
heartbeat(): Promise<void>;
|
|
24
|
+
release(): Promise<void>;
|
|
25
|
+
private row;
|
|
26
|
+
list(): Promise<ServerPlayer[]>;
|
|
27
|
+
create(input: CreateServerPlayer, state: ServerPlayerState): Promise<ServerPlayer>;
|
|
28
|
+
save(player: ServerPlayer, expectedRevision: number): Promise<ServerPlayer>;
|
|
29
|
+
checkpoint(player: ServerPlayer): Promise<void>;
|
|
30
|
+
resolve(ref: PlayerTrackRef): Promise<Track | null>;
|
|
31
|
+
private mapError;
|
|
32
|
+
}
|
|
33
|
+
//# 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,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;CACrD;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;IAED,OAAO,CAAC,QAAQ;CAIjB"}
|
|
@@ -0,0 +1,108 @@
|
|
|
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, 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, revision=revision+1, updated_at=now() WHERE id=$1 AND revision=$5 RETURNING *`, [player.id, player.name, player.assigned_output, JSON.stringify(player.state), 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
|
+
mapError(error) {
|
|
103
|
+
if (error && typeof error === "object" && "code" in error && error.code === "23505")
|
|
104
|
+
return new ServerPlayerError("ASSIGNMENT_CONFLICT", "Player ID, engine, or speaker is already assigned");
|
|
105
|
+
return error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/server/store.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAA2F,MAAM,sBAAsB,CAAC;AAGnJ,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IACrB,IAAI;IAAoC,MAAM;IAAnE,YAAqB,IAAY,EAAE,OAAe,EAAW,MAAM,GAAG,GAAG;QAAI,KAAK,CAAC,OAAO,CAAC,CAAC;oBAAvE,IAAI;sBAAoC,MAAM;IAA0B,CAAC;CAC/F;AASD,8EAA8E;AAC9E,MAAM,OAAO,mBAAmB;IAGT,IAAI;IAFjB,KAAK,CAAc;IACnB,MAAM,CAAc;IAC5B,YAAqB,IAAU;oBAAV,IAAI;IAAS,CAAC;IAEnC,KAAK,CAAC,OAAO,CAAC,MAAkB;QAC9B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QACzC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;YACrF,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,QAAQ;gBAAE,MAAM,IAAI,iBAAiB,CAAC,wBAAwB,EAAE,kDAAkD,EAAE,GAAG,CAAC,CAAC;YAC9I,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC;YACpB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;YACrB,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC7B,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAC7B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YAAC,MAAM,KAAK,CAAC;QAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,IAAI,CAAC,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,iBAAiB,CAAC,oBAAoB,EAAE,qCAAqC,EAAE,GAAG,CAAC,CAAC;QAC/G,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACrC,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACvB,IAAI,CAAC;YACH,IAAI,KAAK,EAAE,CAAC;gBACV,IAAI,CAAC;oBAAC,MAAM,KAAK,CAAC,KAAK,CAAC,mCAAmC,CAAC,CAAC;gBAAC,CAAC;wBACvD,CAAC;oBAAC,KAAK,CAAC,OAAO,EAAE,CAAC;gBAAC,CAAC;YAC9B,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;gBAChB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;gBACpC,KAAK,EAAE,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;gBACjC,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;gBAC/B,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC;YAC1B,CAAC;YACD,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;QACxB,CAAC;IACH,CAAC;IAEO,GAAG,CAAC,GAA4B;QACtC,OAAO,kBAAkB,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,eAAe,EAAE,GAAG,CAAC,eAAe,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC;IACpL,CAAC;IAED,KAAK,CAAC,IAAI;QACR,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,0CAA0C,CAAC,CAAC;QACjF,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,KAAyB,EAAE,KAAwB;QAC9D,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,mGAAmG,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAC1M,OAAO,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAClC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC;IACjD,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,MAAoB,EAAE,gBAAwB;QACvD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,uJAAuJ,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,eAAe,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC;YAChS,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;gBAAE,MAAM,IAAI,iBAAiB,CAAC,mBAAmB,EAAE,mDAAmD,CAAC,CAAC;YAC3H,OAAO,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAClC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QAAC,CAAC;IACjD,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,MAAoB;QACnC,yEAAyE;QACzE,2EAA2E;QAC3E,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,yFAAyF,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC/K,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAmB;QAC/B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,qEAAqE,EAAE,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC;QAC3I,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1C,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAU,CAAC;IACjC,CAAC;IAEO,QAAQ,CAAC,KAAc;QAC7B,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,IAAI,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO;YAAE,OAAO,IAAI,iBAAiB,CAAC,qBAAqB,EAAE,mDAAmD,CAAC,CAAC;QAC9L,OAAO,KAAK,CAAC;IACf,CAAC;CACF"}
|
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);
|
package/build/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/build/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"
|
|
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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- a/src/player.h
|
|
2
|
+
+++ b/src/player.h
|
|
3
|
+
@@ -44,0 +45 @@
|
|
4
|
+
+ bool gn_connected;
|
|
5
|
+
@@ -72,0 +74,6 @@
|
|
6
|
+
+
|
|
7
|
+
+ /* Groovenet terminal signal v1; snapshotted on the player thread. */
|
|
8
|
+
+ char gn_instance[33];
|
|
9
|
+
+ uint32_t gn_sequence;
|
|
10
|
+
+ uint32_t gn_item_id;
|
|
11
|
+
+ const char *gn_reason;
|
|
12
|
+
--- a/src/player.c
|
|
13
|
+
+++ b/src/player.c
|
|
14
|
+
@@ -201,0 +202,3 @@
|
|
15
|
+
+ // Never report an input/output failure as natural completion.
|
|
16
|
+
+ bool gn_failed;
|
|
17
|
+
+
|
|
18
|
+
@@ -341,0 +345,14 @@
|
|
19
|
+
+
|
|
20
|
+
+// Groovenet terminal signal. All access is confined to the player thread.
|
|
21
|
+
+static char gn_instance[33];
|
|
22
|
+
+static uint32_t gn_sequence;
|
|
23
|
+
+static uint32_t gn_item_id;
|
|
24
|
+
+static const char *gn_reason = "none";
|
|
25
|
+
+
|
|
26
|
+
+static void
|
|
27
|
+
+gn_terminal(uint32_t item_id, const char *reason)
|
|
28
|
+
+{
|
|
29
|
+
+ gn_sequence++;
|
|
30
|
+
+ gn_item_id = item_id;
|
|
31
|
+
+ gn_reason = reason;
|
|
32
|
+
+}
|
|
33
|
+
@@ -1023,0 +1041,2 @@
|
|
34
|
+
+ pb_session.reading_now->gn_failed = true;
|
|
35
|
+
+ gn_terminal(pb_session.reading_now->item_id, "input_error");
|
|
36
|
+
@@ -1090,0 +1110,3 @@
|
|
37
|
+
+
|
|
38
|
+
+ gn_terminal(pb_session.playing_now->item_id,
|
|
39
|
+
+ pb_session.playing_now->gn_failed ? "failed" : "eof");
|
|
40
|
+
@@ -1499,0 +1522,9 @@
|
|
41
|
+
+ if ((!device || status == OUTPUT_STATE_FAILED || (status == OUTPUT_STATE_STOPPED && player_state == PLAY_PLAYING))
|
|
42
|
+
+ && pb_session.playing_now)
|
|
43
|
+
+ {
|
|
44
|
+
+ pb_session.playing_now->gn_failed = true;
|
|
45
|
+
+ if (pb_session.reading_now)
|
|
46
|
+
+ pb_session.reading_now->gn_failed = true;
|
|
47
|
+
+ gn_terminal(pb_session.playing_now->item_id, "output_lost");
|
|
48
|
+
+ }
|
|
49
|
+
+
|
|
50
|
+
@@ -1516,7 +1547,3 @@
|
|
51
|
+
- if (!device->resurrect)
|
|
52
|
+
- goto out;
|
|
53
|
+
-
|
|
54
|
+
- DPRINTF(E_LOG, L_PLAYER, "Attempting reconnection in %d sec to the %s device '%s'\n", PLAYER_SPEAKER_RESURRECT_TIME, device->type_name, device->name);
|
|
55
|
+
-
|
|
56
|
+
- // TODO do this internally instead of through the worker
|
|
57
|
+
- worker_execute(player_speaker_resurrect, &(device->id), sizeof(device->id), PLAYER_SPEAKER_RESURRECT_TIME);
|
|
58
|
+
+ // Groovenet never automatically reclaims a failed/taken-over receiver.
|
|
59
|
+
+ // A new explicit play/output-selection action is required.
|
|
60
|
+
+ goto out;
|
|
61
|
+
@@ -1791,0 +1819,3 @@
|
|
62
|
+
+ if (pb_session.playing_now)
|
|
63
|
+
+ gn_terminal(pb_session.playing_now->item_id, "interrupted");
|
|
64
|
+
+
|
|
65
|
+
@@ -1910,0 +1941,4 @@
|
|
66
|
+
+ memcpy(status->gn_instance, gn_instance, sizeof(status->gn_instance));
|
|
67
|
+
+ status->gn_sequence = gn_sequence;
|
|
68
|
+
+ status->gn_item_id = gn_item_id;
|
|
69
|
+
+ status->gn_reason = gn_reason;
|
|
70
|
+
@@ -2564,0 +2599 @@
|
|
71
|
+
+ spk->gn_connected = (device->session != NULL);
|
|
72
|
+
@@ -3943 +3978,7 @@
|
|
73
|
+
- int ret;
|
|
74
|
+
+ unsigned char nonce[16];
|
|
75
|
+
+ unsigned int i;
|
|
76
|
+
+ int ret;
|
|
77
|
+
+
|
|
78
|
+
+ gcry_create_nonce(nonce, sizeof(nonce));
|
|
79
|
+
+ for (i = 0; i < sizeof(nonce); i++)
|
|
80
|
+
+ snprintf(gn_instance + i * 2, 3, "%02x", nonce[i]);
|
|
81
|
+
--- a/src/httpd_jsonapi.c
|
|
82
|
+
+++ b/src/httpd_jsonapi.c
|
|
83
|
+
@@ -1631,0 +1632,3 @@
|
|
84
|
+
+ json_object_object_add(output, "busy", json_object_new_boolean(spk->busy));
|
|
85
|
+
+ json_object_object_add(output, "connected", json_object_new_boolean(spk->gn_connected));
|
|
86
|
+
+ json_object_object_add(output, "prevent_playback", json_object_new_boolean(spk->prevent_playback));
|
|
87
|
+
@@ -2198,0 +2202,9 @@
|
|
88
|
+
+
|
|
89
|
+
+ /* Additive, versioned extension: stock OwnTone clients ignore these fields. */
|
|
90
|
+
+ json_object *terminal = json_object_new_object();
|
|
91
|
+
+ json_object_object_add(terminal, "version", json_object_new_int(1));
|
|
92
|
+
+ json_object_object_add(terminal, "instance", json_object_new_string(status.gn_instance));
|
|
93
|
+
+ json_object_object_add(terminal, "sequence", json_object_new_int64(status.gn_sequence));
|
|
94
|
+
+ json_object_object_add(terminal, "item_id", json_object_new_int64(status.gn_item_id));
|
|
95
|
+
+ json_object_object_add(terminal, "reason", json_object_new_string(status.gn_reason));
|
|
96
|
+
+ json_object_object_add(reply, "groovenet_terminal", terminal);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groovenet/player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Headless playback core and mpv backend for Groovenet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "build/index.js",
|
|
10
10
|
"types": "build/index.d.ts",
|
|
11
|
+
"bin": {
|
|
12
|
+
"groovenet-mpris": "build/mprisBin.js",
|
|
13
|
+
"groovenet-player-server": "build/server/bin.js"
|
|
14
|
+
},
|
|
11
15
|
"exports": {
|
|
12
16
|
".": {
|
|
13
17
|
"import": "./build/index.js",
|
|
@@ -20,6 +24,10 @@
|
|
|
20
24
|
"./testing": {
|
|
21
25
|
"import": "./build/testing.js",
|
|
22
26
|
"types": "./build/testing.d.ts"
|
|
27
|
+
},
|
|
28
|
+
"./server": {
|
|
29
|
+
"types": "./build/server/manager.d.ts",
|
|
30
|
+
"import": "./build/server/manager.js"
|
|
23
31
|
}
|
|
24
32
|
},
|
|
25
33
|
"scripts": {
|
|
@@ -29,16 +37,25 @@
|
|
|
29
37
|
"test:coverage": "vitest run --coverage"
|
|
30
38
|
},
|
|
31
39
|
"files": [
|
|
32
|
-
"build/"
|
|
40
|
+
"build/",
|
|
41
|
+
"MPRIS.md",
|
|
42
|
+
"OWNTONE.md",
|
|
43
|
+
"SERVER_PLAYERS.md",
|
|
44
|
+
"owntone/",
|
|
45
|
+
"scripts/mpris-smoke.mjs"
|
|
33
46
|
],
|
|
34
47
|
"publishConfig": {
|
|
35
48
|
"access": "public"
|
|
36
49
|
},
|
|
37
50
|
"dependencies": {
|
|
38
|
-
"@groovenet/client": "^3.
|
|
51
|
+
"@groovenet/client": "^3.5.0",
|
|
52
|
+
"@groovenet/contracts": "1.2.0",
|
|
53
|
+
"pg": "8.23.1",
|
|
54
|
+
"zod": "4.6.7"
|
|
39
55
|
},
|
|
40
56
|
"devDependencies": {
|
|
41
|
-
"@types/node": "^26.6.
|
|
57
|
+
"@types/node": "^26.6.5",
|
|
58
|
+
"@types/pg": "8.23.1",
|
|
42
59
|
"@vitest/coverage-v8": "^5.0.3",
|
|
43
60
|
"typescript": "^7.0",
|
|
44
61
|
"vitest": "^5.0.0"
|
|
@@ -48,5 +65,8 @@
|
|
|
48
65
|
"type": "git",
|
|
49
66
|
"url": "git+https://github.com/Public-Vinyl-Radio/groovenet.git",
|
|
50
67
|
"directory": "packages/groovenet-player"
|
|
68
|
+
},
|
|
69
|
+
"optionalDependencies": {
|
|
70
|
+
"dbus-next": "0.10.2"
|
|
51
71
|
}
|
|
52
72
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Run on Linux: dbus-run-session -- node packages/groovenet-player/scripts/mpris-smoke.mjs
|
|
2
|
+
// Requires built workspaces, optional dbus-next dependency, dbus and playerctl.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { PlayerDaemon, PlayerCore, TrackResolver } from "../build/index.js";
|
|
10
|
+
import { TestPlaybackBackend } from "../build/testing.js";
|
|
11
|
+
import { startMpris } from "../build/mpris.js";
|
|
12
|
+
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), "groovenet-mpris-"));
|
|
14
|
+
const backend = new TestPlaybackBackend();
|
|
15
|
+
const player = new PlayerCore(backend, new TrackResolver({}, { apiBase: "http://unused/api" }));
|
|
16
|
+
const paths = { socketPath: join(dir, "player.sock"), pidPath: join(dir, "player.pid") };
|
|
17
|
+
const daemon = new PlayerDaemon({ player, paths });
|
|
18
|
+
const ctl = async (...args) => (await promisify(execFile)("playerctl", ["--player=groovenet", ...args])).stdout.trim();
|
|
19
|
+
let close;
|
|
20
|
+
try {
|
|
21
|
+
player.add(["First", "Second"].map((title) => ({ title, track_id: title, artist: "Artist", album: "Album", friend_id: 1, local_audio_url: `${title}.mp3` })));
|
|
22
|
+
await daemon.start();
|
|
23
|
+
close = await startMpris(paths.socketPath);
|
|
24
|
+
assert.ok(close, "MPRIS must be enabled in this session");
|
|
25
|
+
assert.equal(await ctl("status"), "Stopped");
|
|
26
|
+
await ctl("play");
|
|
27
|
+
assert.equal(await ctl("status"), "Playing");
|
|
28
|
+
assert.equal(await ctl("metadata", "xesam:title"), "First");
|
|
29
|
+
assert.equal(await ctl("metadata", "xesam:artist"), "Artist");
|
|
30
|
+
assert.equal(await ctl("metadata", "xesam:album"), "Album");
|
|
31
|
+
await ctl("play-pause");
|
|
32
|
+
assert.equal(await ctl("status"), "Paused");
|
|
33
|
+
await ctl("position", "12.5");
|
|
34
|
+
assert.equal(Number(await ctl("position")), 12.5);
|
|
35
|
+
await ctl("position", "2+");
|
|
36
|
+
assert.equal(Number(await ctl("position")), 14.5);
|
|
37
|
+
await ctl("play-pause");
|
|
38
|
+
assert.equal(await ctl("status"), "Playing");
|
|
39
|
+
await ctl("next");
|
|
40
|
+
assert.equal(await ctl("metadata", "xesam:title"), "Second");
|
|
41
|
+
await ctl("previous");
|
|
42
|
+
assert.equal(await ctl("metadata", "xesam:title"), "First");
|
|
43
|
+
await ctl("stop");
|
|
44
|
+
assert.equal(await ctl("status"), "Stopped");
|
|
45
|
+
assert.equal(player.snapshot.queue.length, 2);
|
|
46
|
+
await close();
|
|
47
|
+
await player.play();
|
|
48
|
+
assert.equal(player.snapshot.status, "playing");
|
|
49
|
+
console.log("MPRIS playerctl smoke test passed.");
|
|
50
|
+
} finally {
|
|
51
|
+
await close?.();
|
|
52
|
+
await daemon.stop();
|
|
53
|
+
await rm(dir, { recursive: true, force: true });
|
|
54
|
+
}
|