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
package/CHANGELOG.md
ADDED
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":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|