deep-roon-mcp 0.4.0 → 0.4.6

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-nine tools. Qobuz, TIDAL or both — whatever is signed in on your Core.
26
26
 
27
27
  **Reading** — changes nothing in Roon:
28
28
 
@@ -32,6 +32,7 @@ Version 0.4: fifty-six tools. Qobuz, TIDAL or both — whatever is signed in on
32
32
  | `roon_new` | Qobuz's shelves (new, qobuzissimes, press awards, top, trending, ideal discography) or TIDAL's (new, recommended, top, rising, masters; albums or tracks) — by genre |
33
33
  | `roon_genres` | Qobuz's genres and their ids, or TIDAL's genres and moods |
34
34
  | `roon_album` | An album's page: tracks, release dates, label, barcode, credits |
35
+ | `roon_track_files` | Where your own files are: per track of an album, an artist or a list of tracks, the file path as the Core sees it (and duplicates) — Roon's export data |
35
36
  | `roon_review` | The review Roon shows, in full, with its author |
36
37
  | `roon_bio` | The biography Roon shows, in full, with related artists |
37
38
  | `roon_artist` | An artist's discography at Qobuz or TIDAL, oldest first |
@@ -88,6 +89,7 @@ Version 0.4: fifty-six tools. Qobuz, TIDAL or both — whatever is signed in on
88
89
  | `roon_library_add` | A Qobuz or TIDAL album into your library |
89
90
  | `roon_playlist_create` | A new playlist from tracks |
90
91
  | `roon_dsp_set` | MUSE on or off, one part on or off, or a preset |
92
+ | `roon_radio_favorite` | A station into My Live Radio, or out of it |
91
93
 
92
94
  ## Install
93
95
 
@@ -107,7 +109,7 @@ The settings (Claude Desktop › Settings › Extensions › Deep Roon MCP):
107
109
  | Setting | What it does |
108
110
  |---|---|
109
111
  | 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 |
112
+ | Essentials only | Seventeen tools instead of fifty-nine, for small models |
111
113
  | Use Roon's internal protocol | Off: only the official API — zones, playback, volume, grouping; no search, reviews, Focus or history |
112
114
 
113
115
  ### 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.6";
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");
@@ -16413,6 +16426,71 @@ async function discography(key, wanted) {
16413
16426
  return { service: SERVICE_NAME[service2], artist, albums };
16414
16427
  });
16415
16428
  }
