@azurestacknerd/roon-mcp 1.0.1
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/README.md +180 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +37 -0
- package/build/roon-connection.d.ts +21 -0
- package/build/roon-connection.js +202 -0
- package/build/tools/browse.d.ts +2 -0
- package/build/tools/browse.js +482 -0
- package/build/tools/playback.d.ts +2 -0
- package/build/tools/playback.js +166 -0
- package/build/tools/volume.d.ts +2 -0
- package/build/tools/volume.js +115 -0
- package/build/tools/zone.d.ts +2 -0
- package/build/tools/zone.js +153 -0
- package/package.json +40 -0
- package/scripts/patch-roon-api.js +45 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { roonConnection } from "../roon-connection.js";
|
|
3
|
+
let sessionCounter = 0;
|
|
4
|
+
function newSessionKey() {
|
|
5
|
+
return `mcp-${++sessionCounter}`;
|
|
6
|
+
}
|
|
7
|
+
function promisifyBrowse(browse, opts) {
|
|
8
|
+
return new Promise((resolve) => browse.browse(opts, (error, body) => resolve({ error, body })));
|
|
9
|
+
}
|
|
10
|
+
function promisifyLoad(browse, opts) {
|
|
11
|
+
return new Promise((resolve) => browse.load(opts, (error, body) => resolve({ error, body })));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Strip Roon's internal link format from text.
|
|
15
|
+
* Roon subtitles may contain `[[12345|Artist Name]]` — extract just the name.
|
|
16
|
+
*/
|
|
17
|
+
function stripRoonLinks(text) {
|
|
18
|
+
return text.replace(/\[\[\d+\|([^\]]+)\]\]/g, "$1");
|
|
19
|
+
}
|
|
20
|
+
function formatItems(items) {
|
|
21
|
+
return items
|
|
22
|
+
.filter((item) => item.hint !== "header")
|
|
23
|
+
.map((item, i) => {
|
|
24
|
+
const sub = item.subtitle ? ` - ${stripRoonLinks(item.subtitle)}` : "";
|
|
25
|
+
return `${i + 1}. ${item.title}${sub}`;
|
|
26
|
+
})
|
|
27
|
+
.join("\n");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Helper: browse + load in one step.
|
|
31
|
+
* Calls browse, then if action is "list", loads items.
|
|
32
|
+
* Returns `navigated: true` if the browse stack changed (and you need pop_levels to go back).
|
|
33
|
+
*/
|
|
34
|
+
async function browseAndLoad(browse, browseOpts, loadCount = 100) {
|
|
35
|
+
const result = await promisifyBrowse(browse, browseOpts);
|
|
36
|
+
if (result.error) {
|
|
37
|
+
return { error: String(result.error), navigated: false };
|
|
38
|
+
}
|
|
39
|
+
if (result.body.action === "message") {
|
|
40
|
+
return { error: null, navigated: false, message: result.body.message || "Done" };
|
|
41
|
+
}
|
|
42
|
+
if (result.body.action !== "list" || !result.body.list) {
|
|
43
|
+
return { error: null, navigated: false, list: result.body.list, items: [] };
|
|
44
|
+
}
|
|
45
|
+
// Browse succeeded with a list → we navigated deeper
|
|
46
|
+
const loaded = await promisifyLoad(browse, {
|
|
47
|
+
hierarchy: browseOpts.hierarchy,
|
|
48
|
+
multi_session_key: browseOpts.multi_session_key,
|
|
49
|
+
count: loadCount,
|
|
50
|
+
});
|
|
51
|
+
if (loaded.error) {
|
|
52
|
+
return { error: String(loaded.error), navigated: true };
|
|
53
|
+
}
|
|
54
|
+
return { error: null, navigated: true, list: result.body.list, items: loaded.body.items };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Find the best matching item from browse results.
|
|
58
|
+
* Scores each item based on title + subtitle (artist) match against the query.
|
|
59
|
+
* On equal scores, Roon's relevance ordering wins (first item).
|
|
60
|
+
*/
|
|
61
|
+
function bestMatch(items, query) {
|
|
62
|
+
const playable = items.filter((item) => item.item_key && item.hint !== "header");
|
|
63
|
+
if (!playable.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
const lower = query.toLowerCase().trim();
|
|
66
|
+
const queryWords = lower.split(/\s+/).filter((w) => w.length > 1);
|
|
67
|
+
let topScore = -Infinity;
|
|
68
|
+
let topItem = playable[0];
|
|
69
|
+
for (let i = 0; i < playable.length; i++) {
|
|
70
|
+
const item = playable[i];
|
|
71
|
+
const titleLower = item.title.toLowerCase().trim();
|
|
72
|
+
const subtitleLower = stripRoonLinks(item.subtitle || "").toLowerCase();
|
|
73
|
+
let score = 0;
|
|
74
|
+
// Word matching in title (high value)
|
|
75
|
+
for (const word of queryWords) {
|
|
76
|
+
if (titleLower.includes(word))
|
|
77
|
+
score += 10;
|
|
78
|
+
}
|
|
79
|
+
// Word matching in subtitle (artist disambiguation)
|
|
80
|
+
for (const word of queryWords) {
|
|
81
|
+
if (subtitleLower.includes(word))
|
|
82
|
+
score += 5;
|
|
83
|
+
}
|
|
84
|
+
// Primary artist bonus: first credited artist in subtitle is a strong
|
|
85
|
+
// signal for the original version vs covers/remixes/tributes
|
|
86
|
+
const firstArtist = subtitleLower.split(",")[0].trim();
|
|
87
|
+
for (const word of queryWords) {
|
|
88
|
+
if (word.length > 2 && firstArtist.includes(word))
|
|
89
|
+
score += 8;
|
|
90
|
+
}
|
|
91
|
+
// Penalty for tributes, covers, karaoke, medleys — these are almost never
|
|
92
|
+
// what the user wants when searching for a specific artist/album/track
|
|
93
|
+
if (/\b(tribute|cover[s]?|karaoke|medley|in the style of)\b/i.test(titleLower)) {
|
|
94
|
+
score -= 50;
|
|
95
|
+
}
|
|
96
|
+
// Position bonus: Roon returns results in relevance order, so earlier
|
|
97
|
+
// items are more likely to be what the user wants. This also breaks ties
|
|
98
|
+
// in favor of Roon's ranking.
|
|
99
|
+
score += Math.max(0, 5 - i);
|
|
100
|
+
if (score > topScore) {
|
|
101
|
+
topScore = score;
|
|
102
|
+
topItem = item;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return topItem;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Find an action item (Play Now, Queue, etc.) from an action list.
|
|
109
|
+
* Does NOT require hint === "action" — Roon may return actions with various hint values.
|
|
110
|
+
*/
|
|
111
|
+
function findAction(items, type) {
|
|
112
|
+
const actionable = items.filter((item) => item.item_key && item.hint !== "header");
|
|
113
|
+
if (type === "play") {
|
|
114
|
+
return (actionable.find((item) => item.title.trim().toLowerCase() === "play now") ||
|
|
115
|
+
actionable.find((item) => item.title.trim().toLowerCase() === "play album") ||
|
|
116
|
+
actionable.find((item) => item.title.toLowerCase().startsWith("play") &&
|
|
117
|
+
!item.title.toLowerCase().includes("radio")) ||
|
|
118
|
+
actionable[0]);
|
|
119
|
+
}
|
|
120
|
+
// queue: prefer Queue, then Play Album (sub-menu containing Queue), then Play Now
|
|
121
|
+
return (actionable.find((item) => item.title.trim().toLowerCase() === "queue") ||
|
|
122
|
+
actionable.find((item) => item.title.toLowerCase().includes("queue")) ||
|
|
123
|
+
actionable.find((item) => item.title.trim().toLowerCase() === "play album") ||
|
|
124
|
+
actionable.find((item) => item.title.toLowerCase().startsWith("play") &&
|
|
125
|
+
!item.title.toLowerCase().includes("radio")) ||
|
|
126
|
+
actionable.find((item) => item.title.trim().toLowerCase() === "play now") ||
|
|
127
|
+
actionable[0]);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Search for something and play/queue the best match.
|
|
131
|
+
*
|
|
132
|
+
* Flow through Roon browse hierarchy:
|
|
133
|
+
* 1. Search → get categories (Artists, Albums, Tracks, Playlists...)
|
|
134
|
+
* 2. Pick the matching category → get items in that category
|
|
135
|
+
* 3. Pick the best matching item → get action list (Play Now, Queue, etc.)
|
|
136
|
+
* 4. Pick the action → starts playback / queues
|
|
137
|
+
* 5. Disable auto_radio to prevent radio mode after album/track ends
|
|
138
|
+
*
|
|
139
|
+
* All steps use the SAME multi_session_key to maintain browse state.
|
|
140
|
+
*/
|
|
141
|
+
async function searchAndPlay(query, zoneName, category, actionType = "play") {
|
|
142
|
+
try {
|
|
143
|
+
const browse = roonConnection.getBrowse();
|
|
144
|
+
const zone = roonConnection.findZoneOrThrow(zoneName);
|
|
145
|
+
const sessionKey = newSessionKey();
|
|
146
|
+
const hierarchy = "search";
|
|
147
|
+
const log = (step, data) => console.error(`[roon-mcp] searchAndPlay[${sessionKey}] ${step}:`, JSON.stringify(data, null, 2));
|
|
148
|
+
log("start", { query, zoneName: zone.display_name, zoneId: zone.zone_id, category, actionType });
|
|
149
|
+
// Step 1: Start search
|
|
150
|
+
const searchData = await browseAndLoad(browse, {
|
|
151
|
+
hierarchy,
|
|
152
|
+
input: query,
|
|
153
|
+
pop_all: true,
|
|
154
|
+
zone_or_output_id: zone.zone_id,
|
|
155
|
+
multi_session_key: sessionKey,
|
|
156
|
+
});
|
|
157
|
+
log("step1-search", {
|
|
158
|
+
error: searchData.error,
|
|
159
|
+
navigated: searchData.navigated,
|
|
160
|
+
itemCount: searchData.items?.length,
|
|
161
|
+
items: searchData.items?.map((i) => ({ title: i.title, hint: i.hint, item_key: i.item_key })),
|
|
162
|
+
});
|
|
163
|
+
if (searchData.error) {
|
|
164
|
+
return { content: [{ type: "text", text: `Search error: ${searchData.error}` }], isError: true };
|
|
165
|
+
}
|
|
166
|
+
if (!searchData.items?.length) {
|
|
167
|
+
return { content: [{ type: "text", text: `No results found for "${query}".` }] };
|
|
168
|
+
}
|
|
169
|
+
// Step 2: Find the right category
|
|
170
|
+
const categories = searchData.items;
|
|
171
|
+
let targetCategory;
|
|
172
|
+
if (category) {
|
|
173
|
+
const catLower = category.toLowerCase();
|
|
174
|
+
// Try exact match first (e.g. "Artists", "Albums", "Tracks")
|
|
175
|
+
targetCategory = categories.find((item) => item.item_key &&
|
|
176
|
+
(item.title.toLowerCase() === catLower + "s" || item.title.toLowerCase() === catLower));
|
|
177
|
+
// Then try partial match
|
|
178
|
+
if (!targetCategory) {
|
|
179
|
+
targetCategory = categories.find((item) => item.item_key &&
|
|
180
|
+
item.title.toLowerCase().includes(catLower) &&
|
|
181
|
+
item.hint !== "header");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Fallback: first selectable item
|
|
185
|
+
if (!targetCategory) {
|
|
186
|
+
targetCategory = categories.find((item) => item.item_key && item.hint !== "header");
|
|
187
|
+
}
|
|
188
|
+
log("step2-category", { selected: targetCategory?.title, hint: targetCategory?.hint, item_key: targetCategory?.item_key });
|
|
189
|
+
if (!targetCategory?.item_key) {
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: "text", text: `Search results for "${query}":\n${formatItems(categories)}\n\nNo playable category found.` }],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// Step 3: Drill into category to get items
|
|
195
|
+
const categoryData = await browseAndLoad(browse, {
|
|
196
|
+
hierarchy,
|
|
197
|
+
item_key: targetCategory.item_key,
|
|
198
|
+
zone_or_output_id: zone.zone_id,
|
|
199
|
+
multi_session_key: sessionKey,
|
|
200
|
+
});
|
|
201
|
+
log("step3-categoryItems", {
|
|
202
|
+
error: categoryData.error,
|
|
203
|
+
navigated: categoryData.navigated,
|
|
204
|
+
listTitle: categoryData.list?.title,
|
|
205
|
+
listCount: categoryData.list?.count,
|
|
206
|
+
itemCount: categoryData.items?.length,
|
|
207
|
+
items: categoryData.items?.slice(0, 10).map((i) => ({ title: i.title, subtitle: i.subtitle, hint: i.hint, item_key: i.item_key })),
|
|
208
|
+
});
|
|
209
|
+
if (categoryData.error) {
|
|
210
|
+
return { content: [{ type: "text", text: `Error browsing ${targetCategory.title}: ${categoryData.error}` }], isError: true };
|
|
211
|
+
}
|
|
212
|
+
if (!categoryData.items?.length) {
|
|
213
|
+
return { content: [{ type: "text", text: `No ${targetCategory.title.toLowerCase()} found for "${query}".` }] };
|
|
214
|
+
}
|
|
215
|
+
// Step 4: Select best matching result (not just first)
|
|
216
|
+
const matchedResult = bestMatch(categoryData.items, query);
|
|
217
|
+
log("step4-bestMatch", { selected: matchedResult?.title, subtitle: matchedResult?.subtitle, hint: matchedResult?.hint, item_key: matchedResult?.item_key });
|
|
218
|
+
if (!matchedResult?.item_key) {
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: "text", text: `${targetCategory.title} for "${query}":\n${formatItems(categoryData.items)}\n\nNo playable item found.` }],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// Step 5: Select it to get action list
|
|
224
|
+
const actionData = await browseAndLoad(browse, {
|
|
225
|
+
hierarchy,
|
|
226
|
+
item_key: matchedResult.item_key,
|
|
227
|
+
zone_or_output_id: zone.zone_id,
|
|
228
|
+
multi_session_key: sessionKey,
|
|
229
|
+
});
|
|
230
|
+
log("step5-actionList", {
|
|
231
|
+
error: actionData.error,
|
|
232
|
+
navigated: actionData.navigated,
|
|
233
|
+
message: actionData.message,
|
|
234
|
+
listTitle: actionData.list?.title,
|
|
235
|
+
listHint: actionData.list?.hint,
|
|
236
|
+
itemCount: actionData.items?.length,
|
|
237
|
+
items: actionData.items?.map((i) => ({ title: i.title, hint: i.hint, item_key: i.item_key })),
|
|
238
|
+
});
|
|
239
|
+
// Some items might directly trigger playback (action = "message" or "none")
|
|
240
|
+
if (actionData.message) {
|
|
241
|
+
return {
|
|
242
|
+
content: [{ type: "text", text: `${actionData.message} ("${matchedResult.title}" in zone '${zone.display_name}')` }],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (actionData.error) {
|
|
246
|
+
return { content: [{ type: "text", text: `Error: ${actionData.error}` }], isError: true };
|
|
247
|
+
}
|
|
248
|
+
if (!actionData.items?.length) {
|
|
249
|
+
return { content: [{ type: "text", text: `No actions available for "${matchedResult.title}".` }], isError: true };
|
|
250
|
+
}
|
|
251
|
+
// Step 5b: Navigate deeper if items are intermediate (hint: "action_list" or "list"),
|
|
252
|
+
// not actual actions (hint: "action"). Roon may have multiple levels before the action list.
|
|
253
|
+
//
|
|
254
|
+
// Key distinction:
|
|
255
|
+
// - Single intermediate item (e.g., a track in album context) → drill deeper to reach actions
|
|
256
|
+
// - Multiple items (e.g., album tracklist, discography) → this IS the content, don't drill
|
|
257
|
+
// - list.hint === "action_list" → items are the action list, don't drill
|
|
258
|
+
let actionItems = actionData.items;
|
|
259
|
+
let currentListHint = actionData.list?.hint;
|
|
260
|
+
const MAX_NAV_DEPTH = 3;
|
|
261
|
+
for (let depth = 0; depth < MAX_NAV_DEPTH; depth++) {
|
|
262
|
+
// If the list itself is marked as an action list, items are actions
|
|
263
|
+
if (currentListHint === "action_list")
|
|
264
|
+
break;
|
|
265
|
+
// If items include action-hinted items, we're at the action list
|
|
266
|
+
const hasActions = actionItems.some((item) => item.hint === "action");
|
|
267
|
+
if (hasActions)
|
|
268
|
+
break;
|
|
269
|
+
// Check if items are navigable (action_list or list hints that need drilling)
|
|
270
|
+
const navigable = actionItems.filter((item) => item.item_key && (item.hint === "action_list" || item.hint === "list"));
|
|
271
|
+
if (!navigable.length)
|
|
272
|
+
break;
|
|
273
|
+
// Multiple navigable items = content list (album tracklist, artist albums, etc.)
|
|
274
|
+
// Don't drill into individual items — we'd lose the parent-level context.
|
|
275
|
+
if (navigable.length > 1) {
|
|
276
|
+
log(`step5-skip-drill`, { reason: "multiple navigable items (content list)", count: navigable.length });
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
// Single navigable item → likely an intermediate navigation level, go deeper
|
|
280
|
+
const nextItem = navigable[0];
|
|
281
|
+
log(`step5-deeper-${depth}`, { title: nextItem?.title, hint: nextItem?.hint, item_key: nextItem?.item_key });
|
|
282
|
+
const deeper = await browseAndLoad(browse, {
|
|
283
|
+
hierarchy,
|
|
284
|
+
item_key: nextItem.item_key,
|
|
285
|
+
zone_or_output_id: zone.zone_id,
|
|
286
|
+
multi_session_key: sessionKey,
|
|
287
|
+
});
|
|
288
|
+
log(`step5-deeper-${depth}-result`, {
|
|
289
|
+
error: deeper.error,
|
|
290
|
+
navigated: deeper.navigated,
|
|
291
|
+
message: deeper.message,
|
|
292
|
+
listHint: deeper.list?.hint,
|
|
293
|
+
itemCount: deeper.items?.length,
|
|
294
|
+
items: deeper.items?.map((i) => ({ title: i.title, hint: i.hint, item_key: i.item_key })),
|
|
295
|
+
});
|
|
296
|
+
if (deeper.message) {
|
|
297
|
+
// Action was triggered directly (e.g., playback started)
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text", text: `${deeper.message} ("${matchedResult.title}" in zone '${zone.display_name}')` }],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (deeper.error || !deeper.items?.length)
|
|
303
|
+
break;
|
|
304
|
+
actionItems = deeper.items;
|
|
305
|
+
currentListHint = deeper.list?.hint;
|
|
306
|
+
}
|
|
307
|
+
// Step 6: Find and execute the right action (Play Now / Queue)
|
|
308
|
+
const targetAction = findAction(actionItems, actionType);
|
|
309
|
+
log("step6-action", { actionType, selected: targetAction?.title, hint: targetAction?.hint, item_key: targetAction?.item_key });
|
|
310
|
+
if (!targetAction?.item_key) {
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: "text", text: `Available actions for "${matchedResult.title}":\n${formatItems(actionItems)}\n\nNo "${actionType}" action found.` }],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// Step 7: Execute
|
|
316
|
+
let playResult = await promisifyBrowse(browse, {
|
|
317
|
+
hierarchy,
|
|
318
|
+
item_key: targetAction.item_key,
|
|
319
|
+
zone_or_output_id: zone.zone_id,
|
|
320
|
+
multi_session_key: sessionKey,
|
|
321
|
+
});
|
|
322
|
+
log("step7-execute", {
|
|
323
|
+
error: playResult.error,
|
|
324
|
+
action: playResult.body.action,
|
|
325
|
+
message: playResult.body.message,
|
|
326
|
+
is_error: playResult.body.is_error,
|
|
327
|
+
item: playResult.body.item,
|
|
328
|
+
list: playResult.body.list,
|
|
329
|
+
});
|
|
330
|
+
if (playResult.error) {
|
|
331
|
+
return { content: [{ type: "text", text: `Error: ${playResult.error}` }], isError: true };
|
|
332
|
+
}
|
|
333
|
+
// Step 7b: If the action opened a sub-menu (e.g., "Play Album" → Play Now/Queue/Start Radio),
|
|
334
|
+
// load that sub-menu and find+execute the actual target action inside it.
|
|
335
|
+
if (playResult.body.action === "list" && playResult.body.list) {
|
|
336
|
+
const subItems = await promisifyLoad(browse, {
|
|
337
|
+
hierarchy,
|
|
338
|
+
multi_session_key: sessionKey,
|
|
339
|
+
count: 20,
|
|
340
|
+
});
|
|
341
|
+
if (!subItems.error && subItems.body.items?.length) {
|
|
342
|
+
log("step7-submenu", {
|
|
343
|
+
listTitle: playResult.body.list.title,
|
|
344
|
+
items: subItems.body.items.map((i) => ({ title: i.title, hint: i.hint, item_key: i.item_key })),
|
|
345
|
+
});
|
|
346
|
+
const subAction = findAction(subItems.body.items, actionType);
|
|
347
|
+
if (subAction?.item_key) {
|
|
348
|
+
log("step7-submenu-action", { selected: subAction.title, hint: subAction.hint });
|
|
349
|
+
playResult = await promisifyBrowse(browse, {
|
|
350
|
+
hierarchy,
|
|
351
|
+
item_key: subAction.item_key,
|
|
352
|
+
zone_or_output_id: zone.zone_id,
|
|
353
|
+
multi_session_key: sessionKey,
|
|
354
|
+
});
|
|
355
|
+
log("step7-submenu-execute", {
|
|
356
|
+
error: playResult.error,
|
|
357
|
+
action: playResult.body.action,
|
|
358
|
+
message: playResult.body.message,
|
|
359
|
+
});
|
|
360
|
+
if (playResult.error) {
|
|
361
|
+
return { content: [{ type: "text", text: `Error: ${playResult.error}` }], isError: true };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Step 8: Disable auto_radio to prevent Roon Radio from taking over
|
|
367
|
+
try {
|
|
368
|
+
const transport = roonConnection.getTransport();
|
|
369
|
+
await new Promise((resolve) => {
|
|
370
|
+
transport.change_settings(zone, { auto_radio: false }, () => resolve());
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Non-critical: if auto_radio disable fails, playback still works
|
|
375
|
+
}
|
|
376
|
+
const subtitle = matchedResult.subtitle ? ` by ${stripRoonLinks(matchedResult.subtitle)}` : "";
|
|
377
|
+
const actionVerb = actionType === "queue" ? "Queued" : "Now playing";
|
|
378
|
+
return {
|
|
379
|
+
content: [{ type: "text", text: `${actionVerb}: "${matchedResult.title}"${subtitle} in zone '${zone.display_name}'.` }],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
|
|
385
|
+
isError: true,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
export function registerBrowseTools(server) {
|
|
390
|
+
server.tool("search", "Search the Roon music library. Returns matching artists, albums, tracks, playlists, etc.", {
|
|
391
|
+
query: z.string().describe("Search query (artist name, album title, track name, etc.)"),
|
|
392
|
+
zone: z.string().optional().describe("Zone name or ID (optional, provides playback context)"),
|
|
393
|
+
}, async ({ query, zone }) => {
|
|
394
|
+
try {
|
|
395
|
+
const browse = roonConnection.getBrowse();
|
|
396
|
+
const zoneObj = zone ? roonConnection.findZoneOrThrow(zone) : null;
|
|
397
|
+
const sessionKey = newSessionKey();
|
|
398
|
+
const hierarchy = "search";
|
|
399
|
+
// Start search
|
|
400
|
+
const searchData = await browseAndLoad(browse, {
|
|
401
|
+
hierarchy,
|
|
402
|
+
input: query,
|
|
403
|
+
pop_all: true,
|
|
404
|
+
zone_or_output_id: zoneObj?.zone_id,
|
|
405
|
+
multi_session_key: sessionKey,
|
|
406
|
+
});
|
|
407
|
+
if (searchData.error) {
|
|
408
|
+
return { content: [{ type: "text", text: `Search error: ${searchData.error}` }], isError: true };
|
|
409
|
+
}
|
|
410
|
+
if (!searchData.items?.length) {
|
|
411
|
+
return { content: [{ type: "text", text: `No results for "${query}".` }] };
|
|
412
|
+
}
|
|
413
|
+
// For each category, drill in and show top results
|
|
414
|
+
const allResults = [`Search results for "${query}":`];
|
|
415
|
+
for (const cat of searchData.items) {
|
|
416
|
+
if (!cat.item_key || cat.hint === "header")
|
|
417
|
+
continue;
|
|
418
|
+
const catData = await browseAndLoad(browse, {
|
|
419
|
+
hierarchy,
|
|
420
|
+
item_key: cat.item_key,
|
|
421
|
+
zone_or_output_id: zoneObj?.zone_id,
|
|
422
|
+
multi_session_key: sessionKey,
|
|
423
|
+
}, 5);
|
|
424
|
+
if (catData.error || !catData.items?.length) {
|
|
425
|
+
if (catData.navigated) {
|
|
426
|
+
await promisifyBrowse(browse, { hierarchy, pop_levels: 1, multi_session_key: sessionKey });
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const count = catData.list?.count || catData.items.length;
|
|
431
|
+
allResults.push(`\n${catData.list?.title || cat.title} (${count}):`);
|
|
432
|
+
for (const item of catData.items) {
|
|
433
|
+
if (item.hint === "header")
|
|
434
|
+
continue;
|
|
435
|
+
const sub = item.subtitle ? ` - ${stripRoonLinks(item.subtitle)}` : "";
|
|
436
|
+
allResults.push(` - ${item.title}${sub}`);
|
|
437
|
+
}
|
|
438
|
+
// Pop back to category list
|
|
439
|
+
if (catData.navigated) {
|
|
440
|
+
await promisifyBrowse(browse, { hierarchy, pop_levels: 1, multi_session_key: sessionKey });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (allResults.length <= 1) {
|
|
444
|
+
return { content: [{ type: "text", text: `No results for "${query}".` }] };
|
|
445
|
+
}
|
|
446
|
+
if (zone) {
|
|
447
|
+
allResults.push(`\nUse play_artist, play_album, play_playlist, or play_track to play a result.`);
|
|
448
|
+
}
|
|
449
|
+
return { content: [{ type: "text", text: allResults.join("\n") }] };
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
return {
|
|
453
|
+
content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
|
|
454
|
+
isError: true,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
server.tool("play_artist", "Search for an artist and start playing their music in a Roon zone", {
|
|
459
|
+
artist: z.string().describe("Artist name to search for"),
|
|
460
|
+
zone: z.string().describe("Zone name or ID to play in"),
|
|
461
|
+
}, async ({ artist, zone }) => searchAndPlay(artist, zone, "artist"));
|
|
462
|
+
server.tool("play_album", "Search for an album and start playing it in a Roon zone", {
|
|
463
|
+
album: z.string().describe("Album name to search for"),
|
|
464
|
+
zone: z.string().describe("Zone name or ID to play in"),
|
|
465
|
+
}, async ({ album, zone }) => searchAndPlay(album, zone, "album"));
|
|
466
|
+
server.tool("play_playlist", "Search for a playlist and start playing it in a Roon zone", {
|
|
467
|
+
playlist: z.string().describe("Playlist name to search for"),
|
|
468
|
+
zone: z.string().describe("Zone name or ID to play in"),
|
|
469
|
+
}, async ({ playlist, zone }) => searchAndPlay(playlist, zone, "playlist"));
|
|
470
|
+
server.tool("play_track", "Search for a specific track/song and start playing it in a Roon zone", {
|
|
471
|
+
track: z.string().describe("Track/song name to search for"),
|
|
472
|
+
zone: z.string().describe("Zone name or ID to play in"),
|
|
473
|
+
}, async ({ track, zone }) => searchAndPlay(track, zone, "track"));
|
|
474
|
+
server.tool("add_to_queue", "Search for a track, album, artist, or playlist and add it to the queue in a Roon zone", {
|
|
475
|
+
query: z.string().describe("Search query (track name, album title, artist name, etc.)"),
|
|
476
|
+
zone: z.string().describe("Zone name or ID to queue in"),
|
|
477
|
+
category: z
|
|
478
|
+
.enum(["track", "album", "artist", "playlist"])
|
|
479
|
+
.optional()
|
|
480
|
+
.describe("Category to search in (optional, auto-detects if not specified)"),
|
|
481
|
+
}, async ({ query, zone, category }) => searchAndPlay(query, zone, category, "queue"));
|
|
482
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { roonConnection } from "../roon-connection.js";
|
|
3
|
+
export function registerPlaybackTools(server) {
|
|
4
|
+
server.tool("play", "Start playback in a Roon zone", {
|
|
5
|
+
zone: z.string().describe("Zone name or ID to start playing"),
|
|
6
|
+
}, async ({ zone }) => transportControl(zone, "play"));
|
|
7
|
+
server.tool("pause", "Pause playback in a Roon zone", {
|
|
8
|
+
zone: z.string().describe("Zone name or ID to pause"),
|
|
9
|
+
}, async ({ zone }) => transportControl(zone, "pause"));
|
|
10
|
+
server.tool("play_pause", "Toggle play/pause in a Roon zone", {
|
|
11
|
+
zone: z.string().describe("Zone name or ID to toggle"),
|
|
12
|
+
}, async ({ zone }) => transportControl(zone, "playpause"));
|
|
13
|
+
server.tool("stop", "Stop playback in a Roon zone and release the audio device", {
|
|
14
|
+
zone: z.string().describe("Zone name or ID to stop"),
|
|
15
|
+
}, async ({ zone }) => transportControl(zone, "stop"));
|
|
16
|
+
server.tool("next_track", "Skip to the next track in a Roon zone", {
|
|
17
|
+
zone: z.string().describe("Zone name or ID"),
|
|
18
|
+
}, async ({ zone }) => transportControl(zone, "next"));
|
|
19
|
+
server.tool("previous_track", "Go to the previous track (or start of current track) in a Roon zone", {
|
|
20
|
+
zone: z.string().describe("Zone name or ID"),
|
|
21
|
+
}, async ({ zone }) => transportControl(zone, "previous"));
|
|
22
|
+
server.tool("seek", "Seek to a position within the currently playing track in a Roon zone", {
|
|
23
|
+
zone: z.string().describe("Zone name or ID"),
|
|
24
|
+
seconds: z.number().describe("Target position in seconds"),
|
|
25
|
+
relative: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.default(false)
|
|
28
|
+
.describe("If true, seek relative to current position (positive = forward, negative = backward). If false, seek to absolute position."),
|
|
29
|
+
}, async ({ zone: zoneName, seconds, relative }) => {
|
|
30
|
+
try {
|
|
31
|
+
const transport = roonConnection.getTransport();
|
|
32
|
+
const zone = roonConnection.findZoneOrThrow(zoneName);
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
transport.seek(zone, relative ? "relative" : "absolute", seconds, (error) => {
|
|
35
|
+
if (error) {
|
|
36
|
+
resolve({ content: [{ type: "text", text: `Error: ${error}` }], isError: true });
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const desc = relative
|
|
40
|
+
? `Seeked ${seconds > 0 ? "forward" : "backward"} ${Math.abs(seconds)}s`
|
|
41
|
+
: `Seeked to ${formatSeekTime(seconds)}`;
|
|
42
|
+
resolve({
|
|
43
|
+
content: [{ type: "text", text: `${desc} in zone '${zone.display_name}'.` }],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
|
|
52
|
+
isError: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
server.tool("shuffle", "Enable or disable shuffle mode in a Roon zone", {
|
|
57
|
+
zone: z.string().describe("Zone name or ID"),
|
|
58
|
+
enabled: z.boolean().describe("true to enable shuffle, false to disable"),
|
|
59
|
+
}, async ({ zone: zoneName, enabled }) => {
|
|
60
|
+
try {
|
|
61
|
+
const transport = roonConnection.getTransport();
|
|
62
|
+
const zone = roonConnection.findZoneOrThrow(zoneName);
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
transport.change_settings(zone, { shuffle: enabled }, (error) => {
|
|
65
|
+
if (error) {
|
|
66
|
+
resolve({ content: [{ type: "text", text: `Error: ${error}` }], isError: true });
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
resolve({
|
|
70
|
+
content: [{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: `Shuffle ${enabled ? "enabled" : "disabled"} in zone '${zone.display_name}'.`,
|
|
73
|
+
}],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
server.tool("loop", "Set the loop mode in a Roon zone", {
|
|
87
|
+
zone: z.string().describe("Zone name or ID"),
|
|
88
|
+
mode: z
|
|
89
|
+
.enum(["loop", "loop_one", "disabled", "next"])
|
|
90
|
+
.describe("Loop mode: 'loop' for all, 'loop_one' for single track, 'disabled' to turn off, 'next' to cycle through modes"),
|
|
91
|
+
}, async ({ zone: zoneName, mode }) => {
|
|
92
|
+
try {
|
|
93
|
+
const transport = roonConnection.getTransport();
|
|
94
|
+
const zone = roonConnection.findZoneOrThrow(zoneName);
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
transport.change_settings(zone, { loop: mode }, (error) => {
|
|
97
|
+
if (error) {
|
|
98
|
+
resolve({ content: [{ type: "text", text: `Error: ${error}` }], isError: true });
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const modeMap = {
|
|
102
|
+
loop: "Loop all",
|
|
103
|
+
loop_one: "Loop one",
|
|
104
|
+
disabled: "Loop off",
|
|
105
|
+
next: "Cycled to next loop mode",
|
|
106
|
+
};
|
|
107
|
+
resolve({
|
|
108
|
+
content: [{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: `${modeMap[mode]} in zone '${zone.display_name}'.`,
|
|
111
|
+
}],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function formatSeekTime(seconds) {
|
|
126
|
+
const mins = Math.floor(seconds / 60);
|
|
127
|
+
const secs = Math.floor(seconds % 60);
|
|
128
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
129
|
+
}
|
|
130
|
+
async function transportControl(zoneName, control) {
|
|
131
|
+
try {
|
|
132
|
+
const transport = roonConnection.getTransport();
|
|
133
|
+
const zone = roonConnection.findZoneOrThrow(zoneName);
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
transport.control(zone, control, (error) => {
|
|
136
|
+
if (error) {
|
|
137
|
+
resolve({
|
|
138
|
+
content: [{ type: "text", text: `Error: ${error}` }],
|
|
139
|
+
isError: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const actionMap = {
|
|
144
|
+
play: "Playing",
|
|
145
|
+
pause: "Paused",
|
|
146
|
+
playpause: "Toggled play/pause",
|
|
147
|
+
stop: "Stopped",
|
|
148
|
+
next: "Skipped to next track",
|
|
149
|
+
previous: "Went to previous track",
|
|
150
|
+
};
|
|
151
|
+
resolve({
|
|
152
|
+
content: [
|
|
153
|
+
{ type: "text", text: `${actionMap[control]} in zone '${zone.display_name}'.` },
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
|
|
163
|
+
isError: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|