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 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-six tools. Qobuz, TIDAL or both — whatever is signed in on your Core.
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-six, for small models |
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
@@ -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.0";
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
- const all = items(c, decoded2(c, res.payload));
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
- key: playlistKey(p),
50710
- name: field2(p, "Name") ?? "",
50711
- kind: PLAYLIST_KIND[field2(p, "Type")] ?? String(field2(p, "Type") ?? ""),
50712
- tracks: field2(p, "TrackCount") ?? field2(p, "ItemCount") ?? void 0,
50713
- read_only: field2(p, "IsReadOnly") === true || void 0
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
- return { n: i + 1, key: String(field2(tr, "TrackId") ?? ""), title: field2(tr, "Title") ?? null, artist: clean(field2(al, "PerformedBy") ?? ""), album: field2(al, "Title") ?? void 0 };
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.0",
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>",