16429
+ async function trackFiles(a) {
16430
+ return withCore(async (c) => {
16431
+ const cb = { type: "ResultCallback<IList<TrackExportInfo>>", name: "cb_result" };
16432
+ let res;
16433
+ if (a.tracks?.length) {
16434
+ if (a.tracks.some((k) => !/^\d+$/.test(k))) throw new RoonError("A track key is a number", "invalid");
16435
+ res = await short(c.call(
16436
+ "Library",
16437
+ "GetTrackExportInfo",
16438
+ [{ type: "IEnumerable<long>", name: "ids" }, cb],
16439
+ buildArgs([Arg.collection(a.tracks.map((k) => buildArgs([Arg.long(BigInt(k))])))]),
16440
+ library(c)
16441
+ ), 2e4);
16442
+ } else if (a.album) {
16443
+ const al = await findAlbum(c, a.album);
16444
+ res = await short(c.call(
16445
+ "Library",
16446
+ "GetTrackExportInfoForAlbums",
16447
+ [{ type: "IEnumerable<AlbumBase>", name: "albums" }, cb],
16448
+ buildArgs([Arg.collection([buildArgs([Arg.ref(al.oid)])])]),
16449
+ library(c)
16450
+ ), 2e4);
16451
+ } else if (a.artist) {
16452
+ const p = await performer(c, a.artist);
16453
+ res = await short(c.call(
16454
+ "Library",
16455
+ "GetTrackExportInfoForPerformers",
16456
+ [{ type: "IEnumerable<PerformerBase>", name: "performers" }, cb],
16457
+ buildArgs([Arg.collection([buildArgs([Arg.ref(p.$ref)])])]),
16458
+ library(c)
16459
+ ), 3e4);
16460
+ } else throw new RoonError("Give an album key, an artist key or track keys", "invalid");
16461
+ if (!res?.success) throw new RoonError(`Roon refused the file list (${res?.status ?? "no answer"})`);
16462
+ if (res.payload.length < 2) return { tracks: [] };
16463
+ const r = new BinaryReader(Uint8Array.from(res.payload));
16464
+ r.integer();
16465
+ const n = r.flexInt();
16466
+ const graph = c.graph;
16467
+ const infos = [];
16468
+ for (let i = 0; i < n && r.remaining > 0; i++) infos.push(graph.readValue(r, 23));
16469
+ await untilLoaded(c, infos.map((x) => field2(x, "Track")).filter(isRef2), 3e3);
16470
+ const paden = /* @__PURE__ */ __name((x) => items(c, field2(x, "DuplicateTracks")).map((d) => field2(d, "FilePath")).filter((p) => typeof p === "string" && !!p), "paden");
16471
+ const tracks2 = infos.map((x) => {
16472
+ const t = obj(c, field2(x, "Track"));
16473
+ const row = { key: String(field2(t, "TrackId") ?? "") };
16474
+ const title = field2(t, "Title");
16475
+ if (title) row.title = title;
16476
+ const al = field2(obj(c, field2(t, "Album")), "Title");
16477
+ if (al) row.album = al;
16478
+ const n2 = field2(t, "TrackNumber");
16479
+ if (n2 != null) row.n = n2;
16480
+ const path2 = field2(x, "FilePath");
16481
+ if (typeof path2 === "string" && path2) row.path = path2;
16482
+ const format = field2(t, "Format");
16483
+ if (format) row.format = format;
16484
+ const dup = paden(x);
16485
+ if (dup.length) row.duplicates = dup;
16486
+ return row;
16487
+ });
16488
+ const gevraagd = a.tracks?.length ?? 0, ontbreekt = gevraagd ? gevraagd - tracks2.length : 0;
16489
+ const zonder = tracks2.filter((t) => !t.path).length + Math.max(0, ontbreekt);
16490
+ const of = gevraagd || tracks2.length;
16491
+ return { tracks: tracks2, ...zonder ? { note: `${zonder} of ${of} ${of === 1 ? "track" : "tracks"} ${zonder === 1 ? "has" : "have"} no file (streaming tracks, or not on this Core's storage)` } : {} };
16492
+ });
16493
+ }
16416
16494
  var SERVICE_NAME, SERVICE_TYPE, NONE2, SECTIONS, library, CREDIT_KIND, cut, choose;
