roon-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/BrowseSessionManager.d.ts +34 -0
- package/dist/BrowseSessionManager.js +97 -0
- package/dist/BrowseSessionManager.js.map +1 -0
- package/dist/BrowseSessionManager.test.d.ts +1 -0
- package/dist/BrowseSessionManager.test.js +83 -0
- package/dist/BrowseSessionManager.test.js.map +1 -0
- package/dist/GenreService.d.ts +22 -0
- package/dist/GenreService.js +177 -0
- package/dist/GenreService.js.map +1 -0
- package/dist/GenreService.test.d.ts +1 -0
- package/dist/GenreService.test.js +147 -0
- package/dist/GenreService.test.js.map +1 -0
- package/dist/PlaybackService.d.ts +43 -0
- package/dist/PlaybackService.js +291 -0
- package/dist/PlaybackService.js.map +1 -0
- package/dist/PlaybackService.test.d.ts +1 -0
- package/dist/PlaybackService.test.js +281 -0
- package/dist/PlaybackService.test.js.map +1 -0
- package/dist/RoonClient.d.ts +37 -0
- package/dist/RoonClient.js +105 -0
- package/dist/RoonClient.js.map +1 -0
- package/dist/RoonMcpServer.d.ts +21 -0
- package/dist/RoonMcpServer.js +218 -0
- package/dist/RoonMcpServer.js.map +1 -0
- package/dist/SearchNavigator.d.ts +38 -0
- package/dist/SearchNavigator.js +104 -0
- package/dist/SearchNavigator.js.map +1 -0
- package/dist/SearchService.d.ts +29 -0
- package/dist/SearchService.js +236 -0
- package/dist/SearchService.js.map +1 -0
- package/dist/SearchService.test.d.ts +1 -0
- package/dist/SearchService.test.js +197 -0
- package/dist/SearchService.test.js.map +1 -0
- package/dist/TrackExpansionService.d.ts +75 -0
- package/dist/TrackExpansionService.js +337 -0
- package/dist/TrackExpansionService.js.map +1 -0
- package/dist/TrackExpansionService.test.d.ts +1 -0
- package/dist/TrackExpansionService.test.js +382 -0
- package/dist/TrackExpansionService.test.js.map +1 -0
- package/dist/ZoneService.d.ts +52 -0
- package/dist/ZoneService.js +139 -0
- package/dist/ZoneService.js.map +1 -0
- package/dist/ZoneService.test.d.ts +1 -0
- package/dist/ZoneService.test.js +111 -0
- package/dist/ZoneService.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/locator.d.ts +58 -0
- package/dist/locator.js +88 -0
- package/dist/locator.js.map +1 -0
- package/dist/locator.test.d.ts +1 -0
- package/dist/locator.test.js +37 -0
- package/dist/locator.test.js.map +1 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +56 -0
- package/dist/logger.js.map +1 -0
- package/dist/logger.test.d.ts +1 -0
- package/dist/logger.test.js +53 -0
- package/dist/logger.test.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { ZoneService } from "./ZoneService.js";
|
|
4
|
+
import { RoonMcpError } from "./types.js";
|
|
5
|
+
function zone(partial) {
|
|
6
|
+
return {
|
|
7
|
+
display_name: partial.zone_id,
|
|
8
|
+
state: "stopped",
|
|
9
|
+
outputs: [{ output_id: `o:${partial.zone_id}`, zone_id: partial.zone_id, display_name: partial.zone_id }],
|
|
10
|
+
...partial,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Builds a ZoneService backed by a stub RoonClient returning the given zones. */
|
|
14
|
+
function serviceWith(zones, defaultZone) {
|
|
15
|
+
const stub = {
|
|
16
|
+
waitForCore: async () => undefined,
|
|
17
|
+
getTransport: () => ({
|
|
18
|
+
get_zones: (cb) => cb(false, { zones }),
|
|
19
|
+
subscribe_zones: () => { },
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
return new ZoneService(stub, undefined, defaultZone);
|
|
23
|
+
}
|
|
24
|
+
test("listZones maps Roon zones to the public shape", async () => {
|
|
25
|
+
const svc = serviceWith([zone({ zone_id: "z1", display_name: "Office", state: "playing" })]);
|
|
26
|
+
const [z] = await svc.listZones();
|
|
27
|
+
assert.deepEqual(z, {
|
|
28
|
+
zoneId: "z1",
|
|
29
|
+
displayName: "Office",
|
|
30
|
+
state: "playing",
|
|
31
|
+
outputIds: ["o:z1"],
|
|
32
|
+
isDefaultCandidate: true,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
test("resolveZone returns the only zone without a preferred name", async () => {
|
|
36
|
+
const svc = serviceWith([zone({ zone_id: "z1", display_name: "Kitchen" })]);
|
|
37
|
+
const res = await svc.resolveZone();
|
|
38
|
+
assert.equal(res.zoneId, "z1");
|
|
39
|
+
});
|
|
40
|
+
test("resolveZone prefers an exact name match over a substring", async () => {
|
|
41
|
+
const svc = serviceWith([
|
|
42
|
+
zone({ zone_id: "z1", display_name: "Office" }),
|
|
43
|
+
zone({ zone_id: "z2", display_name: "Office Speakers" }),
|
|
44
|
+
]);
|
|
45
|
+
const res = await svc.resolveZone("Office");
|
|
46
|
+
assert.equal(res.zoneId, "z1");
|
|
47
|
+
});
|
|
48
|
+
test("resolveZone returns ambiguity when a fuzzy name matches several zones", async () => {
|
|
49
|
+
const svc = serviceWith([
|
|
50
|
+
zone({ zone_id: "z1", display_name: "Living Room" }),
|
|
51
|
+
zone({ zone_id: "z2", display_name: "Living Room Sub" }),
|
|
52
|
+
]);
|
|
53
|
+
const res = await svc.resolveZone("living");
|
|
54
|
+
assert.equal("ambiguous" in res && res.ambiguous, true);
|
|
55
|
+
});
|
|
56
|
+
test("resolveZone falls back to an Office zone with no preferred name", async () => {
|
|
57
|
+
const svc = serviceWith([
|
|
58
|
+
zone({ zone_id: "z1", display_name: "Kitchen" }),
|
|
59
|
+
zone({ zone_id: "z2", display_name: "Home Office" }),
|
|
60
|
+
]);
|
|
61
|
+
const res = await svc.resolveZone();
|
|
62
|
+
assert.equal(res.zoneId, "z2");
|
|
63
|
+
});
|
|
64
|
+
test("resolveZone prefers the currently playing zone when no name match", async () => {
|
|
65
|
+
const svc = serviceWith([
|
|
66
|
+
zone({ zone_id: "z1", display_name: "Kitchen", state: "stopped" }),
|
|
67
|
+
zone({ zone_id: "z2", display_name: "Bedroom", state: "playing" }),
|
|
68
|
+
]);
|
|
69
|
+
const res = await svc.resolveZone();
|
|
70
|
+
assert.equal(res.zoneId, "z2");
|
|
71
|
+
});
|
|
72
|
+
test("resolveZone throws ZONE_NOT_FOUND when there are no zones", async () => {
|
|
73
|
+
const svc = serviceWith([]);
|
|
74
|
+
await assert.rejects(svc.resolveZone(), (e) => e instanceof RoonMcpError && e.code === "ZONE_NOT_FOUND");
|
|
75
|
+
});
|
|
76
|
+
test("resolveTarget passes an explicit id through unchanged (output id preserved)", async () => {
|
|
77
|
+
const svc = serviceWith([
|
|
78
|
+
zone({ zone_id: "z1", display_name: "Office" }),
|
|
79
|
+
zone({ zone_id: "z2", display_name: "Kitchen" }),
|
|
80
|
+
]);
|
|
81
|
+
const out = await svc.resolveTarget("o:z2");
|
|
82
|
+
assert.equal(out.targetId, "o:z2"); // caller's output id, not the zone id
|
|
83
|
+
assert.equal(out.zone.zoneId, "z2");
|
|
84
|
+
});
|
|
85
|
+
test("resolveTarget rejects an unknown explicit id with ZONE_NOT_FOUND", async () => {
|
|
86
|
+
const svc = serviceWith([zone({ zone_id: "z1", display_name: "Office" })]);
|
|
87
|
+
await assert.rejects(svc.resolveTarget("nope"), (e) => e instanceof RoonMcpError && e.code === "ZONE_NOT_FOUND");
|
|
88
|
+
});
|
|
89
|
+
test("resolveTarget uses the configured default zone (by name) when none is passed", async () => {
|
|
90
|
+
const svc = serviceWith([zone({ zone_id: "z1", display_name: "Kitchen" }), zone({ zone_id: "z2", display_name: "Office" })], "Office");
|
|
91
|
+
const out = await svc.resolveTarget();
|
|
92
|
+
assert.equal(out.targetId, "z2");
|
|
93
|
+
});
|
|
94
|
+
test("resolveTarget honours a default given as a zone id", async () => {
|
|
95
|
+
const svc = serviceWith([zone({ zone_id: "z1", display_name: "Kitchen" }), zone({ zone_id: "z2", display_name: "Office" })], "z1");
|
|
96
|
+
const out = await svc.resolveTarget();
|
|
97
|
+
assert.equal(out.targetId, "z1");
|
|
98
|
+
});
|
|
99
|
+
test("resolveTarget falls back to the single zone when no default is set", async () => {
|
|
100
|
+
const svc = serviceWith([zone({ zone_id: "z1", display_name: "Kitchen" })]);
|
|
101
|
+
const out = await svc.resolveTarget();
|
|
102
|
+
assert.equal(out.targetId, "z1");
|
|
103
|
+
});
|
|
104
|
+
test("resolveTarget throws ZONE_AMBIGUOUS when no default and several zones", async () => {
|
|
105
|
+
const svc = serviceWith([
|
|
106
|
+
zone({ zone_id: "z1", display_name: "Kitchen", state: "stopped" }),
|
|
107
|
+
zone({ zone_id: "z2", display_name: "Bedroom", state: "stopped" }),
|
|
108
|
+
]);
|
|
109
|
+
await assert.rejects(svc.resolveTarget(), (e) => e instanceof RoonMcpError && e.code === "ZONE_AMBIGUOUS");
|
|
110
|
+
});
|
|
111
|
+
//# sourceMappingURL=ZoneService.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ZoneService.test.js","sourceRoot":"","sources":["../src/ZoneService.test.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { BrowseSessionManager } from "./BrowseSessionManager.js";
|
|
3
|
+
import { GenreService } from "./GenreService.js";
|
|
4
|
+
import { PlaybackService } from "./PlaybackService.js";
|
|
5
|
+
import { RoonClient } from "./RoonClient.js";
|
|
6
|
+
import { RoonMcpServer } from "./RoonMcpServer.js";
|
|
7
|
+
import { SearchService } from "./SearchService.js";
|
|
8
|
+
import { TrackExpansionService } from "./TrackExpansionService.js";
|
|
9
|
+
import { ZoneService } from "./ZoneService.js";
|
|
10
|
+
import { createStderrLogger } from "./logger.js";
|
|
11
|
+
// node-roon-api logs discovery chatter via console.log. On a stdio MCP server
|
|
12
|
+
// stdout is reserved for JSON-RPC, so route all console output to stderr.
|
|
13
|
+
for (const method of ["log", "info", "debug", "warn"]) {
|
|
14
|
+
console[method] = (...args) => {
|
|
15
|
+
process.stderr.write(`${args.map(String).join(" ")}\n`);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
async function main() {
|
|
19
|
+
// One structured logger shared across services; emits [roon-call] lines to
|
|
20
|
+
// stderr so stdout stays reserved for the MCP JSON-RPC stream.
|
|
21
|
+
const logger = createStderrLogger();
|
|
22
|
+
const roon = new RoonClient();
|
|
23
|
+
// Optional configured default zone (a zone/output id or display-name) used
|
|
24
|
+
// when a playback call omits its zoneId.
|
|
25
|
+
const defaultZone = process.env.ROON_DEFAULT_ZONE?.trim() || undefined;
|
|
26
|
+
const zones = new ZoneService(roon, logger, defaultZone);
|
|
27
|
+
const browse = new BrowseSessionManager(roon, logger);
|
|
28
|
+
const genres = new GenreService(browse);
|
|
29
|
+
const tracks = new TrackExpansionService(browse);
|
|
30
|
+
const search = new SearchService(browse, genres, tracks);
|
|
31
|
+
const playback = new PlaybackService(browse, zones, roon, tracks, logger);
|
|
32
|
+
const server = new RoonMcpServer(roon, zones, search, tracks, playback);
|
|
33
|
+
const shutdown = () => {
|
|
34
|
+
void server.stop().finally(() => process.exit(0));
|
|
35
|
+
};
|
|
36
|
+
process.on("SIGINT", shutdown);
|
|
37
|
+
process.on("SIGTERM", shutdown);
|
|
38
|
+
await server.start();
|
|
39
|
+
}
|
|
40
|
+
main().catch((err) => {
|
|
41
|
+
process.stderr.write(`[roon-mcp] fatal: ${err instanceof Error ? err.stack : err}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|
|
44
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,8EAA8E;AAC9E,0EAA0E;AAC1E,KAAK,MAAM,MAAM,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAU,EAAE,CAAC;IAC/D,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,IAAe,EAAE,EAAE;QACvC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC1D,CAAC,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,2EAA2E;IAC3E,+DAA+D;IAC/D,MAAM,MAAM,GAAG,kBAAkB,EAAE,CAAC;IACpC,MAAM,IAAI,GAAG,IAAI,UAAU,EAAE,CAAC;IAC9B,2EAA2E;IAC3E,yCAAyC;IACzC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;IACvE,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC;IACzD,MAAM,MAAM,GAAG,IAAI,oBAAoB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACtD,MAAM,MAAM,GAAG,IAAI,YAAY,CAAC,MAAM,CAAC,CAAC;IACxC,MAAM,MAAM,GAAG,IAAI,qBAAqB,CAAC,MAAM,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACzD,MAAM,QAAQ,GAAG,IAAI,eAAe,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1E,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;IAExE,MAAM,QAAQ,GAAG,GAAG,EAAE;QACpB,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACpD,CAAC,CAAC;IACF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAEhC,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;AACvB,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qBAAqB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACtF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { BrowseHierarchy } from "node-roon-api-browse";
|
|
2
|
+
/**
|
|
3
|
+
* A path back to a flat-search result:
|
|
4
|
+
* q search query that produced the result
|
|
5
|
+
* g index into the (selectable) top-level search groups
|
|
6
|
+
* i index into that group's (selectable) child items
|
|
7
|
+
* t optional index into the ordered tracks expanded from item (g,i)
|
|
8
|
+
*/
|
|
9
|
+
export interface SearchLocator {
|
|
10
|
+
q: string;
|
|
11
|
+
g: number;
|
|
12
|
+
i: number;
|
|
13
|
+
t?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A path back to a genre node in the `genres` hierarchy:
|
|
17
|
+
* ge ordered genre node titles from the root, e.g.
|
|
18
|
+
* ["Electronic", "Trance", "Psytrance"]
|
|
19
|
+
* a optional index into the genre's "Albums" container (which album)
|
|
20
|
+
* t optional index into that album's track list (which track)
|
|
21
|
+
*
|
|
22
|
+
* A genre has no track list of its own, so its tracks are addressed two levels
|
|
23
|
+
* down: album `a`, then track `t` within it. Both are set together for an
|
|
24
|
+
* expanded genre track; a bare `{ ge }` is the genre node itself.
|
|
25
|
+
*/
|
|
26
|
+
export interface GenreLocator {
|
|
27
|
+
ge: string[];
|
|
28
|
+
a?: number;
|
|
29
|
+
t?: number;
|
|
30
|
+
}
|
|
31
|
+
export type Locator = SearchLocator | GenreLocator;
|
|
32
|
+
/** True for a genre locator (re-navigated via the `genres` hierarchy). */
|
|
33
|
+
export declare function isGenreLocator(loc: Locator): loc is GenreLocator;
|
|
34
|
+
/** Which browse hierarchy a locator is re-navigated in. */
|
|
35
|
+
export declare function hierarchyForLocator(loc: Locator): BrowseHierarchy;
|
|
36
|
+
/**
|
|
37
|
+
* Encode a locator as an opaque token suitable for an MCP itemKey. Accepts
|
|
38
|
+
* either shape so callers re-encoding a possibly-genre locator (e.g. adding a
|
|
39
|
+
* track index) don't have to branch; `encodeGenreLocator` is a convenience for
|
|
40
|
+
* building genre locators from a path.
|
|
41
|
+
*/
|
|
42
|
+
export declare function encodeLocator(loc: Locator): string;
|
|
43
|
+
/**
|
|
44
|
+
* Encode a genre locator. With no coords it points at the genre node itself;
|
|
45
|
+
* with `{ a, t }` it points at track `t` of album `a` inside the genre.
|
|
46
|
+
*/
|
|
47
|
+
export declare function encodeGenreLocator(path: string[], coords?: {
|
|
48
|
+
a?: number;
|
|
49
|
+
t?: number;
|
|
50
|
+
}): string;
|
|
51
|
+
/** Decode a locator token, or return null if it isn't one (e.g. a raw key). */
|
|
52
|
+
export declare function decodeLocator(token: string): Locator | null;
|
|
53
|
+
/**
|
|
54
|
+
* Derive a track locator by extending a search-item locator with a track index.
|
|
55
|
+
* Genre tracks are addressed by (album, track) — build those with
|
|
56
|
+
* `encodeGenreLocator` and coords instead.
|
|
57
|
+
*/
|
|
58
|
+
export declare function withTrackIndex(loc: SearchLocator, t: number): SearchLocator;
|
package/dist/locator.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Roon browse item keys are *level-scoped*: the Core invalidates them as soon
|
|
2
|
+
// as you pop out of the list they belong to. `search_music` has to drill into
|
|
3
|
+
// several result groups (Artists, Albums, …) and pop back between them, so by
|
|
4
|
+
// the time it returns, every raw key it collected is already dead — unusable by
|
|
5
|
+
// a later `play_now` / `get_tracks_for` / `enqueue_and_play` call.
|
|
6
|
+
//
|
|
7
|
+
// To make the keys durable across MCP calls we hand back a *locator* instead: a
|
|
8
|
+
// self-describing token that records how to re-reach the item. Playback/
|
|
9
|
+
// expansion decode it and re-navigate from scratch in one uninterrupted browse
|
|
10
|
+
// session, where the live keys are valid at the moment they're used. This trades
|
|
11
|
+
// a re-navigation per action for correctness, and removes any dependence on
|
|
12
|
+
// fragile server-side session state.
|
|
13
|
+
//
|
|
14
|
+
// Two locator shapes exist:
|
|
15
|
+
// - a *search* locator re-reached via the flat `search` hierarchy (a query
|
|
16
|
+
// plus the indices to drill);
|
|
17
|
+
// - a *genre* locator re-reached via the dedicated `genres` hierarchy (a path
|
|
18
|
+
// of genre node titles to drill from the root). Genres don't appear in the
|
|
19
|
+
// flat search, so they need their own navigation path (see GenreService).
|
|
20
|
+
const PREFIX = "rl1:";
|
|
21
|
+
/** True for a genre locator (re-navigated via the `genres` hierarchy). */
|
|
22
|
+
export function isGenreLocator(loc) {
|
|
23
|
+
return Array.isArray(loc.ge);
|
|
24
|
+
}
|
|
25
|
+
/** Which browse hierarchy a locator is re-navigated in. */
|
|
26
|
+
export function hierarchyForLocator(loc) {
|
|
27
|
+
return isGenreLocator(loc) ? "genres" : "search";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Encode a locator as an opaque token suitable for an MCP itemKey. Accepts
|
|
31
|
+
* either shape so callers re-encoding a possibly-genre locator (e.g. adding a
|
|
32
|
+
* track index) don't have to branch; `encodeGenreLocator` is a convenience for
|
|
33
|
+
* building genre locators from a path.
|
|
34
|
+
*/
|
|
35
|
+
export function encodeLocator(loc) {
|
|
36
|
+
const json = JSON.stringify(loc);
|
|
37
|
+
return PREFIX + Buffer.from(json, "utf8").toString("base64url");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Encode a genre locator. With no coords it points at the genre node itself;
|
|
41
|
+
* with `{ a, t }` it points at track `t` of album `a` inside the genre.
|
|
42
|
+
*/
|
|
43
|
+
export function encodeGenreLocator(path, coords) {
|
|
44
|
+
const loc = { ge: path };
|
|
45
|
+
if (coords?.a !== undefined)
|
|
46
|
+
loc.a = coords.a;
|
|
47
|
+
if (coords?.t !== undefined)
|
|
48
|
+
loc.t = coords.t;
|
|
49
|
+
return encodeLocator(loc);
|
|
50
|
+
}
|
|
51
|
+
/** Decode a locator token, or return null if it isn't one (e.g. a raw key). */
|
|
52
|
+
export function decodeLocator(token) {
|
|
53
|
+
if (!token.startsWith(PREFIX))
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const json = Buffer.from(token.slice(PREFIX.length), "base64url").toString("utf8");
|
|
57
|
+
const obj = JSON.parse(json);
|
|
58
|
+
// Genre locator: a path of node titles.
|
|
59
|
+
if (Array.isArray(obj.ge) && obj.ge.every((s) => typeof s === "string")) {
|
|
60
|
+
const loc = { ge: obj.ge };
|
|
61
|
+
if (typeof obj.a === "number")
|
|
62
|
+
loc.a = obj.a;
|
|
63
|
+
if (typeof obj.t === "number")
|
|
64
|
+
loc.t = obj.t;
|
|
65
|
+
return loc;
|
|
66
|
+
}
|
|
67
|
+
// Search locator: query + group/item indices.
|
|
68
|
+
if (typeof obj.q === "string" && typeof obj.g === "number" && typeof obj.i === "number") {
|
|
69
|
+
const loc = { q: obj.q, g: obj.g, i: obj.i };
|
|
70
|
+
if (typeof obj.t === "number")
|
|
71
|
+
loc.t = obj.t;
|
|
72
|
+
return loc;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Derive a track locator by extending a search-item locator with a track index.
|
|
82
|
+
* Genre tracks are addressed by (album, track) — build those with
|
|
83
|
+
* `encodeGenreLocator` and coords instead.
|
|
84
|
+
*/
|
|
85
|
+
export function withTrackIndex(loc, t) {
|
|
86
|
+
return { q: loc.q, g: loc.g, i: loc.i, t };
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=locator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locator.js","sourceRoot":"","sources":["../src/locator.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { decodeLocator, encodeGenreLocator, encodeLocator, hierarchyForLocator, isGenreLocator, withTrackIndex, } from "./locator.js";
|
|
4
|
+
test("a search locator round-trips through encode/decode", () => {
|
|
5
|
+
const token = encodeLocator({ q: "Tycho", g: 1, i: 2 });
|
|
6
|
+
const loc = decodeLocator(token);
|
|
7
|
+
assert.ok(loc && !isGenreLocator(loc));
|
|
8
|
+
assert.deepEqual(loc, { q: "Tycho", g: 1, i: 2 });
|
|
9
|
+
assert.equal(hierarchyForLocator(loc), "search");
|
|
10
|
+
});
|
|
11
|
+
test("a genre locator round-trips and reports the genres hierarchy", () => {
|
|
12
|
+
const token = encodeGenreLocator(["Electronic", "Trance", "Psytrance"]);
|
|
13
|
+
const loc = decodeLocator(token);
|
|
14
|
+
assert.ok(loc && isGenreLocator(loc));
|
|
15
|
+
assert.deepEqual(loc.ge, ["Electronic", "Trance", "Psytrance"]);
|
|
16
|
+
assert.equal(loc.a, undefined);
|
|
17
|
+
assert.equal(loc.t, undefined);
|
|
18
|
+
assert.equal(hierarchyForLocator(loc), "genres");
|
|
19
|
+
});
|
|
20
|
+
test("a genre track locator carries the (album, track) coordinates", () => {
|
|
21
|
+
const token = encodeGenreLocator(["Electronic", "Trance", "Psytrance"], { a: 2, t: 5 });
|
|
22
|
+
const loc = decodeLocator(token);
|
|
23
|
+
assert.ok(loc && isGenreLocator(loc));
|
|
24
|
+
assert.equal(loc.a, 2);
|
|
25
|
+
assert.equal(loc.t, 5);
|
|
26
|
+
});
|
|
27
|
+
test("withTrackIndex extends a search locator with a track index", () => {
|
|
28
|
+
const search = decodeLocator(encodeLocator({ q: "x", g: 0, i: 0 }));
|
|
29
|
+
assert.ok(!isGenreLocator(search));
|
|
30
|
+
const searchT = withTrackIndex(search, 3);
|
|
31
|
+
assert.equal(searchT.t, 3);
|
|
32
|
+
});
|
|
33
|
+
test("a non-locator token decodes to null", () => {
|
|
34
|
+
assert.equal(decodeLocator("341:0"), null);
|
|
35
|
+
assert.equal(decodeLocator("rl1:not-base64-json"), null);
|
|
36
|
+
});
|
|
37
|
+
//# sourceMappingURL=locator.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locator.test.js","sourceRoot":"","sources":["../src/locator.test.ts"],"names":[],"mappings":"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"}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RoonCallLogger {
|
|
2
|
+
/**
|
|
3
|
+
* Time and log a single Roon API call. Emits one structured line when `fn`
|
|
4
|
+
* settles (level `info` on success, `error` on rejection) and then
|
|
5
|
+
* returns/rethrows `fn`'s result unchanged — so callers' error codes are
|
|
6
|
+
* preserved for downstream retry/skip logic.
|
|
7
|
+
*
|
|
8
|
+
* @param op Short operation name, e.g. "browse" or "get_zones".
|
|
9
|
+
* @param params Request parameters, logged as-is (Roon browse params hold
|
|
10
|
+
* no secrets). Pass `{}` when there are none.
|
|
11
|
+
* @param fn The async call to time.
|
|
12
|
+
* @param summarize Optional: derive a compact result summary to log instead
|
|
13
|
+
* of the full (often large) response body.
|
|
14
|
+
*/
|
|
15
|
+
call<T>(op: string, params: unknown, fn: () => Promise<T>, summarize?: (result: T) => unknown): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
/** A no-op logger: runs the call without emitting anything. Default in tests. */
|
|
18
|
+
export declare const silentLogger: RoonCallLogger;
|
|
19
|
+
/**
|
|
20
|
+
* Logger that writes one `[roon-call] {json}` line per call to stderr (or any
|
|
21
|
+
* injected sink, for tests). Undefined fields are dropped to keep lines compact.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createStderrLogger(sink?: (line: string) => void): RoonCallLogger;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Structured, per-call logging for Roon API calls (Milestone 5 hardening).
|
|
2
|
+
//
|
|
3
|
+
// stdout is reserved for the MCP JSON-RPC stream, so every diagnostic line goes
|
|
4
|
+
// to stderr. Each Roon API call (browse / load / get_zones / change_settings)
|
|
5
|
+
// is wrapped in `call(...)`, which emits one compact JSON line on completion —
|
|
6
|
+
// success or failure — including the elapsed time. Retries therefore show up
|
|
7
|
+
// naturally as repeated lines for the same op.
|
|
8
|
+
/** A no-op logger: runs the call without emitting anything. Default in tests. */
|
|
9
|
+
export const silentLogger = {
|
|
10
|
+
call: (_op, _params, fn) => fn(),
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Logger that writes one `[roon-call] {json}` line per call to stderr (or any
|
|
14
|
+
* injected sink, for tests). Undefined fields are dropped to keep lines compact.
|
|
15
|
+
*/
|
|
16
|
+
export function createStderrLogger(sink = (line) => void process.stderr.write(line)) {
|
|
17
|
+
return {
|
|
18
|
+
async call(op, params, fn, summarize) {
|
|
19
|
+
const started = Date.now();
|
|
20
|
+
try {
|
|
21
|
+
const result = await fn();
|
|
22
|
+
sink(format({
|
|
23
|
+
lvl: "info",
|
|
24
|
+
op,
|
|
25
|
+
ms: Date.now() - started,
|
|
26
|
+
params,
|
|
27
|
+
result: summarize ? summarize(result) : undefined,
|
|
28
|
+
}));
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const e = err;
|
|
33
|
+
sink(format({
|
|
34
|
+
lvl: "error",
|
|
35
|
+
op,
|
|
36
|
+
ms: Date.now() - started,
|
|
37
|
+
params,
|
|
38
|
+
error: {
|
|
39
|
+
code: typeof e?.code === "string" ? e.code : undefined,
|
|
40
|
+
message: typeof e?.message === "string" ? e.message : String(err),
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function format(record) {
|
|
49
|
+
const clean = { t: new Date().toISOString() };
|
|
50
|
+
for (const [key, value] of Object.entries(record)) {
|
|
51
|
+
if (value !== undefined)
|
|
52
|
+
clean[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return `[roon-call] ${JSON.stringify(clean)}\n`;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,EAAE;AACF,gFAAgF;AAChF,8EAA8E;AAC9E,+EAA+E;AAC/E,6EAA6E;AAC7E,+CAA+C;AAwB/C,iFAAiF;AACjF,MAAM,CAAC,MAAM,YAAY,GAAmB;IAC1C,IAAI,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE;CACjC,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAChC,OAA+B,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;IAExE,OAAO;QACL,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS;YAClC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAC3B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,EAAE,EAAE,CAAC;gBAC1B,IAAI,CACF,MAAM,CAAC;oBACL,GAAG,EAAE,MAAM;oBACX,EAAE;oBACF,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO;oBACxB,MAAM;oBACN,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS;iBAClD,CAAC,CACH,CAAC;gBACF,OAAO,MAAM,CAAC;YAChB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,CAAC,GAAG,GAA4C,CAAC;gBACvD,IAAI,CACF,MAAM,CAAC;oBACL,GAAG,EAAE,OAAO;oBACZ,EAAE;oBACF,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO;oBACxB,MAAM;oBACN,KAAK,EAAE;wBACL,IAAI,EAAE,OAAO,CAAC,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;wBACtD,OAAO,EAAE,OAAO,CAAC,EAAE,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;qBAClE;iBACF,CAAC,CACH,CAAC;gBACF,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,MAAM,CAAC,MAA+B;IAC7C,MAAM,KAAK,GAA4B,EAAE,CAAC,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC;IACvE,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAClD,IAAI,KAAK,KAAK,SAAS;YAAE,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC9C,CAAC;IACD,OAAO,eAAe,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC;AAClD,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { createStderrLogger, silentLogger } from "./logger.js";
|
|
4
|
+
function captureLogger() {
|
|
5
|
+
const lines = [];
|
|
6
|
+
return { logger: createStderrLogger((l) => lines.push(l)), lines };
|
|
7
|
+
}
|
|
8
|
+
/** Parse the JSON payload out of a `[roon-call] {json}\n` line. */
|
|
9
|
+
function parse(line) {
|
|
10
|
+
const json = line.replace(/^\[roon-call] /, "").trimEnd();
|
|
11
|
+
return JSON.parse(json);
|
|
12
|
+
}
|
|
13
|
+
test("logs one info line with op, params, summary and timing on success", async () => {
|
|
14
|
+
const { logger, lines } = captureLogger();
|
|
15
|
+
const result = await logger.call("browse", { hierarchy: "search", item_key: "k1" }, async () => ({ action: "list" }), (r) => ({ action: r.action }));
|
|
16
|
+
assert.deepEqual(result, { action: "list" });
|
|
17
|
+
assert.equal(lines.length, 1);
|
|
18
|
+
const rec = parse(lines[0]);
|
|
19
|
+
assert.equal(rec.lvl, "info");
|
|
20
|
+
assert.equal(rec.op, "browse");
|
|
21
|
+
assert.deepEqual(rec.params, { hierarchy: "search", item_key: "k1" });
|
|
22
|
+
assert.deepEqual(rec.result, { action: "list" });
|
|
23
|
+
assert.equal(typeof rec.ms, "number");
|
|
24
|
+
assert.equal(typeof rec.t, "string");
|
|
25
|
+
});
|
|
26
|
+
test("logs an error line and rethrows the original error", async () => {
|
|
27
|
+
const { logger, lines } = captureLogger();
|
|
28
|
+
const boom = Object.assign(new Error("kaboom"), { code: "BROWSE_FAILED" });
|
|
29
|
+
await assert.rejects(logger.call("load", { offset: 0 }, async () => {
|
|
30
|
+
throw boom;
|
|
31
|
+
}), (e) => e === boom);
|
|
32
|
+
assert.equal(lines.length, 1);
|
|
33
|
+
const rec = parse(lines[0]);
|
|
34
|
+
assert.equal(rec.lvl, "error");
|
|
35
|
+
assert.equal(rec.op, "load");
|
|
36
|
+
assert.deepEqual(rec.error, { code: "BROWSE_FAILED", message: "kaboom" });
|
|
37
|
+
});
|
|
38
|
+
test("omits the result field when no summarizer is given", async () => {
|
|
39
|
+
const { logger, lines } = captureLogger();
|
|
40
|
+
await logger.call("get_zones", {}, async () => ({ zones: [] }));
|
|
41
|
+
const rec = parse(lines[0]);
|
|
42
|
+
assert.ok(!("result" in rec));
|
|
43
|
+
});
|
|
44
|
+
test("silentLogger runs the call but emits nothing", async () => {
|
|
45
|
+
let ran = false;
|
|
46
|
+
const out = await silentLogger.call("browse", {}, async () => {
|
|
47
|
+
ran = true;
|
|
48
|
+
return 7;
|
|
49
|
+
});
|
|
50
|
+
assert.equal(ran, true);
|
|
51
|
+
assert.equal(out, 7);
|
|
52
|
+
});
|
|
53
|
+
//# sourceMappingURL=logger.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.test.js","sourceRoot":"","sources":["../src/logger.test.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export type MusicItemType = "artist" | "album" | "track" | "genre" | "playlist" | "radio" | "unknown";
|
|
2
|
+
export type ZoneState = "playing" | "paused" | "stopped" | "loading" | "unknown";
|
|
3
|
+
export interface RoonZone {
|
|
4
|
+
zoneId: string;
|
|
5
|
+
displayName: string;
|
|
6
|
+
state: ZoneState;
|
|
7
|
+
outputIds: string[];
|
|
8
|
+
isDefaultCandidate: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface SearchMusicInput {
|
|
11
|
+
query: string;
|
|
12
|
+
type?: MusicItemType;
|
|
13
|
+
limit?: number;
|
|
14
|
+
/** When true and type is "genre", also sample a track mix from streaming services (e.g. TIDAL). */
|
|
15
|
+
includeStreaming?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface MusicCandidate {
|
|
18
|
+
itemKey: string;
|
|
19
|
+
title: string;
|
|
20
|
+
subtitle?: string;
|
|
21
|
+
type: MusicItemType;
|
|
22
|
+
score: number;
|
|
23
|
+
available: boolean;
|
|
24
|
+
sourceGroup?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SearchMusicOutput {
|
|
27
|
+
query: string;
|
|
28
|
+
candidates: MusicCandidate[];
|
|
29
|
+
broadened: boolean;
|
|
30
|
+
message?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface GetTracksForInput {
|
|
33
|
+
/** Opaque, session-scoped item key from a recent `search_music` result. */
|
|
34
|
+
itemKey: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface TrackCandidate {
|
|
38
|
+
itemKey: string;
|
|
39
|
+
title: string;
|
|
40
|
+
artist?: string;
|
|
41
|
+
album?: string;
|
|
42
|
+
durationSec?: number;
|
|
43
|
+
available: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface GetTracksForOutput {
|
|
46
|
+
sourceItemKey: string;
|
|
47
|
+
tracks: TrackCandidate[];
|
|
48
|
+
skipped: Array<{
|
|
49
|
+
itemKey?: string;
|
|
50
|
+
reason: string;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
export interface PlayNowInput {
|
|
54
|
+
/**
|
|
55
|
+
* Zone id or any of its output ids (from `list_zones`). Optional: when
|
|
56
|
+
* omitted the server falls back to the configured default zone
|
|
57
|
+
* (`ROON_DEFAULT_ZONE`), then to the single/Office/playing heuristics.
|
|
58
|
+
*/
|
|
59
|
+
zoneId?: string;
|
|
60
|
+
/** Opaque, session-scoped item key from a recent `search_music` result. */
|
|
61
|
+
itemKey: string;
|
|
62
|
+
shuffle?: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface EnqueueAndPlayInput {
|
|
65
|
+
/** Zone id or output id (from `list_zones`); see `PlayNowInput.zoneId`. */
|
|
66
|
+
zoneId?: string;
|
|
67
|
+
/** Ordered, session-scoped item keys (e.g. from `get_tracks_for`). */
|
|
68
|
+
itemKeys: string[];
|
|
69
|
+
shuffle?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface PlaybackResult {
|
|
72
|
+
ok: boolean;
|
|
73
|
+
zoneId: string;
|
|
74
|
+
/** Items started/queued by this call. `play_now` queues exactly one. */
|
|
75
|
+
queued: number;
|
|
76
|
+
skipped: Array<{
|
|
77
|
+
itemKey: string;
|
|
78
|
+
reason: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** Best-effort "now playing" line read just after the action; may be stale. */
|
|
81
|
+
nowPlaying?: string;
|
|
82
|
+
message?: string;
|
|
83
|
+
}
|
|
84
|
+
export interface EnqueueAndPlayOutput extends PlaybackResult {
|
|
85
|
+
/** Number of item keys the caller asked to queue. */
|
|
86
|
+
requested: number;
|
|
87
|
+
}
|
|
88
|
+
export type RoonMcpErrorCode = "NO_CORE_PAIRED" | "ZONE_NOT_FOUND" | "ZONE_AMBIGUOUS" | "BROWSE_FAILED" | "INVALID_ITEM_KEY" | "NO_SEARCH_RESULTS" | "NO_PLAYABLE_ITEMS" | "NO_PLAY_ACTION" | "ACTION_FAILED" | "PARTIAL_QUEUE";
|
|
89
|
+
export declare class RoonMcpError extends Error {
|
|
90
|
+
readonly code: RoonMcpErrorCode;
|
|
91
|
+
readonly details?: unknown | undefined;
|
|
92
|
+
constructor(code: RoonMcpErrorCode, message: string, details?: unknown | undefined);
|
|
93
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Public domain types shared across services and MCP tool boundaries.
|
|
2
|
+
// Kept aligned with the implementation plan's TypeScript interfaces.
|
|
3
|
+
export class RoonMcpError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(code, message, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.details = details;
|
|
10
|
+
this.name = "RoonMcpError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,qEAAqE;AAmHrE,MAAM,OAAO,YAAa,SAAQ,KAAK;IAEnB;IAEA;IAHlB,YACkB,IAAsB,EACtC,OAAe,EACC,OAAiB;QAEjC,KAAK,CAAC,OAAO,CAAC,CAAC;QAJC,SAAI,GAAJ,IAAI,CAAkB;QAEtB,YAAO,GAAP,OAAO,CAAU;QAGjC,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF"}
|