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.
Files changed (67) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +161 -0
  4. package/dist/BrowseSessionManager.d.ts +34 -0
  5. package/dist/BrowseSessionManager.js +97 -0
  6. package/dist/BrowseSessionManager.js.map +1 -0
  7. package/dist/BrowseSessionManager.test.d.ts +1 -0
  8. package/dist/BrowseSessionManager.test.js +83 -0
  9. package/dist/BrowseSessionManager.test.js.map +1 -0
  10. package/dist/GenreService.d.ts +22 -0
  11. package/dist/GenreService.js +177 -0
  12. package/dist/GenreService.js.map +1 -0
  13. package/dist/GenreService.test.d.ts +1 -0
  14. package/dist/GenreService.test.js +147 -0
  15. package/dist/GenreService.test.js.map +1 -0
  16. package/dist/PlaybackService.d.ts +43 -0
  17. package/dist/PlaybackService.js +291 -0
  18. package/dist/PlaybackService.js.map +1 -0
  19. package/dist/PlaybackService.test.d.ts +1 -0
  20. package/dist/PlaybackService.test.js +281 -0
  21. package/dist/PlaybackService.test.js.map +1 -0
  22. package/dist/RoonClient.d.ts +37 -0
  23. package/dist/RoonClient.js +105 -0
  24. package/dist/RoonClient.js.map +1 -0
  25. package/dist/RoonMcpServer.d.ts +21 -0
  26. package/dist/RoonMcpServer.js +218 -0
  27. package/dist/RoonMcpServer.js.map +1 -0
  28. package/dist/SearchNavigator.d.ts +38 -0
  29. package/dist/SearchNavigator.js +104 -0
  30. package/dist/SearchNavigator.js.map +1 -0
  31. package/dist/SearchService.d.ts +29 -0
  32. package/dist/SearchService.js +236 -0
  33. package/dist/SearchService.js.map +1 -0
  34. package/dist/SearchService.test.d.ts +1 -0
  35. package/dist/SearchService.test.js +197 -0
  36. package/dist/SearchService.test.js.map +1 -0
  37. package/dist/TrackExpansionService.d.ts +75 -0
  38. package/dist/TrackExpansionService.js +337 -0
  39. package/dist/TrackExpansionService.js.map +1 -0
  40. package/dist/TrackExpansionService.test.d.ts +1 -0
  41. package/dist/TrackExpansionService.test.js +382 -0
  42. package/dist/TrackExpansionService.test.js.map +1 -0
  43. package/dist/ZoneService.d.ts +52 -0
  44. package/dist/ZoneService.js +139 -0
  45. package/dist/ZoneService.js.map +1 -0
  46. package/dist/ZoneService.test.d.ts +1 -0
  47. package/dist/ZoneService.test.js +111 -0
  48. package/dist/ZoneService.test.js.map +1 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +44 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/locator.d.ts +58 -0
  53. package/dist/locator.js +88 -0
  54. package/dist/locator.js.map +1 -0
  55. package/dist/locator.test.d.ts +1 -0
  56. package/dist/locator.test.js +37 -0
  57. package/dist/locator.test.js.map +1 -0
  58. package/dist/logger.d.ts +23 -0
  59. package/dist/logger.js +56 -0
  60. package/dist/logger.js.map +1 -0
  61. package/dist/logger.test.d.ts +1 -0
  62. package/dist/logger.test.js +53 -0
  63. package/dist/logger.test.js.map +1 -0
  64. package/dist/types.d.ts +93 -0
  65. package/dist/types.js +13 -0
  66. package/dist/types.js.map +1 -0
  67. package/package.json +60 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The version history source of truth is git tags in the format `vMAJOR.MINOR.PATCH`.