16417
16495
  var init_library = __esm({
16418
16496
  "src/roon/library.ts"() {
@@ -16469,6 +16547,7 @@ var init_library = __esm({
16469
16547
  __name(performer, "performer");
16470
16548
  __name(bio, "bio");
16471
16549
  __name(discography, "discography");
16550
+ __name(trackFiles, "trackFiles");
16472
16551
  }
16473
16552
  });
16474
16553
 
@@ -16478,6 +16557,7 @@ __export(focus_exports, {
16478
16557
  artists: () => artists,
16479
16558
  focus: () => focus,
16480
16559
  library: () => library2,
16560
+ toplevelGenres: () => toplevelGenres,
16481
16561
  tracks: () => tracks,
16482
16562
  work: () => work,
16483
16563
  works: () => works
@@ -16576,6 +16656,28 @@ async function focusMembers(c, f, people = ["artist", "composer", "producer"]) {
16576
16656
  if (f.not_played_within_days) m.push({ name: "ExcludePlayedInLastNDays", propType: 10, value: maybeInt(f.not_played_within_days) });
16577
16657
  if (f.added_within_days) m.push({ name: "RequireImportedInLastNDays", propType: 10, value: maybeInt(f.added_within_days) });
16578
16658
  if (f.hires) m.push({ name: "RequireSampleRates", propType: 23, value: intList([88200, 96e3, 176400, 192e3, 352800, 384e3]) });
16659
+ if (f.sample_rate) {
16660
+ const hz = names(f.sample_rate).map((x) => Math.round(parseFloat(x) * (parseFloat(x) < 1e3 ? 1e3 : 1))).filter((x) => x > 0);
16661
+ if (hz.length) m.push({ name: "RequireSampleRates", propType: 23, value: intList(hz) });
16662
+ }
16663
+ if (f.bits) {
16664
+ const b = names(f.bits).map(Number).filter((x) => x > 0);
16665
+ if (b.length) m.push({ name: "RequireBitsPerSample", propType: 23, value: intList(b) });
16666
+ }
16667
+ if (f.mqa === true) m.push(yes("RequireIsMQA"));
16668
+ else if (f.mqa === false) m.push(yes("ExcludeIsMQA"));
16669
+ if (f.storage) {
16670
+ const whole = await albumInfo(c, []);
16671
+ const want = f.storage.toLowerCase();
16672
+ const locs = items(c, field2(whole, "Locations")).map((it) => field2(it, "Value")).filter(isRef2);
16673
+ await untilLoaded(c, locs, 1500);
16674
+ const hit = locs.find((v) => {
16675
+ const n = String(field2(obj(c, v), "Name") ?? "").toLowerCase();
16676
+ return want === "local" ? !/tidal|qobuz/.test(n) : n.includes(want);
16677
+ });
16678
+ if (!hit) throw new RoonError(`No "${f.storage}" in this library's locations`, "not_found");
16679
+ m.push({ name: "RequireLocations", propType: 23, value: refList2([hit.$ref]) });
16680
+ }
16579
16681
  if (f.live === true) m.push(yes("RequireIsLive"));
16580
16682
  else if (f.live === false) m.push(yes("ExcludeIsLive"));
16581
16683
  if (f.compilation === true) m.push(yes("RequireIsCompilation"));
@@ -16607,10 +16709,20 @@ function ordering(c, kind, table, sort, ascending, descendingByDefault) {
16607
16709
  if (sort === "random") members2.push({ name: "RandomSeed", propType: 0, value: new BinaryWriter().integer(Math.floor(Math.random() * 2147483647)).toBuffer() });
16608
16710
  return [{ name: "Ordering", propType: 23, value: c.structArg(`Sooloos.Broker.Api.${kind}QueryOrdering`, members2) }];
16609
16711
  }
16712
+ async function toplevelGenres(c) {
16713
+ if (toplevelNames) return toplevelNames;
16714
+ const { members: members2 } = await focusMembers(c, {});
16715
+ const info = await albumInfo(c, members2);
16716
+ const hist = items(c, field2(info, "ToplevelGenres")).map((it) => field2(it, "Value"));
16717
+ await untilLoaded(c, hist.filter(isRef2), 2e3);
16718
+ const names2 = hist.map((w) => isRef2(w) ? field2(obj(c, w), "Name") : w).filter((n) => typeof n === "string");
16719
+ if (names2.length) toplevelNames = new Set(names2);
16720
+ return new Set(names2);
16721
+ }
16610
16722
  async function library2(f, sort, ascending, limit3, offset2) {
16611
16723
  return withCore(async (c) => {
16612
16724
  const { members: members2, matched } = await focusMembers(c, f);
16613
- members2.push(...ordering(c, "Album", ALBUM_ORDER, sort, ascending, ["added", "most_played", "popular"]));
16725
+ members2.push(...ordering(c, "Album", ALBUM_ORDER, sort, ascending, ["added", "released", "most_played", "popular"]));
16614
16726
  const out = await virtualQuery(c, "Album", members2, limit3, offset2, (o) => albumRow(c, o));
16615
16727
  return { ...out, matched };
16616
16728
  });
@@ -16652,7 +16764,13 @@ async function focus(f, more, search2, limit3) {
16652
16764
  bit_depths: numbers("BitsPerSample").map((x) => ({ bits: x.w, albums: x.n })),
16653
16765
  live: field2(info, "IsLiveCount") ?? 0,
16654
16766
  compilations: field2(info, "IsCompilationCount") ?? 0,
16655
- roon_picks: field2(info, "IsPickCount") ?? 0
16767
+ roon_picks: field2(info, "IsPickCount") ?? 0,
16768
+ mqa: field2(info, "IsMQACount") ?? 0,
16769
+ // Raw histograms, to be named once measured (13 Oct 2026): where the albums live, and their file formats.
16770
+ storage: hist("Locations").map((x) => {
16771
+ const n = isRef2(x.w) ? String(field2(obj(c, x.w), "Name") ?? "") : String(x.w);
16772
+ return { name: /tidal/i.test(n) ? "TIDAL" : /qobuz/i.test(n) ? "Qobuz" : "Local", albums: x.n };
16773
+ })
16656
16774
  };
16657
16775
  });
16658
16776
  }
@@ -16673,7 +16791,7 @@ async function tracks(f, sort, ascending, limit3, offset2) {
16673
16791
  text: f.text
16674
16792
  }, ["artist", "composer"]);
16675
16793
  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"]));
16794
+ members2.push(...ordering(c, "Track", TRACK_ORDER, sort, ascending, ["released", "most_played", "last_played", "added", "popular"]));
16677
16795
  const out = await virtualQuery(c, "Track", members2, limit3, offset2, (o) => resultRow(c, o));
16678
16796
  return { ...out, matched };
16679
16797
  });
