deep-roon-mcp 0.4.0 → 0.4.5
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 +3 -2
- package/dist/deep-roon-mcp.js +218 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ server of its own and sends nothing anywhere else. What you ask goes to your MCP
|
|
|
22
22
|
|
|
23
23
|
## Status
|
|
24
24
|
|
|
25
|
-
Version 0.4: fifty-
|
|
25
|
+
Version 0.4: fifty-seven tools. Qobuz, TIDAL or both — whatever is signed in on your Core.
|
|
26
26
|
|
|
27
27
|
**Reading** — changes nothing in Roon:
|
|
28
28
|
|
|
@@ -88,6 +88,7 @@ Version 0.4: fifty-six tools. Qobuz, TIDAL or both — whatever is signed in on
|
|
|
88
88
|
| `roon_library_add` | A Qobuz or TIDAL album into your library |
|
|
89
89
|
| `roon_playlist_create` | A new playlist from tracks |
|
|
90
90
|
| `roon_dsp_set` | MUSE on or off, one part on or off, or a preset |
|
|
91
|
+
| `roon_radio_favorite` | A station into My Live Radio, or out of it |
|
|
91
92
|
|
|
92
93
|
## Install
|
|
93
94
|
|
|
@@ -107,7 +108,7 @@ The settings (Claude Desktop › Settings › Extensions › Deep Roon MCP):
|
|
|
107
108
|
| Setting | What it does |
|
|
108
109
|
|---|---|
|
|
109
110
|
| Roon Core address | Empty: found on your network. Fill in the Core's address for another subnet or a private VPN (Tailscale) |
|
|
110
|
-
| Essentials only | Seventeen tools instead of fifty-
|
|
111
|
+
| Essentials only | Seventeen tools instead of fifty-seven, for small models |
|
|
111
112
|
| Use Roon's internal protocol | Off: only the official API — zones, playback, volume, grouping; no search, reviews, Focus or history |
|
|
112
113
|
|
|
113
114
|
### Other clients
|
package/dist/deep-roon-mcp.js
CHANGED
|
@@ -15525,7 +15525,7 @@ var require_lib2 = __commonJS({
|
|
|
15525
15525
|
var version2;
|
|
15526
15526
|
var init_package = __esm({
|
|
15527
15527
|
"package.json"() {
|
|
15528
|
-
version2 = "0.4.
|
|
15528
|
+
version2 = "0.4.5";
|
|
15529
15529
|
}
|
|
15530
15530
|
});
|
|
15531
15531
|
|
|
@@ -15852,6 +15852,15 @@ function field2(o, name) {
|
|
|
15852
15852
|
for (const [k, v] of Object.entries(f)) if (k.endsWith("::" + name)) return v;
|
|
15853
15853
|
return void 0;
|
|
15854
15854
|
}
|
|
15855
|
+
function imageKeys(c, o, name = "Images", max = 4) {
|
|
15856
|
+
return items(c, field2(o, name)).map((im) => {
|
|
15857
|
+
const m = /^broker:\/\/\/image\/([^.]+)\./.exec(String(field2(im, "Url") ?? ""));
|
|
15858
|
+
return m ? m[1] : "";
|
|
15859
|
+
}).filter(Boolean).slice(0, max);
|
|
15860
|
+
}
|
|
15861
|
+
function imageUrls(c, o, name = "Images", max = 4) {
|
|
15862
|
+
return items(c, field2(o, name)).map((im) => String(field2(im, "Url") ?? "").replace(/^http:\/\//, "https://")).filter((u) => /^https:\/\//.test(u)).slice(0, max);
|
|
15863
|
+
}
|
|
15855
15864
|
function imageKey(c, o, name = "Image") {
|
|
15856
15865
|
const url2 = field2(obj(c, field2(o, name)), "Url");
|
|
15857
15866
|
const m = /^broker:\/\/\/image\/([^.]+)\./.exec(String(url2 ?? ""));
|
|
@@ -15953,6 +15962,8 @@ function albumRow(c, o) {
|
|
|
15953
15962
|
if (typeof st === "number" && st > 0) r.stars = st / 2;
|
|
15954
15963
|
const lb = strings(c, field2(o, "Labels"));
|
|
15955
15964
|
if (lb.length) r.label = lb.join(" / ");
|
|
15965
|
+
const gs = strings(c, field2(o, "Genres"));
|
|
15966
|
+
if (gs.length) r.genres = gs.slice(0, 8);
|
|
15956
15967
|
if (field2(o, "Source") === 1) r.in_library = true;
|
|
15957
15968
|
if (flag(field2(o, "IsFavorite"))) r.favorite = true;
|
|
15958
15969
|
if (flag(field2(o, "IsListenLater"))) r.listen_later = true;
|
|
@@ -16007,6 +16018,8 @@ var init_graph = __esm({
|
|
|
16007
16018
|
return (o?.fields?.$items ?? o?.$items ?? []).filter((x) => typeof x === "string");
|
|
16008
16019
|
}, "strings");
|
|
16009
16020
|
flag = /* @__PURE__ */ __name((v) => v === true || Buffer.isBuffer(v) && v.length > 1 && v[0] >= 1, "flag");
|
|
16021
|
+
__name(imageKeys, "imageKeys");
|
|
16022
|
+
__name(imageUrls, "imageUrls");
|
|
16010
16023
|
__name(imageKey, "imageKey");
|
|
16011
16024
|
__name(dateParts, "dateParts");
|
|
16012
16025
|
yearOf = /* @__PURE__ */ __name((v) => dateParts(v)?.[0], "yearOf");
|
|
@@ -16478,6 +16491,7 @@ __export(focus_exports, {
|
|
|
16478
16491
|
artists: () => artists,
|
|
16479
16492
|
focus: () => focus,
|
|
16480
16493
|
library: () => library2,
|
|
16494
|
+
toplevelGenres: () => toplevelGenres,
|
|
16481
16495
|
tracks: () => tracks,
|
|
16482
16496
|
work: () => work,
|
|
16483
16497
|
works: () => works
|
|
@@ -16576,6 +16590,28 @@ async function focusMembers(c, f, people = ["artist", "composer", "producer"]) {
|
|
|
16576
16590
|
if (f.not_played_within_days) m.push({ name: "ExcludePlayedInLastNDays", propType: 10, value: maybeInt(f.not_played_within_days) });
|
|
16577
16591
|
if (f.added_within_days) m.push({ name: "RequireImportedInLastNDays", propType: 10, value: maybeInt(f.added_within_days) });
|
|
16578
16592
|
if (f.hires) m.push({ name: "RequireSampleRates", propType: 23, value: intList([88200, 96e3, 176400, 192e3, 352800, 384e3]) });
|
|
16593
|
+
if (f.sample_rate) {
|
|
16594
|
+
const hz = names(f.sample_rate).map((x) => Math.round(parseFloat(x) * (parseFloat(x) < 1e3 ? 1e3 : 1))).filter((x) => x > 0);
|
|
16595
|
+
if (hz.length) m.push({ name: "RequireSampleRates", propType: 23, value: intList(hz) });
|
|
16596
|
+
}
|
|
16597
|
+
if (f.bits) {
|
|
16598
|
+
const b = names(f.bits).map(Number).filter((x) => x > 0);
|
|
16599
|
+
if (b.length) m.push({ name: "RequireBitsPerSample", propType: 23, value: intList(b) });
|
|
16600
|
+
}
|
|
16601
|
+
if (f.mqa === true) m.push(yes("RequireIsMQA"));
|
|
16602
|
+
else if (f.mqa === false) m.push(yes("ExcludeIsMQA"));
|
|
16603
|
+
if (f.storage) {
|
|
16604
|
+
const whole = await albumInfo(c, []);
|
|
16605
|
+
const want = f.storage.toLowerCase();
|
|
16606
|
+
const locs = items(c, field2(whole, "Locations")).map((it) => field2(it, "Value")).filter(isRef2);
|
|
16607
|
+
await untilLoaded(c, locs, 1500);
|
|
16608
|
+
const hit = locs.find((v) => {
|
|
16609
|
+
const n = String(field2(obj(c, v), "Name") ?? "").toLowerCase();
|
|
16610
|
+
return want === "local" ? !/tidal|qobuz/.test(n) : n.includes(want);
|
|
16611
|
+
});
|
|
16612
|
+
if (!hit) throw new RoonError(`No "${f.storage}" in this library's locations`, "not_found");
|
|
16613
|
+
m.push({ name: "RequireLocations", propType: 23, value: refList2([hit.$ref]) });
|
|
16614
|
+
}
|
|
16579
16615
|
if (f.live === true) m.push(yes("RequireIsLive"));
|
|
16580
16616
|
else if (f.live === false) m.push(yes("ExcludeIsLive"));
|
|
16581
16617
|
if (f.compilation === true) m.push(yes("RequireIsCompilation"));
|
|
@@ -16607,10 +16643,20 @@ function ordering(c, kind, table, sort, ascending, descendingByDefault) {
|
|
|
16607
16643
|
if (sort === "random") members2.push({ name: "RandomSeed", propType: 0, value: new BinaryWriter().integer(Math.floor(Math.random() * 2147483647)).toBuffer() });
|
|
16608
16644
|
return [{ name: "Ordering", propType: 23, value: c.structArg(`Sooloos.Broker.Api.${kind}QueryOrdering`, members2) }];
|
|
16609
16645
|
}
|
|
16646
|
+
async function toplevelGenres(c) {
|
|
16647
|
+
if (toplevelNames) return toplevelNames;
|
|
16648
|
+
const { members: members2 } = await focusMembers(c, {});
|
|
16649
|
+
const info = await albumInfo(c, members2);
|
|
16650
|
+
const hist = items(c, field2(info, "ToplevelGenres")).map((it) => field2(it, "Value"));
|
|
16651
|
+
await untilLoaded(c, hist.filter(isRef2), 2e3);
|
|
16652
|
+
const names2 = hist.map((w) => isRef2(w) ? field2(obj(c, w), "Name") : w).filter((n) => typeof n === "string");
|
|
16653
|
+
if (names2.length) toplevelNames = new Set(names2);
|
|
16654
|
+
return new Set(names2);
|
|
16655
|
+
}
|
|
16610
16656
|
async function library2(f, sort, ascending, limit3, offset2) {
|
|
16611
16657
|
return withCore(async (c) => {
|
|
16612
16658
|
const { members: members2, matched } = await focusMembers(c, f);
|
|
16613
|
-
members2.push(...ordering(c, "Album", ALBUM_ORDER, sort, ascending, ["added", "most_played", "popular"]));
|
|
16659
|
+
members2.push(...ordering(c, "Album", ALBUM_ORDER, sort, ascending, ["added", "released", "most_played", "popular"]));
|
|
16614
16660
|
const out = await virtualQuery(c, "Album", members2, limit3, offset2, (o) => albumRow(c, o));
|
|
16615
16661
|
return { ...out, matched };
|
|
16616
16662
|
});
|
|
@@ -16652,7 +16698,13 @@ async function focus(f, more, search2, limit3) {
|
|
|
16652
16698
|
bit_depths: numbers("BitsPerSample").map((x) => ({ bits: x.w, albums: x.n })),
|
|
16653
16699
|
live: field2(info, "IsLiveCount") ?? 0,
|
|
16654
16700
|
compilations: field2(info, "IsCompilationCount") ?? 0,
|
|
16655
|
-
roon_picks: field2(info, "IsPickCount") ?? 0
|
|
16701
|
+
roon_picks: field2(info, "IsPickCount") ?? 0,
|
|
16702
|
+
mqa: field2(info, "IsMQACount") ?? 0,
|
|
16703
|
+
// Raw histograms, to be named once measured (13 Oct 2026): where the albums live, and their file formats.
|
|
16704
|
+
storage: hist("Locations").map((x) => {
|
|
16705
|
+
const n = isRef2(x.w) ? String(field2(obj(c, x.w), "Name") ?? "") : String(x.w);
|
|
16706
|
+
return { name: /tidal/i.test(n) ? "TIDAL" : /qobuz/i.test(n) ? "Qobuz" : "Local", albums: x.n };
|
|
16707
|
+
})
|
|
16656
16708
|
};
|
|
16657
16709
|
});
|
|
16658
16710
|
}
|
|
@@ -16673,7 +16725,7 @@ async function tracks(f, sort, ascending, limit3, offset2) {
|
|
|
16673
16725
|
text: f.text
|
|
16674
16726
|
}, ["artist", "composer"]);
|
|
16675
16727
|
if (f.title) members2.push(text("TrackTitleTextFilter", f.title));
|
|
16676
|
-
members2.push(...ordering(c, "Track", TRACK_ORDER, sort, ascending, ["most_played", "last_played", "added", "popular"]));
|
|
16728
|
+
members2.push(...ordering(c, "Track", TRACK_ORDER, sort, ascending, ["released", "most_played", "last_played", "added", "popular"]));
|
|
16677
16729
|
const out = await virtualQuery(c, "Track", members2, limit3, offset2, (o) => resultRow(c, o));
|
|
16678
16730
|
return { ...out, matched };
|
|
16679
16731
|
});
|
|
@@ -16711,6 +16763,8 @@ function workRow(c, w) {
|
|
|
16711
16763
|
if (typeof l === "number" && l > 0) r.length = duration3(l);
|
|
16712
16764
|
const n = field2(w, "PerformanceCount");
|
|
16713
16765
|
if (typeof n === "number") r.performances = n;
|
|
16766
|
+
const imgs = imageKeys(c, w);
|
|
16767
|
+
if (imgs.length) r.images = imgs;
|
|
16714
16768
|
return r;
|
|
16715
16769
|
}
|
|
16716
16770
|
async function works(f, sort, ascending, limit3, offset2) {
|
|
@@ -16777,7 +16831,7 @@ async function work(key, max, wiki) {
|
|
|
16777
16831
|
};
|
|
16778
16832
|
});
|
|
16779
16833
|
}
|
|
16780
|
-
var intList, textList, refList2, maybeInt, dateValue, yes, text, names, english, PANEL, ALBUM_ORDER, TRACK_ORDER, ARTIST_ORDER, WORK_ORDER;
|
|
16834
|
+
var intList, textList, refList2, maybeInt, dateValue, yes, text, names, english, PANEL, ALBUM_ORDER, TRACK_ORDER, ARTIST_ORDER, WORK_ORDER, toplevelNames;
|
|
16781
16835
|
var init_focus = __esm({
|
|
16782
16836
|
"src/roon/focus.ts"() {
|
|
16783
16837
|
"use strict";
|
|
@@ -16827,6 +16881,8 @@ var init_focus = __esm({
|
|
|
16827
16881
|
TRACK_ORDER = { title: 0, released: 1, most_played: 2, last_played: 3, artist: 4, album: 5, length: 6, added: 7, popular: 14, random: 16 };
|
|
16828
16882
|
ARTIST_ORDER = { name: 0, albums: 2, works: 3, most_played: 4, popular: 6 };
|
|
16829
16883
|
WORK_ORDER = { title: 0, composer: 1, composed: 3, period: 4, performances: 5, popular: 9 };
|
|
16884
|
+
toplevelNames = null;
|
|
16885
|
+
__name(toplevelGenres, "toplevelGenres");
|
|
16830
16886
|
__name(library2, "library");
|
|
16831
16887
|
__name(focus, "focus");
|
|
16832
16888
|
__name(tracks, "tracks");
|
|
@@ -49478,6 +49534,7 @@ __name(nowPlaying, "nowPlaying");
|
|
|
49478
49534
|
init_intern();
|
|
49479
49535
|
init_serializer();
|
|
49480
49536
|
init_writer();
|
|
49537
|
+
init_signature();
|
|
49481
49538
|
init_session();
|
|
49482
49539
|
init_log();
|
|
49483
49540
|
init_graph();
|
|
@@ -49935,6 +49992,8 @@ function stationRow(c, o) {
|
|
|
49935
49992
|
if (field2(o, "IsInLibrary") === true) r.in_library = true;
|
|
49936
49993
|
const img = imageKey(c, o);
|
|
49937
49994
|
if (img) r.image = img;
|
|
49995
|
+
const url2 = field2(obj(c, field2(o, "Image")), "Url");
|
|
49996
|
+
if (typeof url2 === "string" && /^https?:/.test(url2)) r.image_url = url2;
|
|
49938
49997
|
return r;
|
|
49939
49998
|
}
|
|
49940
49999
|
__name(stationRow, "stationRow");
|
|
@@ -50030,6 +50089,30 @@ async function playRadio(key, zoneName) {
|
|
|
50030
50089
|
return { ok: true, ...r, ...await started2(r.zone, 0) };
|
|
50031
50090
|
}
|
|
50032
50091
|
__name(playRadio, "playRadio");
|
|
50092
|
+
async function radioFavorite(key, on) {
|
|
50093
|
+
return withCore(async (c) => {
|
|
50094
|
+
const st = await findStation(c, key);
|
|
50095
|
+
const args = buildArgs([Arg.collection([buildArgs([Arg.ref(st.oid)])])]);
|
|
50096
|
+
if (on) {
|
|
50097
|
+
const res = await c.call("Radio", "AddToLibrary", [{ type: "IList<Channel>", name: "channels" }, { type: "ResultCallback", name: "cb_result" }], args, radioOid(c));
|
|
50098
|
+
if (!res.success) throw new RoonError(`Roon refused (${res.status})`);
|
|
50099
|
+
} else {
|
|
50100
|
+
c.remoting.callMethodNoReply(radioOid(c), formatMethodSignature("Radio", "RemoveFromLibrary", [{ type: "IList<Channel>", name: "channels" }, { type: "ResultCallback", name: "cb_result" }]), args);
|
|
50101
|
+
}
|
|
50102
|
+
log(`radio_favorite: ${field2(st, "Name")} ${on ? "+" : "\u2212"}`);
|
|
50103
|
+
await wait(800);
|
|
50104
|
+
const id = hex3(field2(st, "ChannelId"));
|
|
50105
|
+
const mine = await myStations(c).catch(() => null);
|
|
50106
|
+
const now = mine ? mine.some((x) => hex3(field2(x, "ChannelId")) === id) : void 0;
|
|
50107
|
+
return {
|
|
50108
|
+
ok: now === void 0 || now === on,
|
|
50109
|
+
station: field2(st, "Name"),
|
|
50110
|
+
in_library: now ?? on,
|
|
50111
|
+
note: now === void 0 ? "Roon said ok; it couldn't be read back" : now !== on ? "Roon said ok, but nothing changed" : void 0
|
|
50112
|
+
};
|
|
50113
|
+
});
|
|
50114
|
+
}
|
|
50115
|
+
__name(radioFavorite, "radioFavorite");
|
|
50033
50116
|
|
|
50034
50117
|
// src/core/officialtools.ts
|
|
50035
50118
|
init_errors();
|
|
@@ -50325,7 +50408,7 @@ function registerReadTools(server, internal = true) {
|
|
|
50325
50408
|
}, () => run(() => internal ? nowPlaying() : nowPlaying2()));
|
|
50326
50409
|
reg("roon_radio", {
|
|
50327
50410
|
title: "Live Radio",
|
|
50328
|
-
description: 'Roon\'s Live Radio directory. Without arguments: the user\'s own stations (`my_stations`) and the top categories (Music, News & Talk, Sports, Locations), each with a `key`. With `category` (a key): its sub-categories and stations. With `genre` (a category\'s name as written, e.g. "Jazz", "Classical"): stations of that genre. Stations carry a `key` for roon_play_radio, their location and `image
|
|
50411
|
+
description: 'Roon\'s Live Radio directory. Without arguments: the user\'s own stations (`my_stations`) and the top categories (Music, News & Talk, Sports, Locations), each with a `key`. With `category` (a key): its sub-categories and stations. With `genre` (a category\'s name as written, e.g. "Jazz", "Classical"): stations of that genre. Stations carry a `key` for roon_play_radio, their location, and their logo: `image_url` (a plain https picture) for the directory, `image` (a key at the Core) for an own station.',
|
|
50329
50412
|
inputSchema: { category: external_exports.string().optional().describe("A category key from a previous answer."), genre: external_exports.string().optional().describe("A genre, as roon_radio names it (e.g. Jazz)."), limit: limit(100, 40) },
|
|
50330
50413
|
annotations: READ
|
|
50331
50414
|
}, ({ category, genre, limit: n }) => run(() => radio(category, genre, n ?? 40)));
|
|
@@ -50482,6 +50565,7 @@ init_writer();
|
|
|
50482
50565
|
init_session();
|
|
50483
50566
|
init_log();
|
|
50484
50567
|
init_graph();
|
|
50568
|
+
init_focus();
|
|
50485
50569
|
var localNow = /* @__PURE__ */ __name(() => {
|
|
50486
50570
|
const d = /* @__PURE__ */ new Date(), t = /* @__PURE__ */ __name((n) => String(n).padStart(2, "0"), "t");
|
|
50487
50571
|
return `${d.getFullYear()}-${t(d.getMonth() + 1)}-${t(d.getDate())}T${t(d.getHours())}:${t(d.getMinutes())}:${t(d.getSeconds())}`;
|
|
@@ -50526,14 +50610,25 @@ async function dailyPicks(perBox) {
|
|
|
50526
50610
|
});
|
|
50527
50611
|
}
|
|
50528
50612
|
__name(dailyPicks, "dailyPicks");
|
|
50529
|
-
async function newForYou(limit3, offset2) {
|
|
50613
|
+
async function newForYou(limit3, offset2, genre) {
|
|
50530
50614
|
return withCore(async (c) => {
|
|
50531
50615
|
const res = await c.call("Library", "GetNewReleasesForYou", [{ type: "Sooid", name: "profileid" }, { type: "ResultCallback<DataList<AlbumWithExtras>>", name: "cb_result" }], buildArgs([Arg.sooid(c.profile())]), c.serviceOid("Library"));
|
|
50532
50616
|
if (!res.success) throw new RoonError(`Roon refused New releases for you (${res.status})`);
|
|
50533
|
-
|
|
50617
|
+
let all = items(c, decoded2(c, res.payload));
|
|
50618
|
+
await untilLoaded(c, all.map((x) => field2(x, "Album")).filter(isRef2), 4e3);
|
|
50619
|
+
const top2 = await toplevelGenres(c);
|
|
50620
|
+
const counts = /* @__PURE__ */ new Map();
|
|
50621
|
+
for (const x of all) for (const n of new Set(strings(c, field2(obj(c, field2(x, "Album")), "Genres")))) if (top2.has(n)) counts.set(n, (counts.get(n) ?? 0) + 1);
|
|
50622
|
+
const genres2 = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, albums]) => ({ name, albums }));
|
|
50623
|
+
if (genre) {
|
|
50624
|
+
const g = genre.toLowerCase();
|
|
50625
|
+
const names2 = /* @__PURE__ */ __name((x) => strings(c, field2(obj(c, field2(x, "Album")), "Genres")).map((n) => n.toLowerCase()), "names");
|
|
50626
|
+
const exact = all.filter((x) => names2(x).includes(g));
|
|
50627
|
+
all = exact.length ? exact : all.filter((x) => names2(x).some((n) => n.includes(g)));
|
|
50628
|
+
}
|
|
50534
50629
|
const part = all.slice(Math.max(0, offset2), Math.max(0, offset2) + Math.min(Math.max(1, limit3), 100));
|
|
50535
50630
|
await untilLoaded(c, part.map((x) => field2(x, "Album")).filter(isRef2), 3e3);
|
|
50536
|
-
return { total: all.length, offset: Math.max(0, offset2), rows: part.map((x) => {
|
|
50631
|
+
return { total: all.length, offset: Math.max(0, offset2), genres: genres2, rows: part.map((x) => {
|
|
50537
50632
|
const a = obj(c, field2(x, "Album"));
|
|
50538
50633
|
if (!a || field2(a, "Title") === void 0) return null;
|
|
50539
50634
|
const r = albumRow(c, a);
|
|
@@ -50567,10 +50662,43 @@ async function mixes() {
|
|
|
50567
50662
|
const r = { key: mixKey(m), kind: gd && field2(gd, "GenreName") ? "genre" : "artist", name: mixName(c, m) };
|
|
50568
50663
|
const ts = (obj(c, field2(pd ?? gd, "Touchstones"))?.fields?.$items ?? []).filter((x) => typeof x === "string").map(clean).slice(0, 8);
|
|
50569
50664
|
if (ts.length) r.touchstones = ts;
|
|
50665
|
+
const img = pd ? imageKey(c, pd, "Photo") ?? imageKey(c, pd, "Avatar") : void 0;
|
|
50666
|
+
if (img) r.image = img;
|
|
50667
|
+
const imgs = gd ? imageKeys(c, gd) : [];
|
|
50668
|
+
if (imgs.length) r.images = imgs;
|
|
50570
50669
|
return r;
|
|
50571
50670
|
}) }));
|
|
50572
50671
|
}
|
|
50573
50672
|
__name(mixes, "mixes");
|
|
50673
|
+
async function mix(key, limit3) {
|
|
50674
|
+
return withCore(async (c) => {
|
|
50675
|
+
const all = await readMixes(c);
|
|
50676
|
+
const m = all.find((x) => mixKey(x) === key) ?? all.find((x) => String(mixName(c, x)).toLowerCase() === key.toLowerCase());
|
|
50677
|
+
if (!m?.oid) throw new RoonError("Unknown mix \u2014 read roon_mixes for today's keys", "not_found");
|
|
50678
|
+
const res = await c.call("Mix", "GetItems", [{ type: "ResultCallback<DataList<MixItem>>", name: "cb_result" }], Buffer.alloc(0), m.oid);
|
|
50679
|
+
if (!res.success) throw new RoonError(`Roon refused the mix (${res.status})`);
|
|
50680
|
+
const its = items(c, decoded2(c, res.payload));
|
|
50681
|
+
const refs = its.flatMap((it) => obj(c, field2(it, "Tracks"))?.fields?.$items ?? []);
|
|
50682
|
+
await untilLoaded(c, refs, 3e3);
|
|
50683
|
+
const tracks2 = its.flatMap((it) => items(c, field2(it, "Tracks"))).filter((t) => field2(t, "Title") !== void 0);
|
|
50684
|
+
const pd = obj(c, field2(m, "PerformerMixDescription"));
|
|
50685
|
+
const head = { key: mixKey(m), name: mixName(c, m) };
|
|
50686
|
+
const img = pd ? imageKey(c, pd, "Photo") ?? imageKey(c, pd, "Avatar") : void 0;
|
|
50687
|
+
if (img) head.image = img;
|
|
50688
|
+
return { mix: head, total: tracks2.length, rows: tracks2.slice(0, Math.min(Math.max(1, limit3), 300)).map((t, i) => {
|
|
50689
|
+
const al = obj(c, field2(t, "Album"));
|
|
50690
|
+
const r = { n: i + 1, key: String(field2(t, "TrackId") ?? ""), title: field2(t, "Title") ?? "", artist: clean(field2(al, "PerformedBy") ?? field2(t, "PerformedBy") ?? ""), album: field2(al, "Title") ?? void 0 };
|
|
50691
|
+
const ak = field2(al, "AlbumId");
|
|
50692
|
+
if (ak !== void 0 && ak !== null) r.album_key = String(ak);
|
|
50693
|
+
const l = field2(t, "LengthSeconds");
|
|
50694
|
+
if (typeof l === "number" && l > 0) r.length = duration3(l);
|
|
50695
|
+
const im = imageKey(c, al);
|
|
50696
|
+
if (im) r.image = im;
|
|
50697
|
+
return r;
|
|
50698
|
+
}) };
|
|
50699
|
+
});
|
|
50700
|
+
}
|
|
50701
|
+
__name(mix, "mix");
|
|
50574
50702
|
async function playMix(key, zoneName, how) {
|
|
50575
50703
|
const p = PRIORITY[how];
|
|
50576
50704
|
const r = await withCore(async (c) => {
|
|
@@ -50660,6 +50788,10 @@ async function playlists(shelfName, genre, mood, limit3, offset2) {
|
|
|
50660
50788
|
const key = playlistKey(p);
|
|
50661
50789
|
seen.set(key, w);
|
|
50662
50790
|
const r = { key, name: String(field2(p, "Name") ?? "").trim() };
|
|
50791
|
+
const imgs = imageKeys(c, p);
|
|
50792
|
+
if (imgs.length) r.images = imgs;
|
|
50793
|
+
const urls = imageUrls(c, p);
|
|
50794
|
+
if (urls.length) r.image_urls = urls;
|
|
50663
50795
|
const d = field2(p, "Description");
|
|
50664
50796
|
if (d) r.description = clean(d).replace(/\s+/g, " ").trim().slice(0, 240);
|
|
50665
50797
|
const n = field2(p, "TrackCount") ?? field2(p, "ItemCount");
|
|
@@ -50705,13 +50837,24 @@ async function findPlaylist2(c, key) {
|
|
|
50705
50837
|
}
|
|
50706
50838
|
__name(findPlaylist2, "findPlaylist");
|
|
50707
50839
|
async function playlists2() {
|
|
50708
|
-
return withCore(async (c) => ({ playlists: allPlaylists(c).list.map((p) =>
|
|
50709
|
-
|
|
50710
|
-
|
|
50711
|
-
|
|
50712
|
-
|
|
50713
|
-
|
|
50714
|
-
|
|
50840
|
+
return withCore(async (c) => ({ playlists: allPlaylists(c).list.map((p) => {
|
|
50841
|
+
const r = {
|
|
50842
|
+
key: playlistKey(p),
|
|
50843
|
+
name: field2(p, "Name") ?? "",
|
|
50844
|
+
kind: PLAYLIST_KIND[field2(p, "Type")] ?? String(field2(p, "Type") ?? ""),
|
|
50845
|
+
tracks: field2(p, "TrackCount") ?? field2(p, "ItemCount") ?? void 0,
|
|
50846
|
+
read_only: field2(p, "IsReadOnly") === true || void 0
|
|
50847
|
+
};
|
|
50848
|
+
const imgs = imageKeys(c, p);
|
|
50849
|
+
if (imgs.length) r.images = imgs;
|
|
50850
|
+
const l = field2(p, "LengthSeconds");
|
|
50851
|
+
if (typeof l === "number" && l > 0) r.length = duration3(l);
|
|
50852
|
+
const names2 = (obj(c, field2(p, "SampleArtistNames"))?.fields?.$items ?? []).filter((x) => typeof x === "string").slice(0, 4);
|
|
50853
|
+
if (names2.length) r.artists = names2;
|
|
50854
|
+
const d = field2(p, "Description");
|
|
50855
|
+
if (d) r.description = clean(d).replace(/\s+/g, " ").slice(0, 240);
|
|
50856
|
+
return r;
|
|
50857
|
+
}) }));
|
|
50715
50858
|
}
|
|
50716
50859
|
__name(playlists2, "playlists");
|
|
50717
50860
|
async function playlist(key, limit3) {
|
|
@@ -50731,7 +50874,14 @@ async function playlist(key, limit3) {
|
|
|
50731
50874
|
return { name: field2(p, "Name"), total: list.length, rows: list.slice(0, n).map((x, i) => {
|
|
50732
50875
|
const tr = obj(c, field2(obj(c, x), "Track"));
|
|
50733
50876
|
const al = obj(c, field2(tr, "Album"));
|
|
50734
|
-
|
|
50877
|
+
const r = { n: i + 1, key: String(field2(tr, "TrackId") ?? ""), title: field2(tr, "Title") ?? null, artist: clean(field2(al, "PerformedBy") ?? ""), album: field2(al, "Title") ?? void 0 };
|
|
50878
|
+
const ak = field2(al, "AlbumId");
|
|
50879
|
+
if (ak !== void 0 && ak !== null) r.album_key = String(ak);
|
|
50880
|
+
const im = imageKey(c, al);
|
|
50881
|
+
if (im) r.image = im;
|
|
50882
|
+
const l = field2(tr, "LengthSeconds");
|
|
50883
|
+
if (typeof l === "number" && l > 0) r.length = duration3(l);
|
|
50884
|
+
return r;
|
|
50735
50885
|
}) };
|
|
50736
50886
|
});
|
|
50737
50887
|
}
|
|
@@ -51248,6 +51398,10 @@ var FOCUS = {
|
|
|
51248
51398
|
picks: external_exports.boolean().optional().describe("Roon's own picks."),
|
|
51249
51399
|
text: external_exports.string().optional().describe("A word in the title or artist."),
|
|
51250
51400
|
favorites: external_exports.boolean().optional(),
|
|
51401
|
+
sample_rate: external_exports.string().optional().describe('Sample rates in kHz, comma-separated: "44.1, 96".'),
|
|
51402
|
+
bits: external_exports.string().optional().describe('Bit depths, comma-separated: "16, 24".'),
|
|
51403
|
+
mqa: external_exports.boolean().optional(),
|
|
51404
|
+
storage: external_exports.enum(["tidal", "qobuz", "local"]).optional().describe("Where the albums live."),
|
|
51251
51405
|
played_within_days: days,
|
|
51252
51406
|
not_played_within_days: days,
|
|
51253
51407
|
added_within_days: days
|
|
@@ -51336,12 +51490,18 @@ function registerLibraryTools(server) {
|
|
|
51336
51490
|
inputSchema: { limit: limit2(30, 8) },
|
|
51337
51491
|
annotations: READ2
|
|
51338
51492
|
}, ({ limit: n }) => run(() => dailyPicks(n ?? 8)));
|
|
51493
|
+
server.registerTool("roon_mix", {
|
|
51494
|
+
title: "A Daily Mix's tracks",
|
|
51495
|
+
description: "The tracks inside one of Roon's Daily Mixes (by `key` or name from roon_mixes), as Roon's mix page shows them before playing \u2014 each with a `key` for roon_play_tracks. To play the whole mix, roon_play_mix.",
|
|
51496
|
+
inputSchema: { key: external_exports.string().describe("A mix key or its name, from roon_mixes."), limit: limit2(300, 100) },
|
|
51497
|
+
annotations: READ2
|
|
51498
|
+
}, ({ key, limit: n }) => run(() => mix(key, n ?? 100)));
|
|
51339
51499
|
server.registerTool("roon_new_for_you", {
|
|
51340
51500
|
title: "New releases for you",
|
|
51341
|
-
description: '"New releases for you" from Roon\'s Home: new albums Roon picked for this user, from the artists and music they listen to \u2014 personal, unlike roon_new (the streaming services\' editorial shelf, the same for everyone). Use it for "my new releases", "what\'s new for me". Albums carry year, format, sometimes a `review`, and a `key
|
|
51342
|
-
inputSchema: { limit: limit2(100, 30), offset },
|
|
51501
|
+
description: '"New releases for you" from Roon\'s Home: new albums Roon picked for this user, from the artists and music they listen to \u2014 personal, unlike roon_new (the streaming services\' editorial shelf, the same for everyone). Use it for "my new releases", "what\'s new for me". Albums carry year, format, `genres`, sometimes a `review`, and a `key`; `genres` at the top counts the shelf\'s top-level genres (the choices for `genre`).',
|
|
51502
|
+
inputSchema: { limit: limit2(100, 30), offset, genre: external_exports.string().optional().describe("Only albums of this genre (a Roon genre name, e.g. Jazz; sub-genres match too).") },
|
|
51343
51503
|
annotations: READ2
|
|
51344
|
-
}, ({ limit: n, offset: o }) => run(() => newForYou(n ?? 30, o ?? 0)));
|
|
51504
|
+
}, ({ limit: n, offset: o, genre }) => run(() => newForYou(n ?? 30, o ?? 0, genre)));
|
|
51345
51505
|
server.registerTool("roon_mixes", {
|
|
51346
51506
|
title: "Daily Mixes",
|
|
51347
51507
|
description: 'Roon\'s own Daily Mixes, from Roon\'s Home \u2014 NOT TIDAL\'s mixes: for "TIDAL mixes", "My Mix", "My Daily Discovery" or TIDAL\'s daily mixes use roon_tidal_playlists with `shelf: "my_mixes"`. These are today\'s personal Roon mixes, each built around an artist (or genre) the user listens to, with a few `touchstones` and a `key` for roon_play_mix. They change every day.',
|
|
@@ -51396,6 +51556,12 @@ function registerLibraryTools(server) {
|
|
|
51396
51556
|
inputSchema: { source: external_exports.enum(["roon", "qobuz", "tidal"]).optional(), limit: limit2(100, 50) },
|
|
51397
51557
|
annotations: READ2
|
|
51398
51558
|
}, ({ source, limit: n }) => run(() => favorites(source ?? "roon", n ?? 50)));
|
|
51559
|
+
server.registerTool("roon_radio_favorite", {
|
|
51560
|
+
title: "A station in My Live Radio",
|
|
51561
|
+
description: "Put a Live Radio station in the user's own list (My Live Radio), or take it out (`on: false`). The `key` comes from roon_radio. Changes the user's library: only on an explicit request. Reads the result back.",
|
|
51562
|
+
inputSchema: { key: external_exports.string(), on: external_exports.boolean().optional() },
|
|
51563
|
+
annotations: CHANGES
|
|
51564
|
+
}, ({ key, on }) => run(() => radioFavorite(key, on !== false)));
|
|
51399
51565
|
server.registerTool("roon_favorite", {
|
|
51400
51566
|
title: "Heart an album or track",
|
|
51401
51567
|
description: "Put a heart on an album or a track in Roon, or take it off (`on: false`). The `key` comes from a search, shelf, album or playlist result. Changes the user's library: only on an explicit request. Reads the heart back and says if Roon didn't change it.",
|
|
@@ -51499,7 +51665,39 @@ async function runSelfTest() {
|
|
|
51499
51665
|
__name(runSelfTest, "runSelfTest");
|
|
51500
51666
|
|
|
51501
51667
|
// src/tools/status.ts
|
|
51668
|
+
init_session();
|
|
51669
|
+
function registerDebugTool(server) {
|
|
51670
|
+
server.registerTool("roon_debug", {
|
|
51671
|
+
title: "Graph fields (development)",
|
|
51672
|
+
description: "Development only: without `type`, the graph's type names with counts (`match` narrows); with `type`, the first `limit` objects of that type with their fields.",
|
|
51673
|
+
inputSchema: { type: external_exports.string().optional(), match: external_exports.string().optional(), limit: external_exports.number().int().min(1).max(200).optional() },
|
|
51674
|
+
annotations: { readOnlyHint: true }
|
|
51675
|
+
}, ({ type, match, limit: limit3 }) => run(() => withCore(async (c) => {
|
|
51676
|
+
const preview = /* @__PURE__ */ __name((v) => {
|
|
51677
|
+
if (v && typeof v === "object" && "$ref" in v) {
|
|
51678
|
+
const o = c.graph.getObject(v.$ref);
|
|
51679
|
+
return { $ref: String(v.$ref), type: o?.typeName };
|
|
51680
|
+
}
|
|
51681
|
+
if (Array.isArray(v)) return v.slice(0, 3).map(preview);
|
|
51682
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).slice(0, 6).map(([k, x]) => [k, preview(x)]));
|
|
51683
|
+
return typeof v === "string" ? v.slice(0, 80) : typeof v === "bigint" ? String(v) : v;
|
|
51684
|
+
}, "preview");
|
|
51685
|
+
if (!type) {
|
|
51686
|
+
const counts = /* @__PURE__ */ new Map();
|
|
51687
|
+
for (const o of c.graph.objects.values()) counts.set(o.typeName, (counts.get(o.typeName) ?? 0) + 1);
|
|
51688
|
+
return { types: [...counts].filter(([n]) => !match || n.toLowerCase().includes(match.toLowerCase())).sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, n })) };
|
|
51689
|
+
}
|
|
51690
|
+
const all = c.graph.findByType(type);
|
|
51691
|
+
return { count: all.length, objects: all.slice(0, limit3 ?? 3).map((o) => ({
|
|
51692
|
+
oid: String(o.oid),
|
|
51693
|
+
type: o.typeName,
|
|
51694
|
+
fields: Object.fromEntries(Object.entries(o.fields ?? {}).map(([k, v]) => [k.replace(/^.*::/, ""), preview(v)]))
|
|
51695
|
+
})) };
|
|
51696
|
+
})));
|
|
51697
|
+
}
|
|
51698
|
+
__name(registerDebugTool, "registerDebugTool");
|
|
51502
51699
|
function registerStatusTool(server, internal) {
|
|
51700
|
+
if (process.env.DEEP_ROON_DEBUG === "1") registerDebugTool(server);
|
|
51503
51701
|
server.registerTool("roon_status", {
|
|
51504
51702
|
title: "Deep Roon MCP status",
|
|
51505
51703
|
description: "How Deep Roon MCP stands: its version (and whether a newer one is out), the Roon Core it talks to, whether it is enabled in Roon (Settings \u203A Extensions), whether Roon's internal protocol works, and a self-test of a few reads with the tools each one stands for. Use it when a tool fails, or when the user asks whether everything works.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deep-roon-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "An MCP server for Roon that reaches the parts the official API doesn't: reviews, biographies, Qobuz's and TIDAL's shelves, TIDAL's playlists, your history, search across your library and the streaming services",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "René Bouwmeester <21277119+renebouwmeester@users.noreply.github.com>",
|