6
+
7
+ ## [0.1.0] - 2026-06-21
8
+
9
+ ### Added
10
+
11
+ - Initial version.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Christoph Halang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # roon-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server to control the **Roon** music
4
+ player, so an AI agent can find music and start playback. Built on the official
5
+ Roon Extension API (`node-roon-api` + `node-roon-api-transport` /
6
+ `node-roon-api-browse`).
7
+
8
+ ## Requirements
9
+
10
+ - Node.js 20+
11
+ - A running **Roon Core** on the local network to pair with
12
+ - **git** on the install host — the `node-roon-api*` dependencies are published
13
+ on GitHub (not npm) and are fetched via git URLs during install
14
+
15
+ ## MCP Client Configuration
16
+
17
+ Add this to your MCP client config. `npx` fetches the package on first run:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "roon": {
23
+ "command": "npx",
24
+ "args": ["-y", "roon-mcp"],
25
+ "env": { "ROON_DEFAULT_ZONE": "Office" }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ On first launch, open **Roon → Settings → Extensions** and enable **Roon MCP**
32
+ to pair. Pairing status is logged to stderr; stdout is reserved for the MCP
33
+ protocol.
34
+
35
+ ### Global install (optional)
36
+
37
+ ```bash
38
+ npm install -g roon-mcp
39
+ ```
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "roon": {
45
+ "command": "roon-mcp",
46
+ "env": { "ROON_DEFAULT_ZONE": "Office" }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ | Env var | Purpose |
55
+ | --- | --- |
56
+ | `ROON_DEFAULT_ZONE` | Optional fallback target for `play_now` / `enqueue_and_play` when no `zoneId` is given — a zone/output id or a display-name substring. If unset, the server falls back to the only zone, an `Office` zone, or the currently-playing zone; if it still can't decide it returns `ZONE_AMBIGUOUS` so the agent can ask. |
57
+
58
+ ## Tools
59
+
60
+ | Tool | Purpose |
61
+ | --- | --- |
62
+ | `list_zones()` | List playable zones/outputs (id, name, state, output ids). |
63
+ | `search_music({ query, type?, limit?, includeStreaming? })` | Resolve a text query into ranked browse candidates (opaque, session-scoped item keys). `type` (`artist`/`album`/`track`/`genre`/`playlist`/`radio`) restricts the category; for non-genre types an empty typed search broadens to all categories. See [Genre search](#genre-search) for `type:"genre"` and `includeStreaming`. |
64
+ | `get_tracks_for({ itemKey, limit? })` | Expand an artist/album/genre/playlist candidate into concrete playable tracks. |
65
+ | `play_now({ zoneId?, itemKey, shuffle? })` | Immediately play one search candidate; `zoneId` optional (defaults as above). |
66
+ | `enqueue_and_play({ zoneId?, itemKeys, shuffle? })` | Build an ad-hoc queue from curated item keys and start it (**replaces** the zone's queue); reports queued/skipped. |
67
+
68
+ ### Genre search
69
+
70
+ Genres don't appear in Roon's flat search hierarchy, so `search_music({ type: "genre" })`
71
+ is handled specially: the server walks Roon's dedicated **Genres** tree (cached per
72
+ session) and returns the nearest-match genre nodes by fuzzy score, with their parent
73
+ path in the subtitle — e.g. `"Psychedelic Trance"` yields `Psytrance` / `Trance`. It
74
+ never silently broadens to artists/albums. These candidates are **library-scoped**
75
+ (genres present in your collection, including TIDAL albums you've added). Expand one
76
+ with `get_tracks_for` to get a cross-album mix of that genre.
77
+
78
+ Pass **`includeStreaming: true`** (only meaningful for `type:"genre"`) to also pull a
79
+ track mix from streaming services for discovery beyond your library. The server takes
80
+ the genre-relevant **albums** the flat search surfaces and samples tracks across them.
81
+ The result lists library genre nodes first, then ready-to-play streaming tracks (each a
82
+ `track` candidate, source group `Streaming`) appended after. Default is `false`
83
+ (library only).
84
+
85
+ > Cost: with `includeStreaming` on, each sampled album re-navigates the flat search, so
86
+ > an opt-in streaming genre search does a handful of extra browse round-trips.
87
+
88
+ ## Assumptions
89
+
90
+ - **Core language: English.** Category/action label matching (`Artists`, `Play Now`,
91
+ `Top Tracks`, …) assumes an English Core.
92
+ - **Sources: local library + TIDAL.** What searches surface reflects this; results
93
+ differ on a local-only Core.
94
+ - **Queue: replace.** `enqueue_and_play` starts a fresh queue rather than adding to
95
+ whatever is already playing.
96
+ - **Curation is agent-side.** Dedupe / cap-per-artist / ordering / trimming stay in the
97
+ agent; the server has no curation logic.
98
+
99
+ There is no `save_playlist` tool: Roon exposes no official playlist-write service, so
100
+ durable playlists are out of scope. Curated playback is delivered by `enqueue_and_play`
101
+ (an ad-hoc, in-the-moment queue).
102
+
103
+ ## Logging
104
+
105
+ Every Roon API call (`browse`, `load`, `get_zones`, `change_settings`) emits one
106
+ structured line to **stderr** (stdout stays reserved for MCP JSON-RPC):
107
+
108
+ ```
109
+ [roon-call] {"t":"2026-06-19T18:00:00.000Z","lvl":"info","op":"browse","ms":12,"params":{"hierarchy":"search","item_key":"…"},"result":{"action":"list","count":7}}
110
+ ```
111
+
112
+ Failures log `lvl:"error"` with the mapped error code, and retries surface as repeated
113
+ lines for the same `op`. Grep stderr for `[roon-call]` to trace a flow.
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ npm install # pulls node-roon-api packages from RoonLabs' GitHub
119
+ npm run build
120
+ npm test # builds, then runs node:test
121
+ npm run dev # tsc --watch
122
+ npm run typecheck
123
+ ```
124
+
125
+ Run the built server directly:
126
+
127
+ ```bash
128
+ npm start
129
+ ```
130
+
131
+ ### Integration smoke test
132
+
133
+ `scripts/integration.mjs` spawns the built server over stdio (like a real MCP client)
134
+ and runs list → search → expand against your Core. Audible steps are opt-in so it never
135
+ blasts music by accident:
136
+
137
+ ```bash
138
+ npm run build
139
+ node scripts/integration.mjs # list_zones + search only (read-only)
140
+ ROON_PLAY=1 node scripts/integration.mjs # also play_now a shuffled genre
141
+ ROON_ENQUEUE=1 node scripts/integration.mjs # also enqueue a few curated tracks
142
+ # overrides: ROON_ZONE="Office" ROON_QUERY="Tycho"
143
+ ```
144
+
145
+ Enable **Roon MCP** under Roon → Settings → Extensions on the first run (the script
146
+ retries `list_zones` for ~45s while it waits to pair).
147
+
148
+ ## Releasing
149
+
150
+ ```bash
151
+ npm run release -- <patch|minor|major>
152
+ ```
153
+
154
+ Requires a clean worktree on `main` and a matching `## [<version>]` entry in
155
+ [CHANGELOG.md](CHANGELOG.md). The script runs the tests, bumps the version, tags the
156
+ commit, and verifies the package with `npm pack`. It then prints the push and
157
+ `npm publish` commands to run.
158
+
159
+ ## License
160
+
161
+ [MIT](LICENSE)
@@ -0,0 +1,34 @@
1
+ import type { BrowseOptions, BrowseResultBody, LoadOptions, LoadResultBody } from "node-roon-api-browse";
2
+ import { RoonClient } from "./RoonClient.js";
3
+ import { type RoonCallLogger } from "./logger.js";
4
+ /**
5
+ * Roon keeps browse state per session on the Core side, so concurrent browse
6
+ * sequences would corrupt each other. This manager serializes whole browse
7
+ * operations through a single lock (`runExclusive`) and exposes lock-free
8
+ * `browse`/`load` primitives intended to be composed *inside* that lock.
9
+ */
10
+ export declare class BrowseSessionManager {
11
+ private readonly roon;
12
+ private readonly logger;
13
+ private tail;
14
+ constructor(roon: RoonClient, logger?: RoonCallLogger);
15
+ /** Run a multi-step browse sequence with exclusive access to the session. */
16
+ runExclusive<T>(operation: () => Promise<T>): Promise<T>;
17
+ /**
18
+ * Run an exclusive operation, retrying it once after resetting the search
19
+ * hierarchy if it fails with a stale-session error (a popped/expired item
20
+ * key surfaces as `INVALID_ITEM_KEY`).
21
+ *
22
+ * Only safe for operations whose item keys are produced *inside* `operation`
23
+ * (e.g. a search that browses fresh group keys): the reset can recover them.
24
+ * Do NOT use when `operation` drills a caller-supplied key — the reset would
25
+ * invalidate the very key being used.
26
+ */
27
+ runExclusiveWithRetry<T>(operation: () => Promise<T>, isRetryable?: (err: unknown) => boolean): Promise<T>;
28
+ /** Reset the search hierarchy to its root. Call inside `runExclusive`. */
29
+ resetSearchHierarchy(): Promise<void>;
30
+ /** Lock-free browse primitive. Compose inside `runExclusive`. */
31
+ browse(options: BrowseOptions): Promise<BrowseResultBody>;
32
+ /** Lock-free load primitive. Compose inside `runExclusive`. */
33
+ load(options: LoadOptions): Promise<LoadResultBody>;
34
+ }
@@ -0,0 +1,97 @@
1
+ import { silentLogger } from "./logger.js";
2
+ import { RoonMcpError } from "./types.js";
3
+ /**
4
+ * Roon keeps browse state per session on the Core side, so concurrent browse
5
+ * sequences would corrupt each other. This manager serializes whole browse
6
+ * operations through a single lock (`runExclusive`) and exposes lock-free
7
+ * `browse`/`load` primitives intended to be composed *inside* that lock.
8
+ */
9
+ export class BrowseSessionManager {
10
+ roon;
11
+ logger;
12
+ // Tail of the serialization chain; each exclusive op links onto it.
13
+ tail = Promise.resolve();
14
+ constructor(roon, logger = silentLogger) {
15
+ this.roon = roon;
16
+ this.logger = logger;
17
+ }
18
+ /** Run a multi-step browse sequence with exclusive access to the session. */
19
+ runExclusive(operation) {
20
+ const result = this.tail.then(operation, operation);
21
+ // Keep the chain alive regardless of this op's outcome.
22
+ this.tail = result.then(() => undefined, () => undefined);
23
+ return result;
24
+ }
25
+ /**
26
+ * Run an exclusive operation, retrying it once after resetting the search
27
+ * hierarchy if it fails with a stale-session error (a popped/expired item
28
+ * key surfaces as `INVALID_ITEM_KEY`).
29
+ *
30
+ * Only safe for operations whose item keys are produced *inside* `operation`
31
+ * (e.g. a search that browses fresh group keys): the reset can recover them.
32
+ * Do NOT use when `operation` drills a caller-supplied key — the reset would
33
+ * invalidate the very key being used.
34
+ */
35
+ runExclusiveWithRetry(operation, isRetryable = isStaleSession) {
36
+ return this.runExclusive(async () => {
37
+ try {
38
+ return await operation();
39
+ }
40
+ catch (err) {
41
+ if (!isRetryable(err))
42
+ throw err;
43
+ await this.resetSearchHierarchy();
44
+ return operation();
45
+ }
46
+ });
47
+ }
48
+ /** Reset the search hierarchy to its root. Call inside `runExclusive`. */
49
+ async resetSearchHierarchy() {
50
+ await this.browse({ hierarchy: "search", pop_all: true });
51
+ }
52
+ /** Lock-free browse primitive. Compose inside `runExclusive`. */
53
+ async browse(options) {
54
+ await this.roon.waitForCore();
55
+ const browse = this.roon.getBrowse();
56
+ return this.logger.call("browse", options, () => new Promise((resolve, reject) => {
57
+ browse.browse(options, (error, body) => {
58
+ if (error) {
59
+ reject(mapBrowseError(error, options));
60
+ return;
61
+ }
62
+ resolve(body);
63
+ });
64
+ }), (body) => ({
65
+ action: body.action,
66
+ list: body.list?.title,
67
+ count: body.list?.count,
68
+ isError: body.is_error,
69
+ }));
70
+ }
71
+ /** Lock-free load primitive. Compose inside `runExclusive`. */
72
+ async load(options) {
73
+ await this.roon.waitForCore();
74
+ const browse = this.roon.getBrowse();
75
+ return this.logger.call("load", options, () => new Promise((resolve, reject) => {
76
+ browse.load(options, (error, body) => {
77
+ if (error) {
78
+ reject(mapBrowseError(error, options));
79
+ return;
80
+ }
81
+ resolve(body);
82
+ });
83
+ }), (body) => ({ items: body.items.length, count: body.list?.count }));
84
+ }
85
+ }
86
+ /** A stale browse session / popped item key surfaces as INVALID_ITEM_KEY. */
87
+ function isStaleSession(err) {
88
+ return err instanceof RoonMcpError && err.code === "INVALID_ITEM_KEY";
89
+ }
90
+ function mapBrowseError(error, context) {
91
+ // Roon returns the message name, e.g. "InvalidItemKey", "NetworkError".
92
+ if (/invaliditemkey/i.test(error)) {
93
+ return new RoonMcpError("INVALID_ITEM_KEY", `Browse rejected item key: ${error}`, context);
94
+ }
95
+ return new RoonMcpError("BROWSE_FAILED", `Browse failed: ${error}`, context);
96
+ }
97
+ //# sourceMappingURL=BrowseSessionManager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrowseSessionManager.js","sourceRoot":"","sources":["../src/BrowseSessionManager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { BrowseSessionManager } from "./BrowseSessionManager.js";
4
+ import { RoonMcpError } from "./types.js";
5
+ function managerWithBrowse(browse) {
6
+ const stub = {
7
+ waitForCore: async () => undefined,
8
+ getBrowse: () => browse,
9
+ };
10
+ return new BrowseSessionManager(stub);
11
+ }
12
+ test("runExclusive serializes operations, never interleaving them", async () => {
13
+ const mgr = managerWithBrowse({});
14
+ const events = [];
15
+ let releaseA;
16
+ const aGate = new Promise((r) => (releaseA = r));
17
+ const a = mgr.runExclusive(async () => {
18
+ events.push("a:start");
19
+ await aGate;
20
+ events.push("a:end");
21
+ });
22
+ const b = mgr.runExclusive(async () => {
23
+ events.push("b:start");
24
+ events.push("b:end");
25
+ });
26
+ // B must not start until A finishes, even though A is still awaiting.
27
+ await Promise.resolve();
28
+ assert.deepEqual(events, ["a:start"]);
29
+ releaseA();
30
+ await Promise.all([a, b]);
31
+ assert.deepEqual(events, ["a:start", "a:end", "b:start", "b:end"]);
32
+ });
33
+ test("runExclusive keeps the chain alive after an operation throws", async () => {
34
+ const mgr = managerWithBrowse({});
35
+ await assert.rejects(mgr.runExclusive(async () => {
36
+ throw new Error("boom");
37
+ }));
38
+ const ok = await mgr.runExclusive(async () => 42);
39
+ assert.equal(ok, 42);
40
+ });
41
+ test("browse maps InvalidItemKey to a RoonMcpError code", async () => {
42
+ const mgr = managerWithBrowse({
43
+ browse: (_opts, cb) => cb("InvalidItemKey", undefined),
44
+ });
45
+ await assert.rejects(mgr.browse({ hierarchy: "search", item_key: "x" }), (e) => e instanceof RoonMcpError && e.code === "INVALID_ITEM_KEY");
46
+ });
47
+ test("browse maps other errors to BROWSE_FAILED", async () => {
48
+ const mgr = managerWithBrowse({
49
+ browse: (_opts, cb) => cb("NetworkError", undefined),
50
+ });
51
+ await assert.rejects(mgr.browse({ hierarchy: "search" }), (e) => e instanceof RoonMcpError && e.code === "BROWSE_FAILED");
52
+ });
53
+ test("runExclusiveWithRetry resets and replays once on a stale session", async () => {
54
+ // pop_all (the reset) is a browse call; count it to prove the reset happened.
55
+ let popAlls = 0;
56
+ const mgr = managerWithBrowse({
57
+ browse: (opts, cb) => {
58
+ if (opts.pop_all)
59
+ popAlls++;
60
+ cb(false, { action: "list" });
61
+ },
62
+ });
63
+ let attempts = 0;
64
+ const out = await mgr.runExclusiveWithRetry(async () => {
65
+ attempts++;
66
+ if (attempts === 1)
67
+ throw new RoonMcpError("INVALID_ITEM_KEY", "stale");
68
+ return "ok";
69
+ });
70
+ assert.equal(out, "ok");
71
+ assert.equal(attempts, 2);
72
+ assert.equal(popAlls, 1); // reset happened between the two attempts
73
+ });
74
+ test("runExclusiveWithRetry does not retry non-retryable errors", async () => {
75
+ const mgr = managerWithBrowse({});
76
+ let attempts = 0;
77
+ await assert.rejects(mgr.runExclusiveWithRetry(async () => {
78
+ attempts++;
79
+ throw new RoonMcpError("BROWSE_FAILED", "fatal");
80
+ }), (e) => e instanceof RoonMcpError && e.code === "BROWSE_FAILED");
81
+ assert.equal(attempts, 1);
82
+ });
83
+ //# sourceMappingURL=BrowseSessionManager.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrowseSessionManager.test.js","sourceRoot":"","sources":["../src/BrowseSessionManager.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import { BrowseSessionManager } from "./BrowseSessionManager.js";
2
+ import type { MusicCandidate } from "./types.js";
3
+ export declare const MIN_GENRE_SCORE = 0.4;
4
+ /** Fuzzy match of a free-text query to a genre node title; 0 = no match. */
5
+ export declare function scoreGenre(query: string, title: string): number;
6
+ /** Resolves genre queries by walking and caching the `genres` hierarchy tree. */
7
+ export declare class GenreService {
8
+ private readonly browse;
9
+ private index?;
10
+ constructor(browse: BrowseSessionManager);
11
+ /** Ranked genre candidates for a query (nearest real nodes first). */
12
+ searchGenres(query: string, limit: number): Promise<MusicCandidate[]>;
13
+ /** Build the index once per session; rebuild only if the build failed. */
14
+ private getIndex;
15
+ private buildIndex;
16
+ /**
17
+ * Depth-first walk of the level the session is currently positioned at.
18
+ * Records each genre node and drills it (until MAX_DEPTH), popping back so the
19
+ * parent's keys stay valid for the next sibling.
20
+ */
21
+ private walk;
22
+ }
@@ -0,0 +1,177 @@
1
+ import { encodeGenreLocator } from "./locator.js";
2
+ // Roon's flat `search` hierarchy doesn't expose genres; they live in a dedicated
3
+ // `genres` hierarchy as a shallow tree (root parents → sub-genres →
4
+ // sub-sub-genres). We walk that tree once, cache a flat name→path index for the
5
+ // session, and resolve a genre query against it with fuzzy scoring — so
6
+ // "Psychedelic Trance" (which has no exact node) surfaces the real nearby nodes
7
+ // "Psytrance" and "Trance" as ranked candidates.
8
+ // The tree is shallow in practice (parent → sub → sub-sub). Cap the walk so a
9
+ // pathological library can't make the first genre search run unbounded.
10
+ const MAX_DEPTH = 3;
11
+ // Load generously; genre lists run to a few dozen entries (Electronic had 38).
12
+ const SCAN_COUNT = 200;
13
+ // English container labels that sit inside a genre page alongside its
14
+ // sub-genres. They are NOT genres themselves, so the walk skips them. Same
15
+ // English-Core caveat as SearchService.GROUP_TITLE_TO_TYPE and
16
+ // PlaybackService.PLAY_LABELS.
17
+ const CONTAINER_LABELS = new Set(["artists", "albums"]);
18
+ function normalize(text) {
19
+ return text.trim().toLowerCase();
20
+ }
21
+ /** Lowercased alphanumerics only — collapses "Psy-Trance"/"Psy Trance" alike. */
22
+ function collapse(text) {
23
+ return text.toLowerCase().replace(/[^a-z0-9]/g, "");
24
+ }
25
+ // Connector words carry no genre signal; dropping them keeps coverage fair
26
+ // (e.g. "Drum and Bass" is two meaningful tokens, not three).
27
+ const STOPWORDS = new Set(["and", "the", "of", "an"]);
28
+ // Tokens shorter than this are ignored for fuzzy (substring/prefix) matching:
29
+ // 1–2 char tokens like R&B's "r"/"b" or the "n" in "Drum'n'Bass" otherwise
30
+ // match almost anything and flood results with false positives.
31
+ const MIN_TOKEN_LEN = 3;
32
+ function tokens(text) {
33
+ return text
34
+ .toLowerCase()
35
+ .split(/[^a-z0-9]+/)
36
+ .filter(Boolean);
37
+ }
38
+ /** Query tokens worth scoring: long enough to be meaningful, not stopwords. */
39
+ function meaningfulTokens(text) {
40
+ const all = tokens(text);
41
+ const kept = all.filter((tok) => tok.length >= MIN_TOKEN_LEN && !STOPWORDS.has(tok));
42
+ return kept.length > 0 ? kept : all; // never wipe out an all-short query
43
+ }
44
+ function sharedPrefixLen(a, b) {
45
+ const n = Math.min(a.length, b.length);
46
+ let i = 0;
47
+ while (i < n && a[i] === b[i])
48
+ i++;
49
+ return i;
50
+ }
51
+ /**
52
+ * A genre node in a `genres` page: a `list` row that isn't an "Artists"/"Albums"
53
+ * container. Real genre rows carry a "N Artists, M Albums" subtitle, which the
54
+ * containers lack — we use that as the positive signal, and the label set as a
55
+ * belt-and-braces guard.
56
+ */
57
+ function isGenreNode(item) {
58
+ if (!item.item_key || item.hint !== "list")
59
+ return false;
60
+ if (CONTAINER_LABELS.has(normalize(item.title)))
61
+ return false;
62
+ return /\d+\s+artist/i.test(item.subtitle ?? "");
63
+ }
64
+ // Token-overlap scores are scaled below the exact/collapsed tiers so a genuine
65
+ // node always outranks a partial match, while a full token-coverage match still
66
+ // scores well (≈0.85).
67
+ const TOKEN_WEIGHT = 0.85;
68
+ // Candidates below this are treated as noise (an absent genre), not a match.
69
+ // Tuned so a single shared word out of two query tokens (≈0.43) still surfaces
70
+ // as a "nearest", but a lone short/prefix coincidence does not.
71
+ export const MIN_GENRE_SCORE = 0.4;
72
+ /** Fuzzy match of a free-text query to a genre node title; 0 = no match. */
73
+ export function scoreGenre(query, title) {
74
+ const q = normalize(query);
75
+ const t = normalize(title);
76
+ if (t === q)
77
+ return 1.0;
78
+ if (collapse(query) === collapse(title))
79
+ return 0.95;
80
+ // Per-query-token best overlap with the title's tokens. No blanket
81
+ // prefix-of-the-whole-string tier: that gave a partial like "Psychedelic"
82
+ // (covering only the first word of "Psychedelic Trance") an undeserved 0.8,
83
+ // outranking the better "Psytrance".
84
+ const qTokens = meaningfulTokens(query);
85
+ const tTokens = tokens(title);
86
+ if (qTokens.length === 0 || tTokens.length === 0)
87
+ return 0;
88
+ let sum = 0;
89
+ for (const qt of qTokens) {
90
+ let best = 0;
91
+ for (const tt of tTokens) {
92
+ if (qt === tt) {
93
+ best = 1;
94
+ break;
95
+ }
96
+ // Only meaningful (≥3 char) title tokens may fuzzy-match, so short tokens
97
+ // like "r"/"b"/"n" can't spuriously match.
98
+ if (tt.length >= MIN_TOKEN_LEN) {
99
+ if (tt.includes(qt) || qt.includes(tt))
100
+ best = Math.max(best, 0.7);
101
+ else if (sharedPrefixLen(qt, tt) >= 3)
102
+ best = Math.max(best, 0.4);
103
+ }
104
+ }
105
+ sum += best;
106
+ }
107
+ return (sum / qTokens.length) * TOKEN_WEIGHT;
108
+ }
109
+ /** Resolves genre queries by walking and caching the `genres` hierarchy tree. */
110
+ export class GenreService {
111
+ browse;
112
+ index;
113
+ constructor(browse) {
114
+ this.browse = browse;
115
+ }
116
+ /** Ranked genre candidates for a query (nearest real nodes first). */
117
+ async searchGenres(query, limit) {
118
+ const index = await this.getIndex();
119
+ return index
120
+ .map((entry) => ({ entry, score: scoreGenre(query, entry.title) }))
121
+ .filter((s) => s.score >= MIN_GENRE_SCORE)
122
+ .sort((a, b) => b.score - a.score)
123
+ .slice(0, limit)
124
+ .map(({ entry, score }) => ({
125
+ itemKey: encodeGenreLocator(entry.path),
126
+ title: entry.title,
127
+ subtitle: entry.path.join(" › "),
128
+ type: "genre",
129
+ score: Number(score.toFixed(4)),
130
+ available: true,
131
+ sourceGroup: "Genres",
132
+ }));
133
+ }
134
+ /** Build the index once per session; rebuild only if the build failed. */
135
+ getIndex() {
136
+ if (!this.index) {
137
+ this.index = this.buildIndex().catch((err) => {
138
+ this.index = undefined; // let a later call retry a failed walk
139
+ throw err;
140
+ });
141
+ }
142
+ return this.index;
143
+ }
144
+ buildIndex() {
145
+ // The whole DFS runs in one exclusive session so the level-scoped browse
146
+ // keys stay live as we drill and pop. Item keys at a level survive drilling
147
+ // and popping a sibling (same pattern as SearchService.collectFromGroups).
148
+ return this.browse.runExclusive(async () => {
149
+ const entries = [];
150
+ await this.browse.browse({ hierarchy: "genres", pop_all: true });
151
+ await this.walk([], entries, 0);
152
+ return entries;
153
+ });
154
+ }
155
+ /**
156
+ * Depth-first walk of the level the session is currently positioned at.
157
+ * Records each genre node and drills it (until MAX_DEPTH), popping back so the
158
+ * parent's keys stay valid for the next sibling.
159
+ */
160
+ async walk(path, entries, depth) {
161
+ const loaded = await this.browse.load({ hierarchy: "genres", offset: 0, count: SCAN_COUNT });
162
+ const nodes = loaded.items.filter(isGenreNode);
163
+ for (const node of nodes) {
164
+ const childPath = [...path, node.title];
165
+ entries.push({ path: childPath, title: node.title });
166
+ if (depth + 1 >= MAX_DEPTH)
167
+ continue;
168
+ const into = await this.browse.browse({ hierarchy: "genres", item_key: node.item_key });
169
+ if (into.action !== "list")
170
+ continue;
171
+ await this.walk(childPath, entries, depth + 1);
172
+ // Level-scoped keys: pop back to this level before the next sibling.
173
+ await this.browse.browse({ hierarchy: "genres", pop_levels: 1 });
174
+ }
175
+ }
176
+ }
177
+ //# sourceMappingURL=GenreService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GenreService.js","sourceRoot":"","sources":["../src/GenreService.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGlD,iFAAiF;AACjF,oEAAoE;AACpE,gFAAgF;AAChF,wEAAwE;AACxE,gFAAgF;AAChF,iDAAiD;AAEjD,8EAA8E;AAC9E,wEAAwE;AACxE,MAAM,SAAS,GAAG,CAAC,CAAC;AACpB,+EAA+E;AAC/E,MAAM,UAAU,GAAG,GAAG,CAAC;AAEvB,sEAAsE;AACtE,2EAA2E;AAC3E,+DAA+D;AAC/D,+BAA+B;AAC/B,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;AAQxD,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AACnC,CAAC;AAED,iFAAiF;AACjF,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;AACtD,CAAC;AAED,2EAA2E;AAC3E,8DAA8D;AAC9D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;AACtD,8EAA8E;AAC9E,2EAA2E;AAC3E,gEAAgE;AAChE,MAAM,aAAa,GAAG,CAAC,CAAC;AAExB,SAAS,MAAM,CAAC,IAAY;IAC1B,OAAO,IAAI;SACR,WAAW,EAAE;SACb,KAAK,CAAC,YAAY,CAAC;SACnB,MAAM,CAAC,OAAO,CAAC,CAAC;AACrB,CAAC;AAED,+EAA+E;AAC/E,SAAS,gBAAgB,CAAC,IAAY;IACpC,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IACzB,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,IAAI,aAAa,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IACrF,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,oCAAoC;AAC3E,CAAC;AAED,SAAS,eAAe,CAAC,CAAS,EAAE,CAAS;IAC3C,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;IACvC,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAAE,CAAC,EAAE,CAAC;IACnC,OAAO,CAAC,CAAC;AACX,CAAC;AAED;;;;;GAKG;AACH,SAAS,WAAW,CAAC,IAAgB;IACnC,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACzD,IAAI,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAC9D,OAAO,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC;AACnD,CAAC;AAED,+EAA+E;AAC/E,gFAAgF;AAChF,uBAAuB;AACvB,MAAM,YAAY,GAAG,IAAI,CAAC;AAC1B,6EAA6E;AAC7E,+EAA+E;AAC/E,gEAAgE;AAChE,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,CAAC;AAEnC,4EAA4E;AAC5E,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,KAAa;IACrD,MAAM,CAAC,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC;IAC3B,MAAM,CAAC,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC;IAC3B,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IACxB,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAErD,mEAAmE;IACnE,0EAA0E;IAC1E,4EAA4E;IAC5E,qCAAqC;IACrC,MAAM,OAAO,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;IACxC,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9B,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAE3D,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;QACzB,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;YACzB,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;gBACd,IAAI,GAAG,CAAC,CAAC;gBACT,MAAM;YACR,CAAC;YACD,0EAA0E;YAC1E,2CAA2C;YAC3C,IAAI,EAAE,CAAC,MAAM,IAAI,aAAa,EAAE,CAAC;gBAC/B,IAAI,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC;oBAAE,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;qBAC9D,IAAI,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC;oBAAE,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;YACpE,CAAC;QACH,CAAC;QACD,GAAG,IAAI,IAAI,CAAC;IACd,CAAC;IACD,OAAO,CAAC,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,YAAY,CAAC;AAC/C,CAAC;AAED,iFAAiF;AACjF,MAAM,OAAO,YAAY;IAGM;IAFrB,KAAK,CAAyB;IAEtC,YAA6B,MAA4B;QAA5B,WAAM,GAAN,MAAM,CAAsB;IAAG,CAAC;IAE7D,sEAAsE;IACtE,KAAK,CAAC,YAAY,CAAC,KAAa,EAAE,KAAa;QAC7C,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC;QACpC,OAAO,KAAK;aACT,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;aAClE,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,eAAe,CAAC;aACzC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;aACjC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC;aACf,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC;YAC1B,OAAO,EAAE,kBAAkB,CAAC,KAAK,CAAC,IAAI,CAAC;YACvC,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;YAChC,IAAI,EAAE,OAAgB;YACtB,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;YAC/B,SAAS,EAAE,IAAI;YACf,WAAW,EAAE,QAAQ;SACtB,CAAC,CAAC,CAAC;IACR,CAAC;IAED,0EAA0E;IAClE,QAAQ;QACd,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YAChB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;gBAC3C,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC,uCAAuC;gBAC/D,MAAM,GAAG,CAAC;YACZ,CAAC,CAAC,CAAC;QACL,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAEO,UAAU;QAChB,yEAAyE;QACzE,4EAA4E;QAC5E,2EAA2E;QAC3E,OAAO,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,KAAK,IAAI,EAAE;YACzC,MAAM,OAAO,GAAiB,EAAE,CAAC;YACjC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;YACjE,MAAM,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;YAChC,OAAO,OAAO,CAAC;QACjB,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;;;OAIG;IACK,KAAK,CAAC,IAAI,CAAC,IAAc,EAAE,OAAqB,EAAE,KAAa;QACrE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;QAC7F,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;QAE/C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,SAAS,GAAG,CAAC,GAAG,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;YACxC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;YACrD,IAAI,KAAK,GAAG,CAAC,IAAI,SAAS;gBAAE,SAAS;YAErC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAS,EAAE,CAAC,CAAC;YACzF,IAAI,IAAI,CAAC,MAAM,KAAK,MAAM;gBAAE,SAAS;YACrC,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;YAC/C,qEAAqE;YACrE,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;QACnE,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1 @@
1
+ export {};