@@ -16711,6 +16829,8 @@ function workRow(c, w) {
16711
16829
  if (typeof l === "number" && l > 0) r.length = duration3(l);
16712
16830
  const n = field2(w, "PerformanceCount");
16713
16831
  if (typeof n === "number") r.performances = n;
16832
+ const imgs = imageKeys(c, w);
16833
+ if (imgs.length) r.images = imgs;
16714
16834
  return r;
16715
16835
  }
16716
16836
  async function works(f, sort, ascending, limit3, offset2) {
@@ -16777,7 +16897,7 @@ async function work(key, max, wiki) {
16777
16897
  };
16778
16898
  });
16779
16899
  }
16780
- var intList, textList, refList2, maybeInt, dateValue, yes, text, names, english, PANEL, ALBUM_ORDER, TRACK_ORDER, ARTIST_ORDER, WORK_ORDER;
16900
+ var intList, textList, refList2, maybeInt, dateValue, yes, text, names, english, PANEL, ALBUM_ORDER, TRACK_ORDER, ARTIST_ORDER, WORK_ORDER, toplevelNames;
16781
16901
  var init_focus = __esm({
16782
16902
  "src/roon/focus.ts"() {
16783
16903
  "use strict";
@@ -16827,6 +16947,8 @@ var init_focus = __esm({
16827
16947
  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
16948
  ARTIST_ORDER = { name: 0, albums: 2, works: 3, most_played: 4, popular: 6 };
16829
16949
  WORK_ORDER = { title: 0, composer: 1, composed: 3, period: 4, performances: 5, popular: 9 };
16950
+ toplevelNames = null;
16951
+ __name(toplevelGenres, "toplevelGenres");
16830
16952
  __name(library2, "library");
16831
16953
  __name(focus, "focus");
16832
16954
  __name(tracks, "tracks");
@@ -49478,6 +49600,7 @@ __name(nowPlaying, "nowPlaying");
49478
49600
  init_intern();
49479
49601
  init_serializer();
49480
49602
  init_writer();
49603
+ init_signature();
49481
49604
  init_session();
49482
49605
  init_log();
49483
49606
  init_graph();
@@ -49935,6 +50058,8 @@ function stationRow(c, o) {
49935
50058
  if (field2(o, "IsInLibrary") === true) r.in_library = true;
49936
50059
  const img = imageKey(c, o);
49937
50060
  if (img) r.image = img;
50061
+ const url2 = field2(obj(c, field2(o, "Image")), "Url");
50062
+ if (typeof url2 === "string" && /^https?:/.test(url2)) r.image_url = url2;
49938
50063
  return r;
49939
50064
  }
49940
50065
  __name(stationRow, "stationRow");
@@ -50030,6 +50155,30 @@ async function playRadio(key, zoneName) {
50030
50155
  return { ok: true, ...r, ...await started2(r.zone, 0) };
50031
50156
  }
50032
50157
  __name(playRadio, "playRadio");
50158
+ async function radioFavorite(key, on) {
50159
+ return withCore(async (c) => {
50160
+ const st = await findStation(c, key);
50161
+ const args = buildArgs([Arg.collection([buildArgs([Arg.ref(st.oid)])])]);
50162
+ if (on) {
50163
+ const res = await c.call("Radio", "AddToLibrary", [{ type: "IList<Channel>", name: "channels" }, { type: "ResultCallback", name: "cb_result" }], args, radioOid(c));
50164
+ if (!res.success) throw new RoonError(`Roon refused (${res.status})`);
50165
+ } else {
50166
+ c.remoting.callMethodNoReply(radioOid(c), formatMethodSignature("Radio", "RemoveFromLibrary", [{ type: "IList<Channel>", name: "channels" }, { type: "ResultCallback", name: "cb_result" }]), args);
50167
+ }
50168
+ log(`radio_favorite: ${field2(st, "Name")} ${on ? "+" : "\u2212"}`);
50169
+ await wait(800);
50170
+ const id = hex3(field2(st, "ChannelId"));
50171
+ const mine = await myStations(c).catch(() => null);
50172
+ const now = mine ? mine.some((x) => hex3(field2(x, "ChannelId")) === id) : void 0;
50173
+ return {
50174
+ ok: now === void 0 || now === on,
50175
+ station: field2(st, "Name"),
50176
+ in_library: now ?? on,
50177
+ note: now === void 0 ? "Roon said ok; it couldn't be read back" : now !== on ? "Roon said ok, but nothing changed" : void 0
50178
+ };
50179
+ });
50180
+ }
50181
+ __name(radioFavorite, "radioFavorite");
50033
50182
 
50034
50183
  // src/core/officialtools.ts
50035
50184
  init_errors();
@@ -50293,6 +50442,16 @@ function registerReadTools(server, internal = true) {
50293
50442
  inputSchema: { key: external_exports.string().describe("An album key."), credits: external_exports.boolean().optional() },
50294
50443
  annotations: READ
50295
50444
  }, ({ key, credits }) => run(() => album(key, credits === true)));
50445
+ reg("roon_track_files", {
50446
+ title: "File paths",
50447
+ description: "Where the user's own music files are: for an album (`album` key), an artist (`artist` key, all their tracks in the library) or a list of `tracks` keys, each track with its `key`, title, album, track number, format and `path` \u2014 the file as the Roon Core sees it (on the Core's storage), plus `duplicates` when Roon has the same track twice. The data behind Roon's own Export. Use it to link Roon's tracks to files, e.g. to join with data the user keeps per file. Streaming tracks (Qobuz, TIDAL) have no path.",
50448
+ inputSchema: {
50449
+ album: external_exports.string().optional().describe("An album key."),
50450
+ artist: external_exports.string().optional().describe("An artist key."),
50451
+ tracks: external_exports.array(external_exports.string()).max(500).optional().describe("Track keys.")
50452
+ },
50453
+ annotations: READ
50454
+ }, ({ album: album2, artist, tracks: tracks2 }) => run(() => trackFiles({ album: album2, artist, tracks: tracks2 })));
50296
50455
  reg("roon_review", {
50297
50456
  title: "Album review",
50298
50457
  description: "The review Roon shows on an album's page, in full, with its author \u2014 by album `key`. When the user asks for the review (\"what does Roon say about this album\", \"show me the review\"), they want to read the review itself: show the text complete and exactly as returned, as a quote with the author's name \u2014 no paraphrase or shortening unless they ask for a summary, and don't go to the web for it. `wikipedia: true` adds the album's Wikipedia text; it also comes by itself when Roon has no review of its own. `max_chars` only when a shorter excerpt is asked for.",
@@ -50325,7 +50484,7 @@ function registerReadTools(server, internal = true) {
50325
50484
  }, () => run(() => internal ? nowPlaying() : nowPlaying2()));
50326
50485
  reg("roon_radio", {
50327
50486
  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`.',
50487
+ 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
50488
  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
50489
  annotations: READ
50331
50490
  }, ({ category, genre, limit: n }) => run(() => radio(category, genre, n ?? 40)));
@@ -50482,6 +50641,7 @@ init_writer();
50482
50641
  init_session();
50483
50642
  init_log();
50484
50643
  init_graph();
50644
+ init_focus();
50485
50645
  var localNow = /* @__PURE__ */ __name(() => {
50486
50646
  const d = /* @__PURE__ */ new Date(), t = /* @__PURE__ */ __name((n) => String(n).padStart(2, "0"), "t");
50487
50647
  return `${d.getFullYear()}-${t(d.getMonth() + 1)}-${t(d.getDate())}T${t(d.getHours())}:${t(d.getMinutes())}:${t(d.getSeconds())}`;
@@ -50526,14 +50686,25 @@ async function dailyPicks(perBox) {
50526
50686
  });
50527
50687
  }
50528
50688
  __name(dailyPicks, "dailyPicks");
50529
- async function newForYou(limit3, offset2) {
50689
+ async function newForYou(limit3, offset2, genre) {
50530
50690
  return withCore(async (c) => {
50531
50691
  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
50692
  if (!res.success) throw new RoonError(`Roon refused New releases for you (${res.status})`);
50533
- const all = items(c, decoded2(c, res.payload));
50693
+ let all = items(c, decoded2(c, res.payload));
50694
+ await untilLoaded(c, all.map((x) => field2(x, "Album")).filter(isRef2), 4e3);
50695
+ const top2 = await toplevelGenres(c);
50696
+ const counts = /* @__PURE__ */ new Map();
50697
+ 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);
50698
+ const genres2 = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, albums]) => ({ name, albums }));
50699
+ if (genre) {
50700
+ const g = genre.toLowerCase();
50701
+ const names2 = /* @__PURE__ */ __name((x) => strings(c, field2(obj(c, field2(x, "Album")), "Genres")).map((n) => n.toLowerCase()), "names");
50702
+ const exact = all.filter((x) => names2(x).includes(g));
50703
+ all = exact.length ? exact : all.filter((x) => names2(x).some((n) => n.includes(g)));
50704
+ }
50534
50705
  const part = all.slice(Math.max(0, offset2), Math.max(0, offset2) + Math.min(Math.max(1, limit3), 100));
50535
50706
  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) => {
50707
+ return { total: all.length, offset: Math.max(0, offset2), genres: genres2, rows: part.map((x) => {
50537
50708
  const a = obj(c, field2(x, "Album"));
50538
50709
  if (!a || field2(a, "Title") === void 0) return null;
50539
50710
  const r = albumRow(c, a);
@@ -50567,10 +50738,43 @@ async function mixes() {
50567
50738
  const r = { key: mixKey(m), kind: gd && field2(gd, "GenreName") ? "genre" : "artist", name: mixName(c, m) };
50568
50739
  const ts = (obj(c, field2(pd ?? gd, "Touchstones"))?.fields?.$items ?? []).filter((x) => typeof x === "string").map(clean).slice(0, 8);
50569
50740
  if (ts.length) r.touchstones = ts;
50741
+ const img = pd ? imageKey(c, pd, "Photo") ?? imageKey(c, pd, "Avatar") : void 0;
50742
+ if (img) r.image = img;
50743
+ const imgs = gd ? imageKeys(c, gd) : [];
50744
+ if (imgs.length) r.images = imgs;
50570
50745
  return r;
50571
50746
  }) }));
50572
50747
  }
50573
50748
  __name(mixes, "mixes");
50749
+ async function mix(key, limit3) {
50750
+ return withCore(async (c) => {
50751
+ const all = await readMixes(c);
50752
+ const m = all.find((x) => mixKey(x) === key) ?? all.find((x) => String(mixName(c, x)).toLowerCase() === key.toLowerCase());
50753
+ if (!m?.oid) throw new RoonError("Unknown mix \u2014 read roon_mixes for today's keys", "not_found");
50754
+ const res = await c.call("Mix", "GetItems", [{ type: "ResultCallback<DataList<MixItem>>", name: "cb_result" }], Buffer.alloc(0), m.oid);
50755
+ if (!res.success) throw new RoonError(`Roon refused the mix (${res.status})`);
50756
+ const its = items(c, decoded2(c, res.payload));
50757
+ const refs = its.flatMap((it) => obj(c, field2(it, "Tracks"))?.fields?.$items ?? []);
50758
+ await untilLoaded(c, refs, 3e3);
50759
+ const tracks2 = its.flatMap((it) => items(c, field2(it, "Tracks"))).filter((t) => field2(t, "Title") !== void 0);
50760
+ const pd = obj(c, field2(m, "PerformerMixDescription"));
50761
+ const head = { key: mixKey(m), name: mixName(c, m) };
50762
+ const img = pd ? imageKey(c, pd, "Photo") ?? imageKey(c, pd, "Avatar") : void 0;
50763
+ if (img) head.image = img;
50764
+ return { mix: head, total: tracks2.length, rows: tracks2.slice(0, Math.min(Math.max(1, limit3), 300)).map((t, i) => {
50765
+ const al = obj(c, field2(t, "Album"));
50766
+ 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 };
50767
+ const ak = field2(al, "AlbumId");
50768
+ if (ak !== void 0 && ak !== null) r.album_key = String(ak);
50769
+ const l = field2(t, "LengthSeconds");
50770
+ if (typeof l === "number" && l > 0) r.length = duration3(l);
50771
+ const im = imageKey(c, al);
50772
+ if (im) r.image = im;
50773
+ return r;
50774
+ }) };
50775
+ });
50776
+ }
50777
+ __name(mix, "mix");
50574
50778
  async function playMix(key, zoneName, how) {
50575
50779
  const p = PRIORITY[how];
50576
50780
  const r = await withCore(async (c) => {
@@ -50660,6 +50864,10 @@ async function playlists(shelfName, genre, mood, limit3, offset2) {
50660
50864
  const key = playlistKey(p);
50661
50865
  seen.set(key, w);
50662
50866
  const r = { key, name: String(field2(p, "Name") ?? "").trim() };
50867
+ const imgs = imageKeys(c, p);
50868
+ if (imgs.length) r.images = imgs;
50869
+ const urls = imageUrls(c, p);
50870
+ if (urls.length) r.image_urls = urls;
50663
50871
  const d = field2(p, "Description");
50664
50872
  if (d) r.description = clean(d).replace(/\s+/g, " ").trim().slice(0, 240);
50665
50873
  const n = field2(p, "TrackCount") ?? field2(p, "ItemCount");
@@ -50705,13 +50913,24 @@ async function findPlaylist2(c, key) {
50705
50913
  }
50706
50914
  __name(findPlaylist2, "findPlaylist");
50707
50915
  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
- })) }));
50916
+ return withCore(async (c) => ({ playlists: allPlaylists(c).list.map((p) => {
50917
+ const r = {
50918
+ key: playlistKey(p),
50919
+ name: field2(p, "Name") ?? "",
50920
+ kind: PLAYLIST_KIND[field2(p, "Type")] ?? String(field2(p, "Type") ?? ""),
50921
+ tracks: field2(p, "TrackCount") ?? field2(p, "ItemCount") ?? void 0,
50922
+ read_only: field2(p, "IsReadOnly") === true || void 0
50923
+ };
50924
+ const imgs = imageKeys(c, p);
50925
+ if (imgs.length) r.images = imgs;
50926
+ const l = field2(p, "LengthSeconds");
50927
+ if (typeof l === "number" && l > 0) r.length = duration3(l);
50928
+ const names2 = (obj(c, field2(p, "SampleArtistNames"))?.fields?.$items ?? []).filter((x) => typeof x === "string").slice(0, 4);
50929
+ if (names2.length) r.artists = names2;
50930
+ const d = field2(p, "Description");
50931
+ if (d) r.description = clean(d).replace(/\s+/g, " ").slice(0, 240);
50932
+ return r;
50933
+ }) }));
50715
50934
  }
50716
50935
  __name(playlists2, "playlists");
50717
50936
  async function playlist(key, limit3) {
@@ -50731,7 +50950,14 @@ async function playlist(key, limit3) {
50731
50950
  return { name: field2(p, "Name"), total: list.length, rows: list.slice(0, n).map((x, i) => {
50732
50951
  const tr = obj(c, field2(obj(c, x), "Track"));
50733
50952
  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 };
50953
+ 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 };
50954
+ const ak = field2(al, "AlbumId");
50955
+ if (ak !== void 0 && ak !== null) r.album_key = String(ak);
50956
+ const im = imageKey(c, al);
50957
+ if (im) r.image = im;
50958
+ const l = field2(tr, "LengthSeconds");
50959
+ if (typeof l === "number" && l > 0) r.length = duration3(l);
50960
+ return r;
50735
50961
  }) };
50736
50962
  });
50737
50963
  }
@@ -51248,6 +51474,10 @@ var FOCUS = {
51248
51474
  picks: external_exports.boolean().optional().describe("Roon's own picks."),
51249
51475
  text: external_exports.string().optional().describe("A word in the title or artist."),
51250
51476
  favorites: external_exports.boolean().optional(),
51477
+ sample_rate: external_exports.string().optional().describe('Sample rates in kHz, comma-separated: "44.1, 96".'),
51478
+ bits: external_exports.string().optional().describe('Bit depths, comma-separated: "16, 24".'),
51479
+ mqa: external_exports.boolean().optional(),
51480
+ storage: external_exports.enum(["tidal", "qobuz", "local"]).optional().describe("Where the albums live."),
51251
51481
  played_within_days: days,
51252
51482
  not_played_within_days: days,
51253
51483
  added_within_days: days
@@ -51336,12 +51566,18 @@ function registerLibraryTools(server) {
51336
51566
  inputSchema: { limit: limit2(30, 8) },
51337
51567
  annotations: READ2
51338
51568
  }, ({ limit: n }) => run(() => dailyPicks(n ?? 8)));
51569
+ server.registerTool("roon_mix", {
51570
+ title: "A Daily Mix's tracks",
51571
+ 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.",
51572
+ inputSchema: { key: external_exports.string().describe("A mix key or its name, from roon_mixes."), limit: limit2(300, 100) },
51573
+ annotations: READ2
51574
+ }, ({ key, limit: n }) => run(() => mix(key, n ?? 100)));
51339
51575
  server.registerTool("roon_new_for_you", {
51340
51576
  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 },
51577
+ 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`).',
51578
+ 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
51579
  annotations: READ2
51344
- }, ({ limit: n, offset: o }) => run(() => newForYou(n ?? 30, o ?? 0)));
51580
+ }, ({ limit: n, offset: o, genre }) => run(() => newForYou(n ?? 30, o ?? 0, genre)));
51345
51581
  server.registerTool("roon_mixes", {
51346
51582
  title: "Daily Mixes",
51347
51583
  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 +51632,12 @@ function registerLibraryTools(server) {
51396
51632
  inputSchema: { source: external_exports.enum(["roon", "qobuz", "tidal"]).optional(), limit: limit2(100, 50) },
51397
51633
  annotations: READ2
51398
51634
  }, ({ source, limit: n }) => run(() => favorites(source ?? "roon", n ?? 50)));
51635
+ server.registerTool("roon_radio_favorite", {
51636
+ title: "A station in My Live Radio",
51637
+ 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.",
51638
+ inputSchema: { key: external_exports.string(), on: external_exports.boolean().optional() },
51639
+ annotations: CHANGES
51640
+ }, ({ key, on }) => run(() => radioFavorite(key, on !== false)));
51399
51641
  server.registerTool("roon_favorite", {
51400
51642
  title: "Heart an album or track",
51401
51643
  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 +51741,39 @@ async function runSelfTest() {
51499
51741
  __name(runSelfTest, "runSelfTest");
51500
51742
 
51501
51743
  // src/tools/status.ts
51744
+ init_session();
51745
+ function registerDebugTool(server) {
51746
+ server.registerTool("roon_debug", {
51747
+ title: "Graph fields (development)",
51748
+ 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.",
51749
+ inputSchema: { type: external_exports.string().optional(), match: external_exports.string().optional(), limit: external_exports.number().int().min(1).max(200).optional() },
51750
+ annotations: { readOnlyHint: true }
51751
+ }, ({ type, match, limit: limit3 }) => run(() => withCore(async (c) => {
51752
+ const preview = /* @__PURE__ */ __name((v) => {
51753
+ if (v && typeof v === "object" && "$ref" in v) {
51754
+ const o = c.graph.getObject(v.$ref);
51755
+ return { $ref: String(v.$ref), type: o?.typeName };
51756
+ }
51757
+ if (Array.isArray(v)) return v.slice(0, 3).map(preview);
51758
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).slice(0, 6).map(([k, x]) => [k, preview(x)]));
51759
+ return typeof v === "string" ? v.slice(0, 80) : typeof v === "bigint" ? String(v) : v;
51760
+ }, "preview");
51761
+ if (!type) {
51762
+ const counts = /* @__PURE__ */ new Map();
51763
+ for (const o of c.graph.objects.values()) counts.set(o.typeName, (counts.get(o.typeName) ?? 0) + 1);
51764
+ return { types: [...counts].filter(([n]) => !match || n.toLowerCase().includes(match.toLowerCase())).sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, n })) };
51765
+ }
51766
+ const all = c.graph.findByType(type);
51767
+ return { count: all.length, objects: all.slice(0, limit3 ?? 3).map((o) => ({
51768
+ oid: String(o.oid),
51769
+ type: o.typeName,
51770
+ fields: Object.fromEntries(Object.entries(o.fields ?? {}).map(([k, v]) => [k.replace(/^.*::/, ""), preview(v)]))
51771
+ })) };
51772
+ })));
51773
+ }
51774
+ __name(registerDebugTool, "registerDebugTool");
51502
51775
  function registerStatusTool(server, internal) {
51776
+ if (process.env.DEEP_ROON_DEBUG === "1") registerDebugTool(server);
51503
51777
  server.registerTool("roon_status", {
51504
51778
  title: "Deep Roon MCP status",
51505
51779
  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.6",
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>",