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,218 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { RoonMcpError } from "./types.js";
|
|
5
|
+
const MUSIC_ITEM_TYPES = [
|
|
6
|
+
"artist",
|
|
7
|
+
"album",
|
|
8
|
+
"track",
|
|
9
|
+
"genre",
|
|
10
|
+
"playlist",
|
|
11
|
+
"radio",
|
|
12
|
+
"unknown",
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Owns MCP startup and tool registration, and maps tool calls to services.
|
|
16
|
+
* Milestone 1 ships only `list_zones`.
|
|
17
|
+
*/
|
|
18
|
+
export class RoonMcpServer {
|
|
19
|
+
roon;
|
|
20
|
+
zones;
|
|
21
|
+
search;
|
|
22
|
+
tracks;
|
|
23
|
+
playback;
|
|
24
|
+
server;
|
|
25
|
+
constructor(roon, zones, search, tracks, playback) {
|
|
26
|
+
this.roon = roon;
|
|
27
|
+
this.zones = zones;
|
|
28
|
+
this.search = search;
|
|
29
|
+
this.tracks = tracks;
|
|
30
|
+
this.playback = playback;
|
|
31
|
+
this.server = new McpServer({
|
|
32
|
+
name: "roon-mcp",
|
|
33
|
+
version: "0.1.0",
|
|
34
|
+
});
|
|
35
|
+
this.registerTools();
|
|
36
|
+
}
|
|
37
|
+
registerTools() {
|
|
38
|
+
this.server.registerTool("list_zones", {
|
|
39
|
+
title: "List Roon zones",
|
|
40
|
+
description: "List the playable zones/outputs exposed by the paired Roon Core. " +
|
|
41
|
+
"Returns each zone's id, display name, playback state, and output ids.",
|
|
42
|
+
inputSchema: {},
|
|
43
|
+
}, async () => {
|
|
44
|
+
try {
|
|
45
|
+
const zones = await this.zones.listZones();
|
|
46
|
+
const message = zones.length === 0 ? "No zones available on the paired Core." : undefined;
|
|
47
|
+
return structured({ zones, ...(message ? { message } : {}) });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
return toToolError(err);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
this.server.registerTool("search_music", {
|
|
54
|
+
title: "Search Roon music",
|
|
55
|
+
description: "Resolve a text query into ranked Roon browse candidates. Optionally " +
|
|
56
|
+
"filter by item type (artist, album, track, genre, playlist, radio); " +
|
|
57
|
+
"for non-genre types, an empty typed search broadens to all categories. " +
|
|
58
|
+
"type:\"genre\" is special: genres don't appear in Roon's flat search, so " +
|
|
59
|
+
"the server walks the dedicated Genres tree and returns the nearest-match " +
|
|
60
|
+
"genre nodes (with their parent path in the subtitle) without broadening — " +
|
|
61
|
+
"e.g. \"Psychedelic Trance\" yields \"Psytrance\"/\"Trance\". Set " +
|
|
62
|
+
"includeStreaming:true (only meaningful for type:\"genre\") to also pull a " +
|
|
63
|
+
"track mix from streaming services (e.g. TIDAL): the server takes the " +
|
|
64
|
+
"genre-relevant albums and samples tracks across them, so library genre " +
|
|
65
|
+
"nodes are listed first and ready-to-play streaming tracks appended after. " +
|
|
66
|
+
"Returns opaque, session-scoped item keys for use by playback tools.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
query: z.string().min(1).describe("Free-text search, e.g. 'Dark Ambient' or 'Tycho'."),
|
|
69
|
+
type: z
|
|
70
|
+
.enum(MUSIC_ITEM_TYPES)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe("Restrict results to this item type."),
|
|
73
|
+
limit: z
|
|
74
|
+
.number()
|
|
75
|
+
.int()
|
|
76
|
+
.min(1)
|
|
77
|
+
.max(50)
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Max candidates to return (default 10)."),
|
|
80
|
+
includeStreaming: z
|
|
81
|
+
.boolean()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Also pull a track mix from streaming services. " +
|
|
84
|
+
"Only applies when type is \"genre\": library genre nodes come first, " +
|
|
85
|
+
"then tracks sampled across genre-relevant streaming albums. Default false."),
|
|
86
|
+
},
|
|
87
|
+
}, async (args) => {
|
|
88
|
+
try {
|
|
89
|
+
const result = await this.search.searchMusic(args);
|
|
90
|
+
return structured(result);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
return toToolError(err);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
this.server.registerTool("get_tracks_for", {
|
|
97
|
+
title: "Expand a Roon item into tracks",
|
|
98
|
+
description: "Expand an artist, album, genre, or playlist candidate into concrete " +
|
|
99
|
+
"playable tracks. Pass an itemKey from a recent search_music result. " +
|
|
100
|
+
"Returns track candidates with session-scoped item keys (use them " +
|
|
101
|
+
"promptly with enqueue_and_play). Non-expandable items return empty " +
|
|
102
|
+
"tracks with a skipped reason rather than an error.",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
itemKey: z
|
|
105
|
+
.string()
|
|
106
|
+
.min(1)
|
|
107
|
+
.describe("Opaque item key from a recent search_music result."),
|
|
108
|
+
limit: z
|
|
109
|
+
.number()
|
|
110
|
+
.int()
|
|
111
|
+
.min(1)
|
|
112
|
+
.max(50)
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Max tracks to return (default 10)."),
|
|
115
|
+
},
|
|
116
|
+
}, async (args) => {
|
|
117
|
+
try {
|
|
118
|
+
const result = await this.tracks.getTracksFor(args);
|
|
119
|
+
return structured(result);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
return toToolError(err);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
this.server.registerTool("play_now", {
|
|
126
|
+
title: "Play an item now in a Roon zone",
|
|
127
|
+
description: "Immediately play a single search candidate in the target zone. Pass an " +
|
|
128
|
+
"itemKey from a recent search_music result (item keys are session-scoped, " +
|
|
129
|
+
"so use a fresh one). zoneId is optional: omit it to use the configured " +
|
|
130
|
+
"default zone (ROON_DEFAULT_ZONE) or the single/Office/playing fallback; " +
|
|
131
|
+
"an ambiguous result returns ZONE_AMBIGUOUS so you can ask. Optionally " +
|
|
132
|
+
"shuffle. Returns a PlaybackResult.",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
zoneId: z
|
|
135
|
+
.string()
|
|
136
|
+
.min(1)
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Target zone id or output id (from list_zones). Omit to use the default zone."),
|
|
139
|
+
itemKey: z
|
|
140
|
+
.string()
|
|
141
|
+
.min(1)
|
|
142
|
+
.describe("Opaque item key from a recent search_music result."),
|
|
143
|
+
shuffle: z
|
|
144
|
+
.boolean()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("Shuffle the selection when starting playback."),
|
|
147
|
+
},
|
|
148
|
+
}, async (args) => {
|
|
149
|
+
try {
|
|
150
|
+
const result = await this.playback.playNow(args);
|
|
151
|
+
return structured(result);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return toToolError(err);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
this.server.registerTool("enqueue_and_play", {
|
|
158
|
+
title: "Build and start a curated Roon queue",
|
|
159
|
+
description: "Build an ad-hoc queue from an ordered list of curated item keys and " +
|
|
160
|
+
"start playback in the target zone. This replaces the zone's current " +
|
|
161
|
+
"queue: the first playable item starts immediately (Play Now), the rest " +
|
|
162
|
+
"are appended in order. Pass itemKeys from recent get_tracks_for/" +
|
|
163
|
+
"search_music results (use them promptly — they are session-scoped). " +
|
|
164
|
+
"zoneId is optional (omit to use the default zone; see play_now). " +
|
|
165
|
+
"Optionally shuffle. Returns a PlaybackResult with queued/skipped counts " +
|
|
166
|
+
"so you can backfill skipped items.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
zoneId: z
|
|
169
|
+
.string()
|
|
170
|
+
.min(1)
|
|
171
|
+
.optional()
|
|
172
|
+
.describe("Target zone id or output id (from list_zones). Omit to use the default zone."),
|
|
173
|
+
itemKeys: z
|
|
174
|
+
.array(z.string().min(1))
|
|
175
|
+
.min(1)
|
|
176
|
+
.describe("Ordered item keys to queue (from get_tracks_for/search_music)."),
|
|
177
|
+
shuffle: z
|
|
178
|
+
.boolean()
|
|
179
|
+
.optional()
|
|
180
|
+
.describe("Shuffle the queue; omit to leave the zone's setting unchanged."),
|
|
181
|
+
},
|
|
182
|
+
}, async (args) => {
|
|
183
|
+
try {
|
|
184
|
+
const result = await this.playback.enqueueAndPlay(args);
|
|
185
|
+
return structured(result);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return toToolError(err);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async start() {
|
|
193
|
+
this.roon.start();
|
|
194
|
+
const transport = new StdioServerTransport();
|
|
195
|
+
await this.server.connect(transport);
|
|
196
|
+
}
|
|
197
|
+
async stop() {
|
|
198
|
+
this.roon.stop();
|
|
199
|
+
await this.server.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function structured(payload) {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
205
|
+
structuredContent: payload,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function toToolError(err) {
|
|
209
|
+
const code = err instanceof RoonMcpError ? err.code : "BROWSE_FAILED";
|
|
210
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
211
|
+
return {
|
|
212
|
+
isError: true,
|
|
213
|
+
content: [
|
|
214
|
+
{ type: "text", text: JSON.stringify({ error: { code, message } }, null, 2) },
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=RoonMcpServer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RoonMcpServer.js","sourceRoot":"","sources":["../src/RoonMcpServer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BrowseHierarchy, BrowseItem, BrowseResultBody } from "node-roon-api-browse";
|
|
2
|
+
import { BrowseSessionManager } from "./BrowseSessionManager.js";
|
|
3
|
+
import { type Locator } from "./locator.js";
|
|
4
|
+
export { hierarchyForLocator } from "./locator.js";
|
|
5
|
+
export declare const SEARCH_HIERARCHY: BrowseHierarchy;
|
|
6
|
+
/** A selectable browse entry: has a key and isn't a header. */
|
|
7
|
+
export declare function isSelectable(item: BrowseItem): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Re-navigates the search hierarchy to reach an item by *locator* (query +
|
|
10
|
+
* group index + item index). Because every step runs in one uninterrupted
|
|
11
|
+
* browse session, the keys are live at the moment they're used — unlike the raw
|
|
12
|
+
* keys `search_music` collects and then strands by popping back. See
|
|
13
|
+
* `locator.ts` for the why.
|
|
14
|
+
*/
|
|
15
|
+
export declare class SearchNavigator {
|
|
16
|
+
private readonly browse;
|
|
17
|
+
constructor(browse: BrowseSessionManager);
|
|
18
|
+
/**
|
|
19
|
+
* Drill to the item at (loc.g, loc.i) and browse *into* it, leaving the
|
|
20
|
+
* session positioned at the item's child/action level. Returns that browse
|
|
21
|
+
* result. Throws INVALID_ITEM_KEY if the indices no longer resolve.
|
|
22
|
+
*
|
|
23
|
+
* Compose inside `runExclusive` — this issues a multi-step browse sequence.
|
|
24
|
+
*/
|
|
25
|
+
openItem(loc: Locator): Promise<BrowseResultBody>;
|
|
26
|
+
private openSearchItem;
|
|
27
|
+
/**
|
|
28
|
+
* Re-walk the `genres` hierarchy by node title, drilling each segment of the
|
|
29
|
+
* path from the root, and browse *into* the final genre node. Matching by
|
|
30
|
+
* title (not a stale key) keeps the locator durable across calls — the same
|
|
31
|
+
* reasoning as the search locator.
|
|
32
|
+
*/
|
|
33
|
+
private openGenrePath;
|
|
34
|
+
/** Load the children of the currently-opened item (its actions or sub-list). */
|
|
35
|
+
loadCurrent(count?: number): Promise<BrowseItem[]>;
|
|
36
|
+
}
|
|
37
|
+
/** Decode an MCP itemKey to a locator, or throw if it isn't a valid locator. */
|
|
38
|
+
export declare function requireLocator(itemKey: string): Locator;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { decodeLocator, isGenreLocator, } from "./locator.js";
|
|
2
|
+
import { RoonMcpError } from "./types.js";
|
|
3
|
+
export { hierarchyForLocator } from "./locator.js";
|
|
4
|
+
// Flat-search results live in the "search" hierarchy; we re-navigate there.
|
|
5
|
+
export const SEARCH_HIERARCHY = "search";
|
|
6
|
+
// Genre nodes live in the dedicated "genres" hierarchy.
|
|
7
|
+
const GENRES_HIERARCHY = "genres";
|
|
8
|
+
// Load generously when re-resolving so a locator index is never cut off by a
|
|
9
|
+
// short page (search itself may have collected with a small `limit`).
|
|
10
|
+
const RESOLVE_COUNT = 100;
|
|
11
|
+
/** A selectable browse entry: has a key and isn't a header. */
|
|
12
|
+
export function isSelectable(item) {
|
|
13
|
+
return Boolean(item.item_key) && item.hint !== "header";
|
|
14
|
+
}
|
|
15
|
+
const STALE = () => new RoonMcpError("INVALID_ITEM_KEY", "That search result is no longer available; re-run search_music to refresh it.");
|
|
16
|
+
/**
|
|
17
|
+
* Re-navigates the search hierarchy to reach an item by *locator* (query +
|
|
18
|
+
* group index + item index). Because every step runs in one uninterrupted
|
|
19
|
+
* browse session, the keys are live at the moment they're used — unlike the raw
|
|
20
|
+
* keys `search_music` collects and then strands by popping back. See
|
|
21
|
+
* `locator.ts` for the why.
|
|
22
|
+
*/
|
|
23
|
+
export class SearchNavigator {
|
|
24
|
+
browse;
|
|
25
|
+
constructor(browse) {
|
|
26
|
+
this.browse = browse;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Drill to the item at (loc.g, loc.i) and browse *into* it, leaving the
|
|
30
|
+
* session positioned at the item's child/action level. Returns that browse
|
|
31
|
+
* result. Throws INVALID_ITEM_KEY if the indices no longer resolve.
|
|
32
|
+
*
|
|
33
|
+
* Compose inside `runExclusive` — this issues a multi-step browse sequence.
|
|
34
|
+
*/
|
|
35
|
+
async openItem(loc) {
|
|
36
|
+
return isGenreLocator(loc) ? this.openGenrePath(loc) : this.openSearchItem(loc);
|
|
37
|
+
}
|
|
38
|
+
async openSearchItem(loc) {
|
|
39
|
+
await this.browse.browse({ hierarchy: SEARCH_HIERARCHY, input: loc.q, pop_all: true });
|
|
40
|
+
const top = await this.browse.load({
|
|
41
|
+
hierarchy: SEARCH_HIERARCHY,
|
|
42
|
+
offset: 0,
|
|
43
|
+
count: RESOLVE_COUNT,
|
|
44
|
+
});
|
|
45
|
+
const group = top.items.filter(isSelectable)[loc.g];
|
|
46
|
+
if (!group?.item_key)
|
|
47
|
+
throw STALE();
|
|
48
|
+
const gnav = await this.browse.browse({ hierarchy: SEARCH_HIERARCHY, item_key: group.item_key });
|
|
49
|
+
if (gnav.action !== "list")
|
|
50
|
+
throw STALE();
|
|
51
|
+
const children = await this.browse.load({
|
|
52
|
+
hierarchy: SEARCH_HIERARCHY,
|
|
53
|
+
offset: 0,
|
|
54
|
+
count: RESOLVE_COUNT,
|
|
55
|
+
});
|
|
56
|
+
const item = children.items.filter(isSelectable)[loc.i];
|
|
57
|
+
if (!item?.item_key)
|
|
58
|
+
throw STALE();
|
|
59
|
+
return this.browse.browse({ hierarchy: SEARCH_HIERARCHY, item_key: item.item_key });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Re-walk the `genres` hierarchy by node title, drilling each segment of the
|
|
63
|
+
* path from the root, and browse *into* the final genre node. Matching by
|
|
64
|
+
* title (not a stale key) keeps the locator durable across calls — the same
|
|
65
|
+
* reasoning as the search locator.
|
|
66
|
+
*/
|
|
67
|
+
async openGenrePath(loc) {
|
|
68
|
+
await this.browse.browse({ hierarchy: GENRES_HIERARCHY, pop_all: true });
|
|
69
|
+
let last;
|
|
70
|
+
for (const name of loc.ge) {
|
|
71
|
+
const level = await this.browse.load({
|
|
72
|
+
hierarchy: GENRES_HIERARCHY,
|
|
73
|
+
offset: 0,
|
|
74
|
+
count: RESOLVE_COUNT,
|
|
75
|
+
});
|
|
76
|
+
const target = name.trim().toLowerCase();
|
|
77
|
+
const node = level.items
|
|
78
|
+
.filter(isSelectable)
|
|
79
|
+
.find((i) => i.title.trim().toLowerCase() === target);
|
|
80
|
+
if (!node?.item_key)
|
|
81
|
+
throw STALE();
|
|
82
|
+
last = await this.browse.browse({ hierarchy: GENRES_HIERARCHY, item_key: node.item_key });
|
|
83
|
+
if (last.action !== "list")
|
|
84
|
+
throw STALE();
|
|
85
|
+
}
|
|
86
|
+
if (!last)
|
|
87
|
+
throw STALE();
|
|
88
|
+
return last;
|
|
89
|
+
}
|
|
90
|
+
/** Load the children of the currently-opened item (its actions or sub-list). */
|
|
91
|
+
async loadCurrent(count = RESOLVE_COUNT) {
|
|
92
|
+
const loaded = await this.browse.load({ hierarchy: SEARCH_HIERARCHY, offset: 0, count });
|
|
93
|
+
return loaded.items;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Decode an MCP itemKey to a locator, or throw if it isn't a valid locator. */
|
|
97
|
+
export function requireLocator(itemKey) {
|
|
98
|
+
const loc = decodeLocator(itemKey);
|
|
99
|
+
if (!loc) {
|
|
100
|
+
throw new RoonMcpError("INVALID_ITEM_KEY", "itemKey is not a valid search_music locator; pass a key from a recent search_music/get_tracks_for result.");
|
|
101
|
+
}
|
|
102
|
+
return loc;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=SearchNavigator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SearchNavigator.js","sourceRoot":"","sources":["../src/SearchNavigator.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BrowseSessionManager } from "./BrowseSessionManager.js";
|
|
2
|
+
import { GenreService } from "./GenreService.js";
|
|
3
|
+
import { TrackExpansionService } from "./TrackExpansionService.js";
|
|
4
|
+
import { type SearchMusicInput, type SearchMusicOutput } from "./types.js";
|
|
5
|
+
/** Turns a text query into ranked browse candidates via the search hierarchy. */
|
|
6
|
+
export declare class SearchService {
|
|
7
|
+
private readonly browse;
|
|
8
|
+
private readonly genres;
|
|
9
|
+
private readonly tracks;
|
|
10
|
+
constructor(browse: BrowseSessionManager, genres: GenreService, tracks: TrackExpansionService);
|
|
11
|
+
searchMusic(input: SearchMusicInput): Promise<SearchMusicOutput>;
|
|
12
|
+
private searchGenres;
|
|
13
|
+
/**
|
|
14
|
+
* Genre discovery beyond the library. Roon's flat search is text-based, not
|
|
15
|
+
* genre-filtered, so a raw "tracks" search for a genre name returns noise
|
|
16
|
+
* (tracks with the word in their title). Instead we take the genre-relevant
|
|
17
|
+
* albums the flat search surfaces (album/artist metadata makes those genuinely
|
|
18
|
+
* on-genre) and sample a few tracks across them — the same spread-across-albums
|
|
19
|
+
* approach get_tracks_for uses for library genres — yielding a real cross-album
|
|
20
|
+
* mix rather than whole albums. Each expansion re-navigates the flat search; an
|
|
21
|
+
* opt-in cost we accept for the streaming path.
|
|
22
|
+
*/
|
|
23
|
+
private collectStreamingGenreTracks;
|
|
24
|
+
private performSearch;
|
|
25
|
+
/** Indices (into the top-level group list) to scan for the requested type. */
|
|
26
|
+
private selectGroupIndices;
|
|
27
|
+
private collectFromGroups;
|
|
28
|
+
private rankCandidates;
|
|
29
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { encodeLocator } from "./locator.js";
|
|
2
|
+
import { SEARCH_HIERARCHY, isSelectable } from "./SearchNavigator.js";
|
|
3
|
+
import { perAlbumBudget } from "./TrackExpansionService.js";
|
|
4
|
+
import { RoonMcpError, } from "./types.js";
|
|
5
|
+
const DEFAULT_LIMIT = 10;
|
|
6
|
+
const MAX_LIMIT = 50;
|
|
7
|
+
const GROUP_SCAN_COUNT = 50;
|
|
8
|
+
// Ensures a candidate of the requested type always outranks any other type,
|
|
9
|
+
// so "exact type matches" sort before broader matches.
|
|
10
|
+
const TYPE_BOOST = 1.0;
|
|
11
|
+
// English Roon search category titles → our item type.
|
|
12
|
+
const GROUP_TITLE_TO_TYPE = {
|
|
13
|
+
artists: "artist",
|
|
14
|
+
albums: "album",
|
|
15
|
+
tracks: "track",
|
|
16
|
+
genres: "genre",
|
|
17
|
+
playlists: "playlist",
|
|
18
|
+
stations: "radio",
|
|
19
|
+
"internet radio": "radio",
|
|
20
|
+
};
|
|
21
|
+
function normalize(text) {
|
|
22
|
+
return text.trim().toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
/** Strip a trailing count, e.g. "Albums (12)" → "albums". */
|
|
25
|
+
function groupTitleToType(title) {
|
|
26
|
+
const cleaned = normalize(title).replace(/\s*\(\d+\)\s*$/, "");
|
|
27
|
+
return GROUP_TITLE_TO_TYPE[cleaned] ?? "unknown";
|
|
28
|
+
}
|
|
29
|
+
/** Turns a text query into ranked browse candidates via the search hierarchy. */
|
|
30
|
+
export class SearchService {
|
|
31
|
+
browse;
|
|
32
|
+
genres;
|
|
33
|
+
tracks;
|
|
34
|
+
constructor(browse, genres, tracks) {
|
|
35
|
+
this.browse = browse;
|
|
36
|
+
this.genres = genres;
|
|
37
|
+
this.tracks = tracks;
|
|
38
|
+
}
|
|
39
|
+
async searchMusic(input) {
|
|
40
|
+
const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
|
41
|
+
// Genres don't appear in Roon's flat search; resolve them via the dedicated
|
|
42
|
+
// `genres` hierarchy instead (GenreService), and never silently broaden to
|
|
43
|
+
// artists/albums — that hid the failure before.
|
|
44
|
+
if (input.type === "genre") {
|
|
45
|
+
return this.searchGenres(input.query, limit, input.includeStreaming);
|
|
46
|
+
}
|
|
47
|
+
// Search builds its own item keys inside performSearch, so a stale-session
|
|
48
|
+
// failure is recoverable with one reset-and-replay (see runExclusiveWithRetry).
|
|
49
|
+
return this.browse.runExclusiveWithRetry(() => this.performSearch(input, limit));
|
|
50
|
+
}
|
|
51
|
+
async searchGenres(query, limit, includeStreaming) {
|
|
52
|
+
const libraryGenres = await this.genres.searchGenres(query, limit);
|
|
53
|
+
let streamingTracks = [];
|
|
54
|
+
if (includeStreaming) {
|
|
55
|
+
streamingTracks = await this.collectStreamingGenreTracks(query, limit);
|
|
56
|
+
}
|
|
57
|
+
const candidates = [...libraryGenres, ...streamingTracks];
|
|
58
|
+
let message;
|
|
59
|
+
if (candidates.length === 0) {
|
|
60
|
+
message = `No results for "${query}".`;
|
|
61
|
+
}
|
|
62
|
+
else if (libraryGenres.length === 0) {
|
|
63
|
+
message = `No library genres matched "${query}"; showing streaming tracks.`;
|
|
64
|
+
}
|
|
65
|
+
else if ((libraryGenres[0]?.score ?? 0) < 1) {
|
|
66
|
+
const suffix = includeStreaming ? "; also showing streaming tracks" : "";
|
|
67
|
+
message = `No genre exactly named "${query}"; showing nearest genres${suffix}.`;
|
|
68
|
+
}
|
|
69
|
+
return { query, candidates, broadened: false, message };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Genre discovery beyond the library. Roon's flat search is text-based, not
|
|
73
|
+
* genre-filtered, so a raw "tracks" search for a genre name returns noise
|
|
74
|
+
* (tracks with the word in their title). Instead we take the genre-relevant
|
|
75
|
+
* albums the flat search surfaces (album/artist metadata makes those genuinely
|
|
76
|
+
* on-genre) and sample a few tracks across them — the same spread-across-albums
|
|
77
|
+
* approach get_tracks_for uses for library genres — yielding a real cross-album
|
|
78
|
+
* mix rather than whole albums. Each expansion re-navigates the flat search; an
|
|
79
|
+
* opt-in cost we accept for the streaming path.
|
|
80
|
+
*/
|
|
81
|
+
async collectStreamingGenreTracks(query, limit) {
|
|
82
|
+
const albumSearch = await this.browse.runExclusiveWithRetry(() => this.performSearch({ query, type: "album" }, limit));
|
|
83
|
+
const albums = albumSearch.candidates.filter((c) => c.type === "album");
|
|
84
|
+
if (albums.length === 0)
|
|
85
|
+
return [];
|
|
86
|
+
// Spread the budget so the mix draws from several albums, not just the first.
|
|
87
|
+
const perAlbum = perAlbumBudget(limit, albums.length);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const album of albums) {
|
|
90
|
+
if (out.length >= limit)
|
|
91
|
+
break;
|
|
92
|
+
const expanded = await this.tracks.getTracksFor({ itemKey: album.itemKey, limit: perAlbum });
|
|
93
|
+
for (const track of expanded.tracks) {
|
|
94
|
+
if (out.length >= limit)
|
|
95
|
+
break;
|
|
96
|
+
out.push({
|
|
97
|
+
itemKey: track.itemKey,
|
|
98
|
+
title: track.title,
|
|
99
|
+
subtitle: track.artist ?? album.title,
|
|
100
|
+
type: "track",
|
|
101
|
+
// Inherit the album's relevance so on-genre albums sort their tracks up.
|
|
102
|
+
score: album.score,
|
|
103
|
+
available: track.available,
|
|
104
|
+
sourceGroup: "Streaming",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
async performSearch(input, limit) {
|
|
111
|
+
// Roon only registers the search when `input` and `pop_all` ride in the
|
|
112
|
+
// same browse call; submitting `input` after a separate reset yields a
|
|
113
|
+
// "No Results" placeholder. Keep them together.
|
|
114
|
+
const submitted = await this.browse.browse({
|
|
115
|
+
hierarchy: SEARCH_HIERARCHY,
|
|
116
|
+
input: input.query,
|
|
117
|
+
pop_all: true,
|
|
118
|
+
});
|
|
119
|
+
if (submitted.action === "message") {
|
|
120
|
+
return {
|
|
121
|
+
query: input.query,
|
|
122
|
+
candidates: [],
|
|
123
|
+
broadened: false,
|
|
124
|
+
message: submitted.message ?? "Search returned no results.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const groupsLoad = await this.browse.load({
|
|
128
|
+
hierarchy: SEARCH_HIERARCHY,
|
|
129
|
+
offset: 0,
|
|
130
|
+
count: GROUP_SCAN_COUNT,
|
|
131
|
+
});
|
|
132
|
+
const groups = groupsLoad.items.filter(isSelectable);
|
|
133
|
+
if (groups.length === 0) {
|
|
134
|
+
return { query: input.query, candidates: [], broadened: false, message: "No results." };
|
|
135
|
+
}
|
|
136
|
+
let broadened = false;
|
|
137
|
+
let candidates = await this.collectFromGroups(input.query, groups, this.selectGroupIndices(groups, input.type), limit);
|
|
138
|
+
// If a typed search came back empty, broaden to all categories.
|
|
139
|
+
if (candidates.length === 0 && input.type) {
|
|
140
|
+
broadened = true;
|
|
141
|
+
candidates = await this.collectFromGroups(input.query, groups, groups.map((_, idx) => idx), limit);
|
|
142
|
+
}
|
|
143
|
+
const ranked = this.rankCandidates(candidates, input.query, input.type).slice(0, limit);
|
|
144
|
+
let message;
|
|
145
|
+
if (broadened) {
|
|
146
|
+
message = `No "${input.type}" matches; broadened to all categories.`;
|
|
147
|
+
}
|
|
148
|
+
else if (ranked.length === 0) {
|
|
149
|
+
message = "No results.";
|
|
150
|
+
}
|
|
151
|
+
return { query: input.query, candidates: ranked, broadened, message };
|
|
152
|
+
}
|
|
153
|
+
/** Indices (into the top-level group list) to scan for the requested type. */
|
|
154
|
+
selectGroupIndices(groups, type) {
|
|
155
|
+
const all = groups.map((_, idx) => idx);
|
|
156
|
+
if (!type)
|
|
157
|
+
return all;
|
|
158
|
+
return all.filter((idx) => groupTitleToType(groups[idx].title) === type);
|
|
159
|
+
}
|
|
160
|
+
async collectFromGroups(query, groups, indices, limit) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const g of indices) {
|
|
163
|
+
const group = groups[g];
|
|
164
|
+
const type = groupTitleToType(group.title);
|
|
165
|
+
try {
|
|
166
|
+
const nav = await this.browse.browse({ hierarchy: SEARCH_HIERARCHY, item_key: group.item_key });
|
|
167
|
+
// action:"none" means Roon didn't push a new level (e.g. a "No Results"
|
|
168
|
+
// placeholder item) — nothing to load and nothing to pop.
|
|
169
|
+
if (nav.action !== "list")
|
|
170
|
+
continue;
|
|
171
|
+
const loaded = await this.browse.load({
|
|
172
|
+
hierarchy: SEARCH_HIERARCHY,
|
|
173
|
+
offset: 0,
|
|
174
|
+
count: limit,
|
|
175
|
+
});
|
|
176
|
+
// Encode each candidate as a locator (query + group index + item index)
|
|
177
|
+
// rather than the raw Roon key: the raw key dies when we pop back below,
|
|
178
|
+
// whereas a locator lets playback re-navigate to a live key later. The
|
|
179
|
+
// item index is into the *selectable* children so it matches what
|
|
180
|
+
// SearchNavigator re-derives.
|
|
181
|
+
const children = loaded.items.filter(isSelectable);
|
|
182
|
+
children.forEach((item, i) => {
|
|
183
|
+
out.push({
|
|
184
|
+
itemKey: encodeLocator({ q: query, g, i }),
|
|
185
|
+
title: item.title,
|
|
186
|
+
subtitle: item.subtitle,
|
|
187
|
+
type,
|
|
188
|
+
score: 0,
|
|
189
|
+
available: true,
|
|
190
|
+
sourceGroup: group.title,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
// item_keys are level-scoped: pop back to the group list before the
|
|
194
|
+
// next group so its keys stay valid.
|
|
195
|
+
await this.browse.browse({ hierarchy: SEARCH_HIERARCHY, pop_levels: 1 });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
// A single bad group shouldn't sink the whole search.
|
|
199
|
+
if (err instanceof RoonMcpError && err.code === "INVALID_ITEM_KEY")
|
|
200
|
+
continue;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
rankCandidates(candidates, query, type) {
|
|
207
|
+
const q = normalize(query);
|
|
208
|
+
for (const c of candidates)
|
|
209
|
+
c.score = scoreCandidate(c, q, type);
|
|
210
|
+
return [...candidates].sort((a, b) => b.score - a.score);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function scoreCandidate(c, q, type) {
|
|
214
|
+
const title = normalize(c.title);
|
|
215
|
+
let score;
|
|
216
|
+
if (title === q)
|
|
217
|
+
score = 1.0;
|
|
218
|
+
else if (title.startsWith(q))
|
|
219
|
+
score = 0.7;
|
|
220
|
+
else if (title.includes(q))
|
|
221
|
+
score = 0.5;
|
|
222
|
+
else
|
|
223
|
+
score = tokenOverlap(title, q) * 0.4 + 0.05;
|
|
224
|
+
if (type && c.type === type)
|
|
225
|
+
score += TYPE_BOOST;
|
|
226
|
+
return Number(score.toFixed(4));
|
|
227
|
+
}
|
|
228
|
+
function tokenOverlap(title, query) {
|
|
229
|
+
const titleTokens = new Set(title.split(/\s+/).filter(Boolean));
|
|
230
|
+
const queryTokens = query.split(/\s+/).filter(Boolean);
|
|
231
|
+
if (queryTokens.length === 0)
|
|
232
|
+
return 0;
|
|
233
|
+
const hits = queryTokens.filter((t) => titleTokens.has(t)).length;
|
|
234
|
+
return hits / queryTokens.length;
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=SearchService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SearchService.js","sourceRoot":"","sources":["../src/SearchService.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAyB,MAAM,4BAA4B,CAAC;AACnF,OAAO,EACL,YAAY,GAKb,MAAM,YAAY,CAAC;AAEpB,MAAM,aAAa,GAAG,EAAE,CAAC;AACzB,MAAM,SAAS,GAAG,EAAE,CAAC;AACrB,MAAM,gBAAgB,GAAG,EAAE,CAAC;AAC5B,4EAA4E;AAC5E,uDAAuD;AACvD,MAAM,UAAU,GAAG,GAAG,CAAC;AAEvB,uDAAuD;AACvD,MAAM,mBAAmB,GAAkC;IACzD,OAAO,EAAE,QAAQ;IACjB,MAAM,EAAE,OAAO;IACf,MAAM,EAAE,OAAO;IACf,MAAM,EAAE,OAAO;IACf,SAAS,EAAE,UAAU;IACrB,QAAQ,EAAE,OAAO;IACjB,gBAAgB,EAAE,OAAO;CAC1B,CAAC;AAEF,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AACnC,CAAC;AAED,6DAA6D;AAC7D,SAAS,gBAAgB,CAAC,KAAa;IACrC,MAAM,OAAO,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAC;IAC/D,OAAO,mBAAmB,CAAC,OAAO,CAAC,IAAI,SAAS,CAAC;AACnD,CAAC;AAED,iFAAiF;AACjF,MAAM,OAAO,aAAa;IAEL;IACA;IACA;IAHnB,YACmB,MAA4B,EAC5B,MAAoB,EACpB,MAA6B;QAF7B,WAAM,GAAN,MAAM,CAAsB;QAC5B,WAAM,GAAN,MAAM,CAAc;QACpB,WAAM,GAAN,MAAM,CAAuB;IAC7C,CAAC;IAEJ,KAAK,CAAC,WAAW,CAAC,KAAuB;QACvC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,IAAI,aAAa,EAAE,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;QAE7E,4EAA4E;QAC5E,2EAA2E;QAC3E,gDAAgD;QAChD,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAC3B,OAAO,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;QACvE,CAAC;QAED,2EAA2E;QAC3E,gFAAgF;QAChF,OAAO,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;IACnF,CAAC;IAEO,KAAK,CAAC,YAAY,CACxB,KAAa,EACb,KAAa,EACb,gBAA0B;QAE1B,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAEnE,IAAI,eAAe,GAAqB,EAAE,CAAC;QAC3C,IAAI,gBAAgB,EAAE,CAAC;YACrB,eAAe,GAAG,MAAM,IAAI,CAAC,2BAA2B,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QACzE,CAAC;QAED,MAAM,UAAU,GAAG,CAAC,GAAG,aAAa,EAAE,GAAG,eAAe,CAAC,CAAC;QAE1D,IAAI,OAA2B,CAAC;QAChC,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC5B,OAAO,GAAG,mBAAmB,KAAK,IAAI,CAAC;QACzC,CAAC;aAAM,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACtC,OAAO,GAAG,8BAA8B,KAAK,8BAA8B,CAAC;QAC9E,CAAC;aAAM,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;YAC9C,MAAM,MAAM,GAAG,gBAAgB,CAAC,CAAC,CAAC,iCAAiC,CAAC,CAAC,CAAC,EAAE,CAAC;YACzE,OAAO,GAAG,2BAA2B,KAAK,4BAA4B,MAAM,GAAG,CAAC;QAClF,CAAC;QACD,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;IAC1D,CAAC;IAED;;;;;;;;;OASG;IACK,KAAK,CAAC,2BAA2B,CACvC,KAAa,EACb,KAAa;QAEb,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,GAAG,EAAE,CAC/D,IAAI,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,KAAK,CAAC,CACpD,CAAC;QACF,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC;QACxE,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAEnC,8EAA8E;QAC9E,MAAM,QAAQ,GAAG,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QAEtD,MAAM,GAAG,GAAqB,EAAE,CAAC;QACjC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,IAAI,GAAG,CAAC,MAAM,IAAI,KAAK;gBAAE,MAAM;YAC/B,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC;YAC7F,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;gBACpC,IAAI,GAAG,CAAC,MAAM,IAAI,KAAK;oBAAE,MAAM;gBAC/B,GAAG,CAAC,IAAI,CAAC;oBACP,OAAO,EAAE,KAAK,CAAC,OAAO;oBACtB,KAAK,EAAE,KAAK,CAAC,KAAK;oBAClB,QAAQ,EAAE,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,KAAK;oBACrC,IAAI,EAAE,OAAO;oBACb,yEAAyE;oBACzE,KAAK,EAAE,KAAK,CAAC,KAAK;oBAClB,SAAS,EAAE,KAAK,CAAC,SAAS;oBAC1B,WAAW,EAAE,WAAW;iBACzB,CAAC,CAAC;YACL,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAEO,KAAK,CAAC,aAAa,CACzB,KAAuB,EACvB,KAAa;QAEb,wEAAwE;QACxE,uEAAuE;QACvE,gDAAgD;QAChD,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;YACzC,SAAS,EAAE,gBAAgB;YAC3B,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,OAAO,EAAE,IAAI;SACd,CAAC,CAAC;QACH,IAAI,SAAS,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YACnC,OAAO;gBACL,KAAK,EAAE,KAAK,CAAC,KAAK;gBAClB,UAAU,EAAE,EAAE;gBACd,SAAS,EAAE,KAAK;gBAChB,OAAO,EAAE,SAAS,CAAC,OAAO,IAAI,6BAA6B;aAC5D,CAAC;QACJ,CAAC;QAED,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;YACxC,SAAS,EAAE,gBAAgB;YAC3B,MAAM,EAAE,CAAC;YACT,KAAK,EAAE,gBAAgB;SACxB,CAAC,CAAC;QACH,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;QACrD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;QAC1F,CAAC;QAED,IAAI,SAAS,GAAG,KAAK,CAAC;QACtB,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,iBAAiB,CAC3C,KAAK,CAAC,KAAK,EACX,MAAM,EACN,IAAI,CAAC,kBAAkB,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,EAC3C,KAAK,CACN,CAAC;QAEF,gEAAgE;QAChE,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;YAC1C,SAAS,GAAG,IAAI,CAAC;YACjB,UAAU,GAAG,MAAM,IAAI,CAAC,iBAAiB,CACvC,KAAK,CAAC,KAAK,EACX,MAAM,EACN,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAC3B,KAAK,CACN,CAAC;QACJ,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,cAAc,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;QAExF,IAAI,OAA2B,CAAC;QAChC,IAAI,SAAS,EAAE,CAAC;YACd,OAAO,GAAG,OAAO,KAAK,CAAC,IAAI,yCAAyC,CAAC;QACvE,CAAC;aAAM,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/B,OAAO,GAAG,aAAa,CAAC;QAC1B,CAAC;QAED,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;IACxE,CAAC;IAED,8EAA8E;IACtE,kBAAkB,CAAC,MAAoB,EAAE,IAAoB;QACnE,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,CAAC,IAAI;YAAE,OAAO,GAAG,CAAC;QACtB,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAE,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,CAAC;IAC5E,CAAC;IAEO,KAAK,CAAC,iBAAiB,CAC7B,KAAa,EACb,MAAoB,EACpB,OAAiB,EACjB,KAAa;QAEb,MAAM,GAAG,GAAqB,EAAE,CAAC;QACjC,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;YACxB,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC;YACzB,MAAM,IAAI,GAAG,gBAAgB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC3C,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;gBAChG,wEAAwE;gBACxE,0DAA0D;gBAC1D,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM;oBAAE,SAAS;gBACpC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;oBACpC,SAAS,EAAE,gBAAgB;oBAC3B,MAAM,EAAE,CAAC;oBACT,KAAK,EAAE,KAAK;iBACb,CAAC,CAAC;gBACH,wEAAwE;gBACxE,yEAAyE;gBACzE,uEAAuE;gBACvE,kEAAkE;gBAClE,8BAA8B;gBAC9B,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;gBACnD,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;oBAC3B,GAAG,CAAC,IAAI,CAAC;wBACP,OAAO,EAAE,aAAa,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;wBAC1C,KAAK,EAAE,IAAI,CAAC,KAAK;wBACjB,QAAQ,EAAE,IAAI,CAAC,QAAQ;wBACvB,IAAI;wBACJ,KAAK,EAAE,CAAC;wBACR,SAAS,EAAE,IAAI;wBACf,WAAW,EAAE,KAAK,CAAC,KAAK;qBACzB,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;gBACH,oEAAoE;gBACpE,qCAAqC;gBACrC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,gBAAgB,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;YAC3E,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,sDAAsD;gBACtD,IAAI,GAAG,YAAY,YAAY,IAAI,GAAG,CAAC,IAAI,KAAK,kBAAkB;oBAAE,SAAS;gBAC7E,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAEO,cAAc,CACpB,UAA4B,EAC5B,KAAa,EACb,IAAoB;QAEpB,MAAM,CAAC,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC;QAC3B,KAAK,MAAM,CAAC,IAAI,UAAU;YAAE,CAAC,CAAC,KAAK,GAAG,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;QACjE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IAC3D,CAAC;CACF;AAED,SAAS,cAAc,CAAC,CAAiB,EAAE,CAAS,EAAE,IAAoB;IACxE,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IACjC,IAAI,KAAa,CAAC;IAClB,IAAI,KAAK,KAAK,CAAC;QAAE,KAAK,GAAG,GAAG,CAAC;SACxB,IAAI,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,KAAK,GAAG,GAAG,CAAC;SACrC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;QAAE,KAAK,GAAG,GAAG,CAAC;;QACnC,KAAK,GAAG,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,GAAG,GAAG,IAAI,CAAC;IAEjD,IAAI,IAAI,IAAI,CAAC,CAAC,IAAI,KAAK,IAAI;QAAE,KAAK,IAAI,UAAU,CAAC;IACjD,OAAO,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,SAAS,YAAY,CAAC,KAAa,EAAE,KAAa;IAChD,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;IAChE,MAAM,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACvD,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IAClE,OAAO,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC;AACnC,CAAC"}
|