deep-roon-mcp 0.3.8 → 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.
@@ -3635,12 +3635,12 @@ var require_utils = __commonJS({
3635
3635
  let output2 = "";
3636
3636
  for (let i = 0; i < input2.length; i++) {
3637
3637
  if (input2[i] === "%" && i + 2 < input2.length) {
3638
- const hex3 = input2.slice(i + 1, i + 3);
3639
- if (isHexPair(hex3)) {
3640
- const normalizedHex = hex3.toUpperCase();
3641
- const decoded3 = String.fromCharCode(parseInt(normalizedHex, 16));
3642
- if (decodeUnreserved && isUnreserved(decoded3)) {
3643
- output2 += decoded3;
3638
+ const hex4 = input2.slice(i + 1, i + 3);
3639
+ if (isHexPair(hex4)) {
3640
+ const normalizedHex = hex4.toUpperCase();
3641
+ const decoded4 = String.fromCharCode(parseInt(normalizedHex, 16));
3642
+ if (decodeUnreserved && isUnreserved(decoded4)) {
3643
+ output2 += decoded4;
3644
3644
  } else {
3645
3645
  output2 += "%" + normalizedHex;
3646
3646
  }
@@ -3658,12 +3658,12 @@ var require_utils = __commonJS({
3658
3658
  for (let i = 0; i < input2.length; i++) {
3659
3659
  const ch = input2[i];
3660
3660
  if (ch === "%" && i + 2 < input2.length) {
3661
- const hex3 = input2.slice(i + 1, i + 3);
3662
- if (isHexPair(hex3)) {
3663
- const normalizedHex = hex3.toUpperCase();
3664
- const decoded3 = String.fromCharCode(parseInt(normalizedHex, 16));
3665
- if (decoded3 !== "." && isUnreserved(decoded3)) {
3666
- output2 += decoded3;
3661
+ const hex4 = input2.slice(i + 1, i + 3);
3662
+ if (isHexPair(hex4)) {
3663
+ const normalizedHex = hex4.toUpperCase();
3664
+ const decoded4 = String.fromCharCode(parseInt(normalizedHex, 16));
3665
+ if (decoded4 !== "." && isUnreserved(decoded4)) {
3666
+ output2 += decoded4;
3667
3667
  } else {
3668
3668
  output2 += "%" + normalizedHex;
3669
3669
  }
@@ -3701,9 +3701,9 @@ var require_utils = __commonJS({
3701
3701
  for (let i = 0; i < input2.length; i++) {
3702
3702
  const ch = input2[i];
3703
3703
  if (ch === "%" && i + 2 < input2.length) {
3704
- const hex3 = input2.slice(i + 1, i + 3);
3705
- if (isHexPair(hex3)) {
3706
- output2 += "%" + hex3.toUpperCase();
3704
+ const hex4 = input2.slice(i + 1, i + 3);
3705
+ if (isHexPair(hex4)) {
3706
+ output2 += "%" + hex4.toUpperCase();
3707
3707
  i += 2;
3708
3708
  continue;
3709
3709
  }
@@ -3740,9 +3740,9 @@ var require_utils = __commonJS({
3740
3740
  for (let i = 0; i < input2.length; i++) {
3741
3741
  const ch = input2[i];
3742
3742
  if (ch === "%" && i + 2 < input2.length) {
3743
- const hex3 = input2.slice(i + 1, i + 3);
3744
- if (isHexPair(hex3)) {
3745
- output2 += "%" + hex3.toUpperCase();
3743
+ const hex4 = input2.slice(i + 1, i + 3);
3744
+ if (isHexPair(hex4)) {
3745
+ output2 += "%" + hex4.toUpperCase();
3746
3746
  i += 2;
3747
3747
  continue;
3748
3748
  }
@@ -3792,12 +3792,12 @@ var require_utils = __commonJS({
3792
3792
  for (let i = 0; i < input2.length; i++) {
3793
3793
  const ch = input2[i];
3794
3794
  if (ch === "%" && i + 2 < input2.length) {
3795
- const hex3 = input2.slice(i + 1, i + 3);
3796
- if (isHexPair(hex3)) {
3797
- const normalizedHex = hex3.toUpperCase();
3798
- const decoded3 = String.fromCharCode(parseInt(normalizedHex, 16));
3799
- if (isUnreserved(decoded3)) {
3800
- output2 += decoded3;
3795
+ const hex4 = input2.slice(i + 1, i + 3);
3796
+ if (isHexPair(hex4)) {
3797
+ const normalizedHex = hex4.toUpperCase();
3798
+ const decoded4 = String.fromCharCode(parseInt(normalizedHex, 16));
3799
+ if (isUnreserved(decoded4)) {
3800
+ output2 += decoded4;
3801
3801
  } else {
3802
3802
  output2 += "%" + normalizedHex;
3803
3803
  }
@@ -3833,9 +3833,9 @@ var require_utils = __commonJS({
3833
3833
  let output2 = "";
3834
3834
  for (let i = 0; i < input2.length; i++) {
3835
3835
  if (input2[i] === "%" && i + 2 < input2.length) {
3836
- const hex3 = input2.slice(i + 1, i + 3);
3837
- if (isHexPair(hex3)) {
3838
- output2 += "%" + hex3.toUpperCase();
3836
+ const hex4 = input2.slice(i + 1, i + 3);
3837
+ if (isHexPair(hex4)) {
3838
+ output2 += "%" + hex4.toUpperCase();
3839
3839
  i += 2;
3840
3840
  continue;
3841
3841
  }
@@ -9225,24 +9225,24 @@ function editField(info, fieldSuffix, list = false) {
9225
9225
  const metadataValue = list ? decodeStringList(rawMeta) : rawMeta;
9226
9226
  return { value, metadataValue, edited: member(wrapper, "::HasEditLayer") === true };
9227
9227
  }
9228
- function parseAlbumEditInfo(decoded3) {
9228
+ function parseAlbumEditInfo(decoded4) {
9229
9229
  return {
9230
- title: editField(decoded3, "Title"),
9231
- version: editField(decoded3, "Version"),
9232
- performedBy: editField(decoded3, "PerformedBy"),
9233
- genres: editField(decoded3, "Genres", true),
9234
- labels: editField(decoded3, "Labels", true),
9235
- rating: editField(decoded3, "Rating"),
9236
- type: editField(decoded3, "Type"),
9237
- isCompilation: editField(decoded3, "IsCompilation"),
9238
- isLive: editField(decoded3, "IsLive"),
9239
- isPick: editField(decoded3, "IsPick"),
9240
- containsExplicitContent: editField(decoded3, "ContainsExplicitContent"),
9241
- isUserHidden: editField(decoded3, "IsUserHidden"),
9242
- country: editField(decoded3, "Country"),
9243
- catalogNumber: editField(decoded3, "CatalogNumber"),
9244
- productCode: editField(decoded3, "ProductCode"),
9245
- raw: decoded3
9230
+ title: editField(decoded4, "Title"),
9231
+ version: editField(decoded4, "Version"),
9232
+ performedBy: editField(decoded4, "PerformedBy"),
9233
+ genres: editField(decoded4, "Genres", true),
9234
+ labels: editField(decoded4, "Labels", true),
9235
+ rating: editField(decoded4, "Rating"),
9236
+ type: editField(decoded4, "Type"),
9237
+ isCompilation: editField(decoded4, "IsCompilation"),
9238
+ isLive: editField(decoded4, "IsLive"),
9239
+ isPick: editField(decoded4, "IsPick"),
9240
+ containsExplicitContent: editField(decoded4, "ContainsExplicitContent"),
9241
+ isUserHidden: editField(decoded4, "IsUserHidden"),
9242
+ country: editField(decoded4, "Country"),
9243
+ catalogNumber: editField(decoded4, "CatalogNumber"),
9244
+ productCode: editField(decoded4, "ProductCode"),
9245
+ raw: decoded4
9246
9246
  };
9247
9247
  }
9248
9248
  var ROOT_SERVICE_GUID, RoonClient, ALBUM_EDIT, EDIT_OPTIONAL_VAL_INT, EDIT_REQUIRED_REF_STR, EDIT_LIST_STR, LIBRARY_EDIT;
@@ -9532,14 +9532,14 @@ var init_client = __esm({
9532
9532
  * VirtualAlbumLiteQuery oid -> RetainPage(0) -> items push.
9533
9533
  */
9534
9534
  async searchAlbums(term, pageSize = 40) {
9535
- const criteria = this.structArg("Sooloos.Broker.Api.AlbumQueryCriteria", [
9535
+ const criteria2 = this.structArg("Sooloos.Broker.Api.AlbumQueryCriteria", [
9536
9536
  { name: "UiLanguage", propType: 20 /* String */, value: new BinaryWriter().string("en").toBuffer() },
9537
9537
  { name: "TextFilter", propType: 20 /* String */, value: new BinaryWriter().string(term).toBuffer() }
9538
9538
  ]);
9539
9539
  const params = this.structArg("Sooloos.Broker.Api.VirtualQueryParameters", [
9540
9540
  { name: "PageSize", propType: 0 /* Int */, value: new BinaryWriter().integer(pageSize).toBuffer() }
9541
9541
  ]);
9542
- const args = Buffer.concat([buildArgs([Arg.sooid(this.profile())]), criteria, params]);
9542
+ const args = Buffer.concat([buildArgs([Arg.sooid(this.profile())]), criteria2, params]);
9543
9543
  const res = await this.call(
9544
9544
  "Library",
9545
9545
  "VirtualAlbumQuery",
@@ -9716,8 +9716,8 @@ var init_client = __esm({
9716
9716
  this.serviceOid("Library")
9717
9717
  );
9718
9718
  if (!res.success) throw new Error(`GetAlbumEditInfo failed: ${res.status}`);
9719
- const decoded3 = this.graph.decodeReturnValue(Uint8Array.from(res.payload));
9720
- return parseAlbumEditInfo(decoded3);
9719
+ const decoded4 = this.graph.decodeReturnValue(Uint8Array.from(res.payload));
9720
+ return parseAlbumEditInfo(decoded4);
9721
9721
  }
9722
9722
  // --- metadata editing: write side (Phase E) ---
9723
9723
  /**
@@ -9913,7 +9913,7 @@ async function exportPlayHistory(client2, options) {
9913
9913
  if (client2.graph.findByType("HistoryPlay").length > 0) {
9914
9914
  throw new Error("history export requires a fresh client graph with no HistoryPlay objects");
9915
9915
  }
9916
- const criteria = inlineStruct(
9916
+ const criteria2 = inlineStruct(
9917
9917
  client2.remoting.defineType("Sooloos.Broker.Api.HistoryQueryCriteria", [])
9918
9918
  );
9919
9919
  const params = client2.structArg("Sooloos.Broker.Api.VirtualQueryParameters", [
@@ -9923,7 +9923,7 @@ async function exportPlayHistory(client2, options) {
9923
9923
  value: new BinaryWriter().integer(pageSize).toBuffer()
9924
9924
  }
9925
9925
  ]);
9926
- const args = Buffer.concat([buildArgs([Arg.sooid(client2.profile())]), criteria, params]);
9926
+ const args = Buffer.concat([buildArgs([Arg.sooid(client2.profile())]), criteria2, params]);
9927
9927
  const result = await client2.call(
9928
9928
  "Library",
9929
9929
  "VirtualHistoryQuery",
@@ -13953,9 +13953,9 @@ function coreInfo(host, port = 9330, ms = 5e3) {
13953
13953
  });
13954
13954
  }
13955
13955
  function brokerId(coreId) {
13956
- const hex3 = coreId.replace(/-/g, "");
13957
- if (!/^[0-9a-f]{32}$/i.test(hex3)) throw new Error(`not a Roon core_id: ${coreId}`);
13958
- const b = Buffer.from(hex3, "hex");
13956
+ const hex4 = coreId.replace(/-/g, "");
13957
+ if (!/^[0-9a-f]{32}$/i.test(hex4)) throw new Error(`not a Roon core_id: ${coreId}`);
13958
+ const b = Buffer.from(hex4, "hex");
13959
13959
  return Buffer.concat([Buffer.from(b.subarray(0, 4)).reverse(), Buffer.from(b.subarray(4, 6)).reverse(), Buffer.from(b.subarray(6, 8)).reverse(), b.subarray(8)]);
13960
13960
  }
13961
13961
  var import_dgram, import_crypto, ROON_SERVICE_ID, SOOD_PORT;
@@ -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.3.8";
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");
@@ -16207,7 +16220,7 @@ async function findAlbum(c, key) {
16207
16220
  }
16208
16221
  throw new RoonError(`Roon knows no album with key ${key}`, "not_found");
16209
16222
  }
16210
- async function findTracks(c, keys) {
16223
+ async function findTracks(c, keys, lenient = false) {
16211
16224
  const look = /* @__PURE__ */ __name(() => {
16212
16225
  const all = [...c.graph.objects.values()].filter((o) => /\.Track(Lite)?$/.test(o.typeName) && field2(o, "Title") !== void 0);
16213
16226
  return keys.map((k) => all.find((o) => String(field2(o, "TrackId") ?? "") === k));
@@ -16227,6 +16240,7 @@ async function findTracks(c, keys) {
16227
16240
  tracks2 = look();
16228
16241
  }
16229
16242
  const gone = keys.filter((_, i) => !tracks2[i]);
16243
+ if (gone.length && lenient) return tracks2;
16230
16244
  if (gone.length) throw new RoonError(`Roon doesn't know ${gone.length} of the ${keys.length} tracks (keys ${gone.join(", ")})`, "not_found");
16231
16245
  return tracks2;
16232
16246
  }
@@ -16477,6 +16491,7 @@ __export(focus_exports, {
16477
16491
  artists: () => artists,
16478
16492
  focus: () => focus,
16479
16493
  library: () => library2,
16494
+ toplevelGenres: () => toplevelGenres,
16480
16495
  tracks: () => tracks,
16481
16496
  work: () => work,
16482
16497
  works: () => works
@@ -16486,13 +16501,13 @@ async function virtualQuery(c, kind, members2, limit3, offset2, row) {
16486
16501
  const missing = members2.find((f) => !schema.some((m) => m.shortName === f.name));
16487
16502
  if (missing) throw new RoonError(`The filter ${missing.name} doesn't exist for ${kind.toLowerCase()}s in this Roon version`, "invalid");
16488
16503
  const size = Math.min(Math.max(1, limit3), 100), page = Math.floor(Math.max(0, offset2) / size);
16489
- const criteria = c.structArg(`Sooloos.Broker.Api.${kind}QueryCriteria`, [english(), ...members2]);
16504
+ const criteria2 = c.structArg(`Sooloos.Broker.Api.${kind}QueryCriteria`, [english(), ...members2]);
16490
16505
  const params = c.structArg("Sooloos.Broker.Api.VirtualQueryParameters", [{ name: "PageSize", propType: 0, value: new BinaryWriter().integer(size).toBuffer() }]);
16491
16506
  const res = await c.call(
16492
16507
  "Library",
16493
16508
  `Virtual${kind}Query`,
16494
16509
  [{ type: "Sooid", name: "profileid" }, { type: `${kind}QueryCriteria`, name: "criteria" }, { type: "VirtualQueryParameters", name: "queryparams" }, { type: `ResultCallback<Virtual${kind}LiteQuery>`, name: "cb_result" }],
16495
- Buffer.concat([buildArgs([Arg.sooid(c.profile())]), criteria, params]),
16510
+ Buffer.concat([buildArgs([Arg.sooid(c.profile())]), criteria2, params]),
16496
16511
  c.serviceOid("Library")
16497
16512
  );
16498
16513
  if (!res.success) throw new RoonError(`Roon refused the ${kind.toLowerCase()} query (${res.status})`);
@@ -16527,12 +16542,12 @@ async function virtualQuery(c, kind, members2, limit3, offset2, row) {
16527
16542
  }
16528
16543
  }
16529
16544
  async function albumInfo(c, members2) {
16530
- const criteria = c.structArg("Sooloos.Broker.Api.AlbumQueryCriteria", [english(), ...members2]);
16545
+ const criteria2 = c.structArg("Sooloos.Broker.Api.AlbumQueryCriteria", [english(), ...members2]);
16531
16546
  const res = await c.call(
16532
16547
  "Library",
16533
16548
  "GetAlbumQueryInfo",
16534
16549
  [{ type: "Sooid", name: "profileid" }, { type: "AlbumQueryCriteria", name: "criteria" }, { type: "ResultCallback<AlbumQueryInfo>", name: "cb_result" }],
16535
- Buffer.concat([buildArgs([Arg.sooid(c.profile())]), criteria]),
16550
+ Buffer.concat([buildArgs([Arg.sooid(c.profile())]), criteria2]),
16536
16551
  c.serviceOid("Library")
16537
16552
  );
16538
16553
  if (!res.success) throw new RoonError(`Roon refused the Focus panel (${res.status})`);
@@ -16575,6 +16590,28 @@ async function focusMembers(c, f, people = ["artist", "composer", "producer"]) {
16575
16590
  if (f.not_played_within_days) m.push({ name: "ExcludePlayedInLastNDays", propType: 10, value: maybeInt(f.not_played_within_days) });
16576
16591
  if (f.added_within_days) m.push({ name: "RequireImportedInLastNDays", propType: 10, value: maybeInt(f.added_within_days) });
16577
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
+ }
16578
16615
  if (f.live === true) m.push(yes("RequireIsLive"));
16579
16616
  else if (f.live === false) m.push(yes("ExcludeIsLive"));
16580
16617
  if (f.compilation === true) m.push(yes("RequireIsCompilation"));
@@ -16606,10 +16643,20 @@ function ordering(c, kind, table, sort, ascending, descendingByDefault) {
16606
16643
  if (sort === "random") members2.push({ name: "RandomSeed", propType: 0, value: new BinaryWriter().integer(Math.floor(Math.random() * 2147483647)).toBuffer() });
16607
16644
  return [{ name: "Ordering", propType: 23, value: c.structArg(`Sooloos.Broker.Api.${kind}QueryOrdering`, members2) }];
16608
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
+ }
16609
16656
  async function library2(f, sort, ascending, limit3, offset2) {
16610
16657
  return withCore(async (c) => {
16611
16658
  const { members: members2, matched } = await focusMembers(c, f);
16612
- 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"]));
16613
16660
  const out = await virtualQuery(c, "Album", members2, limit3, offset2, (o) => albumRow(c, o));
16614
16661
  return { ...out, matched };
16615
16662
  });
@@ -16651,7 +16698,13 @@ async function focus(f, more, search2, limit3) {
16651
16698
  bit_depths: numbers("BitsPerSample").map((x) => ({ bits: x.w, albums: x.n })),
16652
16699
  live: field2(info, "IsLiveCount") ?? 0,
16653
16700
  compilations: field2(info, "IsCompilationCount") ?? 0,
16654
- 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
+ })
16655
16708
  };
16656
16709
  });
16657
16710
  }
@@ -16672,7 +16725,7 @@ async function tracks(f, sort, ascending, limit3, offset2) {
16672
16725
  text: f.text
16673
16726
  }, ["artist", "composer"]);
16674
16727
  if (f.title) members2.push(text("TrackTitleTextFilter", f.title));
16675
- 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"]));
16676
16729
  const out = await virtualQuery(c, "Track", members2, limit3, offset2, (o) => resultRow(c, o));
16677
16730
  return { ...out, matched };
16678
16731
  });
@@ -16710,6 +16763,8 @@ function workRow(c, w) {
16710
16763
  if (typeof l === "number" && l > 0) r.length = duration3(l);
16711
16764
  const n = field2(w, "PerformanceCount");
16712
16765
  if (typeof n === "number") r.performances = n;
16766
+ const imgs = imageKeys(c, w);
16767
+ if (imgs.length) r.images = imgs;
16713
16768
  return r;
16714
16769
  }
16715
16770
  async function works(f, sort, ascending, limit3, offset2) {
@@ -16776,7 +16831,7 @@ async function work(key, max, wiki) {
16776
16831
  };
16777
16832
  });
16778
16833
  }
16779
- 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;
16780
16835
  var init_focus = __esm({
16781
16836
  "src/roon/focus.ts"() {
16782
16837
  "use strict";
@@ -16826,6 +16881,8 @@ var init_focus = __esm({
16826
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 };
16827
16882
  ARTIST_ORDER = { name: 0, albums: 2, works: 3, most_played: 4, popular: 6 };
16828
16883
  WORK_ORDER = { title: 0, composer: 1, composed: 3, period: 4, performances: 5, popular: 9 };
16884
+ toplevelNames = null;
16885
+ __name(toplevelGenres, "toplevelGenres");
16829
16886
  __name(library2, "library");
16830
16887
  __name(focus, "focus");
16831
16888
  __name(tracks, "tracks");
@@ -17751,14 +17808,14 @@ function isValidJWT(jwt2, alg) {
17751
17808
  if (!header)
17752
17809
  return false;
17753
17810
  const base643 = header.replace(/-/g, "+").replace(/_/g, "/").padEnd(header.length + (4 - header.length % 4) % 4, "=");
17754
- const decoded3 = JSON.parse(atob(base643));
17755
- if (typeof decoded3 !== "object" || decoded3 === null)
17811
+ const decoded4 = JSON.parse(atob(base643));
17812
+ if (typeof decoded4 !== "object" || decoded4 === null)
17756
17813
  return false;
17757
- if ("typ" in decoded3 && decoded3?.typ !== "JWT")
17814
+ if ("typ" in decoded4 && decoded4?.typ !== "JWT")
17758
17815
  return false;
17759
- if (!decoded3.alg)
17816
+ if (!decoded4.alg)
17760
17817
  return false;
17761
- if (alg && decoded3.alg !== alg)
17818
+ if (alg && decoded4.alg !== alg)
17762
17819
  return false;
17763
17820
  return true;
17764
17821
  } catch {
@@ -21983,8 +22040,8 @@ function uint8ArrayToBase64url(bytes) {
21983
22040
  return uint8ArrayToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
21984
22041
  }
21985
22042
  __name(uint8ArrayToBase64url, "uint8ArrayToBase64url");
21986
- function hexToUint8Array(hex3) {
21987
- const cleanHex = hex3.replace(/^0x/, "");
22043
+ function hexToUint8Array(hex4) {
22044
+ const cleanHex = hex4.replace(/^0x/, "");
21988
22045
  if (cleanHex.length % 2 !== 0) {
21989
22046
  throw new Error("Invalid hex string length");
21990
22047
  }
@@ -49295,22 +49352,32 @@ init_library();
49295
49352
  init_intern();
49296
49353
  init_session();
49297
49354
  init_graph();
49355
+ init_library();
49298
49356
  async function history(limit3) {
49299
49357
  return withCore(async (first) => {
49300
49358
  const c = first.graph.findByType("HistoryPlay").length > 0 ? await fresh() : first;
49301
49359
  const out = await exportPlayHistory(c, { limit: Math.min(Math.max(0, limit3), 500) });
49360
+ const keys = [...new Set(out.events.map((e) => e.roonTrackId).filter((k) => !!k))].slice(0, 100);
49361
+ const byKey = /* @__PURE__ */ new Map();
49362
+ if (keys.length) {
49363
+ const tracks2 = await findTracks(c, keys, true).catch(() => []);
49364
+ for (const t of tracks2) if (t) byKey.set(String(field2(t, "TrackId")), t);
49365
+ }
49302
49366
  closeSoon("after the history");
49303
49367
  return {
49304
49368
  total: out.total ?? null,
49305
- plays: out.events.map((e) => ({
49306
- at: localTime(e.playedAt),
49307
- title: e.title,
49308
- artist: clean(e.artist),
49309
- album: e.album ?? void 0,
49310
- played_pct: e.completionPct ?? void 0,
49311
- track_key: e.roonTrackId ?? void 0
49312
- }))
49313
- // for roon_play_tracks (13 Oct 2026, Rosso's history)
49369
+ plays: out.events.map((e) => {
49370
+ const r = { at: localTime(e.playedAt), title: e.title, artist: clean(e.artist), album: e.album ?? void 0, played_pct: e.completionPct ?? void 0, track_key: e.roonTrackId ?? void 0 };
49371
+ const t = e.roonTrackId ? byKey.get(e.roonTrackId) : void 0;
49372
+ if (t) {
49373
+ const al = obj(c, field2(t, "Album"));
49374
+ const ak = field2(al, "AlbumId");
49375
+ if (ak !== void 0 && ak !== null) r.album_key = String(ak);
49376
+ const img = imageKey(c, al);
49377
+ if (img) r.image = img;
49378
+ }
49379
+ return r;
49380
+ })
49314
49381
  };
49315
49382
  });
49316
49383
  }
@@ -49463,312 +49530,14 @@ async function nowPlaying() {
49463
49530
  }
49464
49531
  __name(nowPlaying, "nowPlaying");
49465
49532
 
49466
- // src/core/officialtools.ts
49467
- init_errors();
49468
- init_official();
49469
- var call = /* @__PURE__ */ __name((fn) => new Promise((res, rej) => fn((err) => err ? rej(new RoonError(`Roon refused: ${err}`)) : res())), "call");
49470
- var duration4 = /* @__PURE__ */ __name((s) => {
49471
- const t = Math.round(s), h = Math.floor(t / 3600), m = Math.floor(t % 3600 / 60), r = t % 60;
49472
- return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}` : `${m}:${String(r).padStart(2, "0")}`;
49473
- }, "duration");
49474
- var wait2 = /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "wait");
49475
- async function zoneOf(name) {
49476
- const t = await paired();
49477
- await officialReady();
49478
- const z2 = officialZone(name);
49479
- if (!z2) throw new RoonError(`No Roon zone called "${name}"; Roon has: ${officialZones().map((x) => x.display_name).join(", ")}`, "not_found");
49480
- return { t, z: z2 };
49481
- }
49482
- __name(zoneOf, "zoneOf");
49483
- async function zones3() {
49484
- await officialReady();
49485
- const all = officialZones();
49486
- return {
49487
- zones: [...new Set(all.flatMap((z2) => z2.outputs.map((o) => o.display_name)))].sort(),
49488
- groups: all.filter((z2) => z2.outputs.length > 1).map((z2) => z2.outputs.map((o) => o.display_name))
49489
- };
49490
- }
49491
- __name(zones3, "zones");
49492
- async function nowPlaying2() {
49493
- await officialReady();
49494
- return { zones: officialZones().map((z2) => {
49495
- const r = { zone: z2.display_name, state: z2.state };
49496
- const v = z2.outputs.find((o) => o.volume)?.volume;
49497
- if (v) {
49498
- r.volume = v.value;
49499
- r.volume_max = v.max;
49500
- if (v.is_muted) r.muted = true;
49501
- }
49502
- if (z2.settings?.shuffle) r.shuffle = true;
49503
- if (z2.settings?.loop && z2.settings.loop !== "disabled") r.repeat = z2.settings.loop === "loop_one" ? "one" : "all";
49504
- if (z2.settings?.auto_radio) r.roon_radio = true;
49505
- const np = z2.now_playing;
49506
- if (np && z2.state !== "stopped") {
49507
- if (np.three_line?.line1) {
49508
- r.title = np.three_line.line1;
49509
- if (np.three_line.line2) r.artist = np.three_line.line2;
49510
- if (np.three_line.line3) r.album = np.three_line.line3;
49511
- }
49512
- if (typeof np.seek_position === "number") r.position = duration4(np.seek_position);
49513
- if (typeof np.length === "number") r.length = duration4(np.length);
49514
- }
49515
- return r;
49516
- }) };
49517
- }
49518
- __name(nowPlaying2, "nowPlaying");
49519
- async function control(name, action) {
49520
- const { t, z: z2 } = await zoneOf(name);
49521
- await call((cb) => t.control(z2.zone_id, action, cb));
49522
- const expect = { pause: ["paused"], stop: ["stopped", "paused"], play: ["playing", "loading"] };
49523
- let state = officialZone(z2.display_name)?.state;
49524
- if (expect[action]) {
49525
- const end = Date.now() + 3e3;
49526
- while (Date.now() < end && !expect[action].includes(state ?? "")) {
49527
- await wait2(150);
49528
- state = officialZone(z2.display_name)?.state;
49529
- }
49530
- }
49531
- return { ok: !expect[action] || expect[action].includes(state ?? ""), zone: z2.display_name, action, state };
49532
- }
49533
- __name(control, "control");
49534
- async function seek(name, seconds) {
49535
- const { t, z: z2 } = await zoneOf(name);
49536
- await call((cb) => t.seek(z2.zone_id, "absolute", Math.max(0, Math.round(seconds)), cb));
49537
- return { ok: true, zone: z2.display_name, position: duration4(seconds) };
49538
- }
49539
- __name(seek, "seek");
49540
- async function settings(name, s) {
49541
- if (s.roon_radio === "now") throw new RoonError('"Start Roon Radio now" needs the internal protocol (switched off)', "invalid");
49542
- const { t, z: z2 } = await zoneOf(name);
49543
- const change = {};
49544
- if (s.shuffle !== void 0) change.shuffle = s.shuffle;
49545
- if (s.repeat !== void 0) change.loop = { off: "disabled", all: "loop", one: "loop_one" }[s.repeat];
49546
- if (s.roon_radio !== void 0) change.auto_radio = s.roon_radio;
49547
- if (!Object.keys(change).length) throw new RoonError("Nothing to set: give shuffle, repeat or roon_radio", "invalid");
49548
- await call((cb) => t.change_settings(z2.zone_id, change, cb));
49549
- return { ok: true, zone: z2.display_name, ...s };
49550
- }
49551
- __name(settings, "settings");
49552
- async function transfer(from, to) {
49553
- const a = await zoneOf(from), b = await zoneOf(to);
49554
- if (a.z.zone_id === b.z.zone_id) throw new RoonError("From and to are the same zone", "invalid");
49555
- await call((cb) => a.t.transfer_zone(a.z.zone_id, b.z.zone_id, cb));
49556
- return { ok: true, from: a.z.display_name, to: b.z.display_name };
49557
- }
49558
- __name(transfer, "transfer");
49559
- async function group(main2, join) {
49560
- const m = await zoneOf(main2);
49561
- const outs = [...m.z.outputs.map((o) => o.output_id)];
49562
- for (const n of join) {
49563
- const j = await zoneOf(n);
49564
- for (const o of j.z.outputs) if (!outs.includes(o.output_id)) outs.push(o.output_id);
49565
- }
49566
- if (outs.length === m.z.outputs.length) throw new RoonError("Nothing to group: name at least one other zone", "invalid");
49567
- await call((cb) => m.t.group_outputs(outs, cb));
49568
- return { ok: true, group: outs.map((id) => officialZones().flatMap((z2) => z2.outputs).find((o) => o.output_id === id)?.display_name ?? id) };
49569
- }
49570
- __name(group, "group");
49571
- async function ungroup(name, only) {
49572
- const { t, z: z2 } = await zoneOf(name);
49573
- if (z2.outputs.length < 2) throw new RoonError(`${z2.display_name} isn't in a group`, "invalid");
49574
- const off = only ? z2.outputs.filter((o) => o.display_name.toLowerCase().includes(only.toLowerCase())) : z2.outputs;
49575
- if (!off.length) throw new RoonError(`"${only}" isn't in this group`, "not_found");
49576
- await call((cb) => t.ungroup_outputs(off.map((o) => o.output_id), cb));
49577
- return { ok: true, released: off.map((o) => o.display_name) };
49578
- }
49579
- __name(ungroup, "ungroup");
49580
- async function standby(name, on) {
49581
- const { t, z: z2 } = await zoneOf(name);
49582
- for (const o of z2.outputs) await call((cb) => on ? t.standby(o.output_id, {}, cb) : t.convenience_switch(o.output_id, {}, cb));
49583
- return { ok: true, zone: z2.display_name, standby: on };
49584
- }
49585
- __name(standby, "standby");
49586
- async function cover(target2, name) {
49587
- await officialReady();
49588
- const z2 = name ? officialZone(name) : officialZones().find((x) => x.state === "playing" && x.now_playing?.image_key) ?? officialZones().find((x) => x.state === "paused" && x.now_playing?.image_key);
49589
- if (!z2) throw new RoonError(name ? `No Roon zone called "${name}"` : "Nothing plays in Roon right now", "not_found");
49590
- const key = z2.now_playing?.image_key;
49591
- if (!key) throw new RoonError(`Roon has no cover for what plays on ${z2.display_name}`, "not_found");
49592
- const r = await fetch(`http://${target2.host}:${target2.port}/api/image/${key}?scale=fit&width=600&height=600`).catch(() => null);
49593
- if (!r?.ok) throw new RoonError("Roon doesn't hand over the cover right now", "unavailable");
49594
- const l = z2.now_playing?.three_line;
49595
- return {
49596
- info: { zone: z2.display_name, title: l?.line1, artist: l?.line2, album: l?.line3 },
49597
- image: { data: Buffer.from(await r.arrayBuffer()).toString("base64"), mimeType: r.headers.get("content-type") ?? "image/jpeg" }
49598
- };
49599
- }
49600
- __name(cover, "cover");
49601
-
49602
- // src/tools/run.ts
49533
+ // src/roon/radio.ts
49534
+ init_intern();
49535
+ init_serializer();
49536
+ init_writer();
49537
+ init_signature();
49603
49538
  init_session();
49604
49539
  init_log();
49605
-
49606
- // src/core/status.ts
49607
- init_package();
49608
- init_log();
49609
- var checks = null;
49610
- var checking = null;
49611
- function selfTest(run2) {
49612
- checking ??= run2().then((c) => {
49613
- checks = c;
49614
- const bad = c.filter((x) => !x.ok);
49615
- log(bad.length ? `self-test: ${bad.map((x) => `${x.name} failed (${x.detail})`).join("; ")}` : `self-test: ${c.length} reads ok`);
49616
- }).catch((e) => {
49617
- log(`self-test couldn't run: ${e.message}`);
49618
- });
49619
- return checking;
49620
- }
49621
- __name(selfTest, "selfTest");
49622
- var selfTestResults = /* @__PURE__ */ __name(() => checks, "selfTestResults");
49623
- var newer = null;
49624
- var told = false;
49625
- var parse3 = /* @__PURE__ */ __name((v) => v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0), "parse");
49626
- var isNewer = /* @__PURE__ */ __name((a, b) => {
49627
- const x = parse3(a), y = parse3(b);
49628
- for (let i = 0; i < 3; i++) {
49629
- if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
49630
- }
49631
- return false;
49632
- }, "isNewer");
49633
- async function checkForUpdate() {
49634
- if (process.env.DEEP_ROON_NO_UPDATE_CHECK === "1") return;
49635
- try {
49636
- const r = await fetch("https://api.github.com/repos/renebouwmeester/deep-roon-mcp/releases/latest", {
49637
- headers: { "User-Agent": `deep-roon-mcp/${version2}`, Accept: "application/vnd.github+json" },
49638
- signal: AbortSignal.timeout(5e3)
49639
- });
49640
- if (!r.ok) return;
49641
- const j = await r.json();
49642
- if (j.tag_name && isNewer(j.tag_name, version2)) {
49643
- newer = { version: j.tag_name.replace(/^v/, ""), url: j.html_url ?? "" };
49644
- log(`a newer version is out: ${newer.version}`);
49645
- }
49646
- } catch {
49647
- }
49648
- }
49649
- __name(checkForUpdate, "checkForUpdate");
49650
- var updateInfo = /* @__PURE__ */ __name(() => newer, "updateInfo");
49651
- function updateNotice() {
49652
- if (!newer || told) return null;
49653
- told = true;
49654
- return `Deep Roon MCP ${newer.version} is available (this is ${version2}): ${newer.url}`;
49655
- }
49656
- __name(updateNotice, "updateNotice");
49657
-
49658
- // src/tools/run.ts
49659
- var compact = /* @__PURE__ */ __name((_k, v) => v === void 0 ? void 0 : typeof v === "bigint" ? v.toString() : v, "compact");
49660
- async function run(work2) {
49661
- try {
49662
- const result = await work2();
49663
- const notice = updateNotice();
49664
- return { content: [{ type: "text", text: JSON.stringify(result, compact) }, ...notice ? [{ type: "text", text: notice }] : []] };
49665
- } catch (e) {
49666
- const err = e;
49667
- let text2;
49668
- if (err instanceof RoonError) text2 = err.message;
49669
- else if (/MissingMethod|method not found|unknown method/i.test(err.message)) {
49670
- text2 = `Roon changed this part of its internal protocol in this version (${err.message}). Check for an update of Deep Roon MCP.`;
49671
- } else text2 = `Roon didn't answer as expected: ${err.message}`;
49672
- log(`tool error: ${err.message}`);
49673
- return { isError: true, content: [{ type: "text", text: text2 }] };
49674
- }
49675
- }
49676
- __name(run, "run");
49677
- async function runImage(work2) {
49678
- let image = null;
49679
- const r = await run(async () => {
49680
- const x = await work2();
49681
- image = x.image;
49682
- return x.info;
49683
- });
49684
- if (r.isError || !image) return r;
49685
- const { data, mimeType } = image;
49686
- return { content: [...r.content, { type: "image", data, mimeType }] };
49687
- }
49688
- __name(runImage, "runImage");
49689
-
49690
- // src/tools/read.ts
49691
- var READ = { readOnlyHint: true, openWorldHint: false };
49692
- var service = external_exports.enum(["qobuz", "tidal"]).optional().describe("qobuz or tidal \u2014 default: Qobuz when it is signed in in Roon, else TIDAL.");
49693
- var limit = /* @__PURE__ */ __name((max, dflt) => external_exports.number().int().min(1).max(max).optional().describe(`How many (default ${dflt}, at most ${max}).`), "limit");
49694
- function registerReadTools(server, internal = true) {
49695
- const R = /* @__PURE__ */ __name((name) => internal || name === "roon_zones" || name === "roon_now_playing", "R");
49696
- const reg = /* @__PURE__ */ __name(((name, cfg, cb) => R(name) ? server.registerTool.call(server, name, cfg, cb) : void 0), "reg");
49697
- reg("roon_search", {
49698
- title: "Search Roon",
49699
- description: 'Search Roon \u2014 the user\'s Roon library and the catalogues of the streaming services signed in (Qobuz, TIDAL) at once; each row says its `source` (library, Qobuz, TIDAL). Rows carry `image`, the key of the cover: the Core serves it at http://<core address>:9330/image/<key>.jpg, or scaled at /image/<key>.300.jpg (for clients that draw; a chat shows covers with roon_cover). The `image` of roon_now_playing is an official key, served at /api/image/<key>?scale=fit&width=600&height=600. Returns albums (with format, e.g. FLAC 96kHz 24bit), artists and tracks. `kind` narrows to album, track or artist \u2014 use `kind: "track"` with the song title (and artist) to find one specific track; a broad query without `kind` can fill up with artists. Albums and tracks carry a `key` for `roon_album`; artists carry a `key` for `roon_artist` and `roon_bio`. Albums say their `year`, and `in_library` when the user has them in Roon. Keys stay valid between calls.',
49700
- inputSchema: { query: external_exports.string().min(1), kind: external_exports.enum(["album", "track", "artist"]).optional(), limit: limit(100, 30) },
49701
- annotations: READ
49702
- }, ({ query, kind, limit: n }) => run(() => search(query, n ?? 30, kind)));
49703
- reg("roon_new", {
49704
- title: "Streaming shelves in Roon",
49705
- description: 'The editorial shelves of Qobuz or TIDAL as Roon shows them (the same for everyone). Qobuz (`service: "qobuz"`): `new`, `qobuzissimes`, `press_awards`, `top`, `trending`, `ideal_discography` \u2014 albums; `genre` narrows a shelf to one or more Qobuz genre ids from `roon_genres`. TIDAL (`service: "tidal"`): `new`, `recommended`, `top`, `rising`, `masters` \u2014 albums, or tracks with `what: "tracks"` (not masters); `genre` (one TIDAL genre from `roon_genres`, e.g. "Jazz") gives TIDAL\'s page for that genre instead of a shelf. Use `genre` for "new jazz", "what\'s new in classical"; TIDAL\'s playlists are in roon_tidal_playlists. Albums carry their format and a `key` for `roon_album`; tracks a `key` for roon_play_tracks. `offset` pages through the shelf; `total` says how many there are.',
49706
- inputSchema: {
49707
- service,
49708
- section: external_exports.string().optional().describe("The shelf (default new) \u2014 see the list for each service."),
49709
- genre: external_exports.string().optional().describe("Qobuz: genre id(s) from roon_genres, comma-separated. TIDAL: one genre from roon_genres."),
49710
- what: external_exports.enum(["albums", "tracks"]).optional().describe("TIDAL only: albums (default) or tracks."),
49711
- limit: limit(100, 30),
49712
- offset: external_exports.number().int().min(0).optional()
49713
- },
49714
- annotations: READ
49715
- }, ({ service: sv, section: section2, genre, what, limit: n, offset: offset2 }) => run(() => section(section2 ?? "new", n ?? 30, offset2 ?? 0, (genre ?? "").split(",").map((x) => x.trim()).filter(Boolean), sv, what ?? "albums")));
49716
- reg("roon_genres", {
49717
- title: "Streaming genres",
49718
- description: "The genres of Qobuz or TIDAL, for the `genre` filter of `roon_new` (and, for TIDAL, of `roon_tidal_playlists`). Qobuz: the main genres with their ids (names in the account's language); with `parent` (a genre id) its sub-genres, e.g. the kinds of jazz. TIDAL: its genres and its moods (Relax, Focus \u2026), each with an `id` to pass on.",
49719
- inputSchema: { service, parent: external_exports.string().optional().describe("Qobuz: a genre id, to list its sub-genres.") },
49720
- annotations: READ
49721
- }, ({ service: sv, parent }) => run(() => genres(parent, sv)));
49722
- reg("roon_album", {
49723
- title: "Album page",
49724
- description: "One album in Roon, by its `key` (from roon_search, roon_new, roon_artist): the album with year, format, label and whether it is in the library, its genres, release date (and the date of this edition), barcode and \u2014 when Roon has them \u2014 catalogue number and country, its main artist(s) with a `key` for `roon_artist` / `roon_bio`, and every track with its own `key`. A streaming album that is also in the library comes back as the library copy (its key may differ; both work). `credits: true` adds who played, produced, engineered and wrote, grouped by role \u2014 Roon's own data.",
49725
- inputSchema: { key: external_exports.string().describe("An album key."), credits: external_exports.boolean().optional() },
49726
- annotations: READ
49727
- }, ({ key, credits }) => run(() => album(key, credits === true)));
49728
- reg("roon_review", {
49729
- title: "Album review",
49730
- 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.",
49731
- inputSchema: { key: external_exports.string().describe("An album key."), wikipedia: external_exports.boolean().optional(), max_chars: external_exports.number().int().min(200).optional() },
49732
- annotations: READ
49733
- }, ({ key, wikipedia, max_chars }) => run(() => review(key, max_chars ?? 1e9, wikipedia === true)));
49734
- reg("roon_bio", {
49735
- title: "Artist biography",
49736
- description: 'The biography Roon shows on an artist\'s page, in full, with its author \u2014 by artist `key` \u2014 plus where they are from, active since, genres, and the related artists Roon lists: `similar`, `influenced_by`, `influenced`, `worked_with`, `member_of`, `members`, each with a `key` for `roon_artist` / `roon_bio`. When the user asks for the bio ("who is \u2026", "tell me about this artist"), show the text complete and exactly as returned, with the author\'s name \u2014 no paraphrase or shortening unless they ask for a summary. For a narrower question ("who influenced them") answer from the lists. `wikipedia: true` adds the Wikipedia text; `max_chars` only when a shorter excerpt is asked for.',
49737
- inputSchema: { key: external_exports.string().describe("An artist key."), wikipedia: external_exports.boolean().optional(), max_chars: external_exports.number().int().min(200).optional() },
49738
- annotations: READ
49739
- }, ({ key, wikipedia, max_chars }) => run(() => bio(key, max_chars ?? 1e9, wikipedia === true)));
49740
- reg("roon_artist", {
49741
- title: "Discography",
49742
- description: 'An artist\'s discography in Roon: the main albums Qobuz (or, with `service: "tidal"`, TIDAL) has, oldest first, each with year, format and a `key` for `roon_album`. The artist `key` comes from roon_search (`kind: "artist"`) or from the `artists` of roon_album. Use this for "the album before/after \u2026", "their debut", "what else did they make" \u2014 don\'t answer discography questions from memory. The year is the original release where Roon knows it; reissues and compilations are in the list too.',
49743
- inputSchema: { key: external_exports.string().describe("An artist key."), service },
49744
- annotations: READ
49745
- }, ({ key, service: sv }) => run(() => discography(key, sv)));
49746
- reg("roon_zones", {
49747
- title: "Roon zones",
49748
- description: "The zones Roon can play on (Roon's own names), and which are grouped right now (`groups`).",
49749
- inputSchema: {},
49750
- annotations: READ
49751
- }, () => run(() => internal ? zones2() : zones3()));
49752
- reg("roon_now_playing", {
49753
- title: "Now playing in Roon",
49754
- description: "What plays in Roon, per zone: state (playing, loading, paused, stopped), the track with album, artist and format, the position, how many tracks are left in the queue, shuffle, repeat, Roon Radio and a sleep timer. Read this before saying anything about what Roon plays \u2014 it changes all the time.",
49755
- inputSchema: {},
49756
- annotations: READ
49757
- }, () => run(() => internal ? nowPlaying() : nowPlaying2()));
49758
- reg("roon_history", {
49759
- title: "Play history",
49760
- description: `What was played in Roon, newest first \u2014 Roon's own play history, with how much of each track was heard. For "most played" questions use roon_top instead of counting this.`,
49761
- inputSchema: { limit: limit(300, 30) },
49762
- annotations: READ
49763
- }, ({ limit: n }) => run(() => history(n ?? 30)));
49764
- reg("roon_top", {
49765
- title: "Most played",
49766
- description: 'The most played albums, artists or tracks in Roon, counted over the WHOLE play history (a play counts when at least half the track was heard). Use this for "most listened" questions \u2014 don\'t tally roon_history yourself. `since` (YYYY-MM-DD) limits the period.',
49767
- inputSchema: { what: external_exports.enum(["albums", "artists", "tracks"]).optional(), limit: limit(100, 20), since: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Only plays from this date on (YYYY-MM-DD).") },
49768
- annotations: READ
49769
- }, ({ what, limit: n, since }) => run(() => top(what ?? "albums", n ?? 20, since)));
49770
- }
49771
- __name(registerReadTools, "registerReadTools");
49540
+ init_graph();
49772
49541
 
49773
49542
  // src/roon/control.ts
49774
49543
  init_intern();
@@ -49902,7 +49671,7 @@ async function playTracks(keys, zoneName, how) {
49902
49671
  }
49903
49672
  __name(playTracks, "playTracks");
49904
49673
  var BUTTONS = { pause: "Pause", play: "Play", playpause: "PlayPause", stop: "Stop", next: "Next", previous: "Previous" };
49905
- async function control2(zoneName, action) {
49674
+ async function control(zoneName, action) {
49906
49675
  if (action === "release") {
49907
49676
  return withCore(async (c) => {
49908
49677
  const z2 = findZone(c, zoneName);
@@ -49930,8 +49699,8 @@ async function control2(zoneName, action) {
49930
49699
  }
49931
49700
  return { ok: !expect[action] || expect[action].includes(state ?? ""), zone: r.zone, action, state };
49932
49701
  }
49933
- __name(control2, "control");
49934
- async function seek2(zoneName, seconds) {
49702
+ __name(control, "control");
49703
+ async function seek(zoneName, seconds) {
49935
49704
  return withCore(async (c) => {
49936
49705
  const z2 = findZone(c, zoneName);
49937
49706
  const res = await c.call("Zone", "Seek", [{ type: "int", name: "seconds" }, { type: "ResultCallback", name: "cb_done" }], buildArgs([Arg.int(Math.max(0, Math.round(seconds)))]), z2.oid);
@@ -49939,8 +49708,8 @@ async function seek2(zoneName, seconds) {
49939
49708
  return { ok: true, zone: z2.name, position: duration3(seconds) };
49940
49709
  });
49941
49710
  }
49942
- __name(seek2, "seek");
49943
- async function settings2(zoneName, s) {
49711
+ __name(seek, "seek");
49712
+ async function settings(zoneName, s) {
49944
49713
  return withCore(async (c) => {
49945
49714
  const z2 = findZone(c, zoneName);
49946
49715
  const send = /* @__PURE__ */ __name((method, params, args) => c.remoting.callMethodNoReply(z2.oid, formatMethodSignature("Zone", method, params), args), "send");
@@ -49965,7 +49734,7 @@ async function settings2(zoneName, s) {
49965
49734
  return { ok: true, zone: z2.name, ...done };
49966
49735
  });
49967
49736
  }
49968
- __name(settings2, "settings");
49737
+ __name(settings, "settings");
49969
49738
  function queueOf(c, zoneName) {
49970
49739
  const z2 = findZone(c, zoneName);
49971
49740
  const q = obj(c, field2(c.graph.getObject(z2.oid), "Queue"));
@@ -50034,6 +49803,29 @@ async function queueRemove(zoneName, ns) {
50034
49803
  });
50035
49804
  }
50036
49805
  __name(queueRemove, "queueRemove");
49806
+ var INSERTION = { before: 3, after: 2 };
49807
+ async function queueMove(zoneName, n, to, where) {
49808
+ return withCore(async (c) => {
49809
+ const { z: z2, q, oid, refs, at } = await queueItemAt(c, zoneName, n);
49810
+ const rel = refs[at + to - 1];
49811
+ if (rel === void 0 || to === n) throw new RoonError(`The queue of ${z2.name} has no item ${to} to move next to`, "invalid");
49812
+ const point = INSERTION[where];
49813
+ const res = await c.call(
49814
+ "Queue",
49815
+ "MoveItems",
49816
+ [{ type: "IEnumerable<TransportItem>", name: "items" }, { type: "InsertionPoint", name: "insertionpoint" }, { type: "TransportItem", name: "relative_to_item" }, { type: "ResultCallback", name: "cb_done" }],
49817
+ Buffer.concat([buildArgs([Arg.collection([buildArgs([Arg.ref(oid)])]), Arg.enum_(point), Arg.ref(rel)])]),
49818
+ q.oid
49819
+ );
49820
+ if (!res.success) throw new RoonError(`Roon refused to move the item (${res.status})`);
49821
+ log(`queue_move: ${z2.name} item ${n} \u2192 ${where} ${to} (point ${point})`);
49822
+ await wait(300);
49823
+ const after = await queueItems(c, q);
49824
+ const now = after.indexOf(oid) - at + 1;
49825
+ return { ok: true, zone: z2.name, moved: n, now_at: now > 0 ? now : void 0 };
49826
+ });
49827
+ }
49828
+ __name(queueMove, "queueMove");
50037
49829
  async function queue2(zoneName, limit3) {
50038
49830
  return withCore(async (c) => {
50039
49831
  const { z: z2, q } = queueOf(c, zoneName);
@@ -50069,7 +49861,7 @@ var outputsOf = /* @__PURE__ */ __name((c, zone3) => c.graph.findByType("Endpoin
50069
49861
  const z2 = field2(e, "Zone");
50070
49862
  return isRef2(z2) && String(z2.$ref) === String(zone3) && typeof field2(e, "Name") === "string";
50071
49863
  }), "outputsOf");
50072
- async function group2(main2, join) {
49864
+ async function group(main2, join) {
50073
49865
  return withCore(async (c) => {
50074
49866
  const h = findZone(c, main2);
50075
49867
  const own2 = outputsOf(c, h.oid);
@@ -50095,8 +49887,8 @@ async function group2(main2, join) {
50095
49887
  return { ok: true, group: [...own2, ...added].map((e) => field2(e, "Name")) };
50096
49888
  });
50097
49889
  }
50098
- __name(group2, "group");
50099
- async function ungroup2(zoneName, only) {
49890
+ __name(group, "group");
49891
+ async function ungroup(zoneName, only) {
50100
49892
  return withCore(async (c) => {
50101
49893
  const z2 = findZone(c, zoneName);
50102
49894
  const all = outputsOf(c, z2.oid);
@@ -50114,8 +49906,8 @@ async function ungroup2(zoneName, only) {
50114
49906
  return { ok: true, released: off.map((e) => field2(e, "Name")) };
50115
49907
  });
50116
49908
  }
50117
- __name(ungroup2, "ungroup");
50118
- async function transfer2(from, to) {
49909
+ __name(ungroup, "ungroup");
49910
+ async function transfer(from, to) {
50119
49911
  return withCore(async (c) => {
50120
49912
  const a = findZone(c, from), b = findZone(c, to);
50121
49913
  if (a.oid === b.oid) throw new RoonError("From and to are the same zone", "invalid");
@@ -50130,7 +49922,7 @@ async function transfer2(from, to) {
50130
49922
  return { ok: true, from: a.name, to: b.name };
50131
49923
  });
50132
49924
  }
50133
- __name(transfer2, "transfer");
49925
+ __name(transfer, "transfer");
50134
49926
  async function sleep(zoneName, minutes) {
50135
49927
  return withCore(async (c) => {
50136
49928
  const z2 = findZone(c, zoneName);
@@ -50143,7 +49935,7 @@ async function sleep(zoneName, minutes) {
50143
49935
  });
50144
49936
  }
50145
49937
  __name(sleep, "sleep");
50146
- async function standby2(zoneName, on) {
49938
+ async function standby(zoneName, on) {
50147
49939
  return withCore(async (c) => {
50148
49940
  const z2 = findZone(c, zoneName);
50149
49941
  const outs = outputsOf(c, z2.oid).filter((e) => field2(e, "SupportsStandby") === true);
@@ -50155,8 +49947,8 @@ async function standby2(zoneName, on) {
50155
49947
  return { ok: true, zone: z2.name, standby: on };
50156
49948
  });
50157
49949
  }
50158
- __name(standby2, "standby");
50159
- async function cover2(zoneName) {
49950
+ __name(standby, "standby");
49951
+ async function cover(zoneName) {
50160
49952
  const t = await findTarget();
50161
49953
  const info = await withCore(async (c) => {
50162
49954
  const pick2 = zoneName ? findZone(c, zoneName) : null;
@@ -50185,8 +49977,456 @@ async function cover2(zoneName) {
50185
49977
  image: { data: Buffer.from(await r.arrayBuffer()).toString("base64"), mimeType: r.headers.get("content-type") ?? "image/jpeg" }
50186
49978
  };
50187
49979
  }
49980
+ __name(cover, "cover");
49981
+
49982
+ // src/roon/radio.ts
49983
+ var hex3 = /* @__PURE__ */ __name((v) => Buffer.isBuffer(v) ? v.toString("hex") : "", "hex");
49984
+ var decoded = /* @__PURE__ */ __name((c, payload) => payload.length ? c.graph.decodeReturnValue(Uint8Array.from(payload)) : null, "decoded");
49985
+ var radioOid = /* @__PURE__ */ __name((c) => c.serviceOid("Radio"), "radioOid");
49986
+ function stationRow(c, o) {
49987
+ const r = { key: hex3(field2(o, "ChannelId")), name: field2(o, "Name") ?? "" };
49988
+ const loc = field2(o, "Location");
49989
+ if (loc) r.location = loc;
49990
+ const d = field2(o, "Description");
49991
+ if (d) r.description = String(d).slice(0, 200);
49992
+ if (field2(o, "IsInLibrary") === true) r.in_library = true;
49993
+ const img = imageKey(c, o);
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;
49997
+ return r;
49998
+ }
49999
+ __name(stationRow, "stationRow");
50000
+ var categoryRow = /* @__PURE__ */ __name((c, o) => ({ key: hex3(field2(o, "CategoryId")), name: field2(o, "Name") ?? "" }), "categoryRow");
50001
+ function criteria(c, category, genre) {
50002
+ const members2 = [];
50003
+ if (category) {
50004
+ const cat = c.structArg("Sooloos.Broker.Api.RadioCategory", [{ name: "System.Sooid Sooloos.Broker.Api.RadioCategory::CategoryId", propType: 4, value: new BinaryWriter().sooid(Buffer.from(category, "hex")).toBuffer() }]);
50005
+ members2.push({ name: "Sooloos.Broker.Api.RadioCategory Sooloos.Broker.Api.RadioBrowseCriteria::Category", propType: 23, value: cat });
50006
+ }
50007
+ if (genre) members2.push({ name: "string Sooloos.Broker.Api.RadioBrowseCriteria::Genre", propType: 20, value: new BinaryWriter().string(genre).toBuffer() });
50008
+ return c.structArg("Sooloos.Broker.Api.RadioBrowseCriteria", members2);
50009
+ }
50010
+ __name(criteria, "criteria");
50011
+ async function myStations(c) {
50012
+ const crit = c.structArg("Sooloos.Broker.Api.ChannelQueryCriteria", [{ name: "bool Sooloos.Broker.Api.ChannelQueryCriteria::RequireInLibrary", propType: 2, value: new BinaryWriter().boolean(true).toBuffer() }]);
50013
+ const res = await c.call("Radio", "ChannelQuery", [{ type: "Sooid", name: "profileid" }, { type: "ChannelQueryCriteria", name: "criteria" }, { type: "ResultCallback<Query<Channel>>", name: "cb_result" }], Buffer.concat([buildArgs([Arg.sooid(c.profile())]), crit]), radioOid(c));
50014
+ if (!res.success) throw new RoonError(`Roon refused the radio stations (${res.status})`);
50015
+ const v = decoded(c, res.payload);
50016
+ const end = Date.now() + 3e3;
50017
+ let list = [];
50018
+ while (Date.now() < end) {
50019
+ list = (isRef2(v) ? c.graph.getObject(v.$ref)?.fields?.$items : null) ?? [];
50020
+ if (!list.length || list.every((x) => field2(obj(c, x), "Name") !== void 0)) break;
50021
+ await wait(60);
50022
+ }
50023
+ return list.map((x) => obj(c, x)).filter(Boolean);
50024
+ }
50025
+ __name(myStations, "myStations");
50026
+ async function radio(category, genre, limit3 = 40) {
50027
+ return withCore(async (c) => {
50028
+ const out = {};
50029
+ if (!category && !genre) out.my_stations = (await myStations(c)).map((o) => stationRow(c, o));
50030
+ const crit = criteria(c, category, genre);
50031
+ if (!genre) {
50032
+ const res = await c.call("Radio", "BrowseCategories", [{ type: "Sooid", name: "profileid" }, { type: "RadioBrowseCriteria", name: "criterid" }, { type: "ResultCallback<DataList<RadioCategory>>", name: "cb_result" }], Buffer.concat([buildArgs([Arg.sooid(c.profile())]), crit]), radioOid(c));
50033
+ if (!res.success) throw new RoonError(`Roon refused the radio categories (${res.status})`);
50034
+ const cats = items(c, decoded(c, res.payload));
50035
+ await untilLoaded(c, cats.map((x) => ({ $ref: x.oid })), 2500);
50036
+ out.categories = cats.map((x) => categoryRow(c, x));
50037
+ }
50038
+ if (category || genre) {
50039
+ const n = Math.min(Math.max(1, limit3), 100);
50040
+ const res = await c.call(
50041
+ "Radio",
50042
+ "BrowseChannels",
50043
+ [{ type: "Sooid", name: "profileid" }, { type: "RadioBrowseCriteria", name: "criteria" }, { type: "int?", name: "count" }, { type: "ResultCallback<DataList<Channel>>", name: "cb_result" }],
50044
+ Buffer.concat([buildArgs([Arg.sooid(c.profile())]), crit, Buffer.from([1]), new BinaryWriter().integer(n).toBuffer()]),
50045
+ radioOid(c)
50046
+ );
50047
+ if (!res.success) throw new RoonError(`Roon refused the radio stations (${res.status})`);
50048
+ const chs = items(c, decoded(c, res.payload));
50049
+ await untilLoaded(c, chs.map((x) => ({ $ref: x.oid })), 3500);
50050
+ out.stations = chs.map((x) => stationRow(c, x));
50051
+ }
50052
+ return out;
50053
+ });
50054
+ }
50055
+ __name(radio, "radio");
50056
+ async function findStation(c, key) {
50057
+ if (/^[0-9a-f]{20,}$/i.test(key)) {
50058
+ const res = await c.call("Radio", "GetChannel", [{ type: "Sooid", name: "channelid" }, { type: "ResultCallback<Channel>", name: "cb_result" }], buildArgs([Arg.sooid(Buffer.from(key, "hex"))]), radioOid(c));
50059
+ const v = res.success ? decoded(c, res.payload) : null;
50060
+ if (isRef2(v)) {
50061
+ await untilLoaded(c, [v], 2500);
50062
+ const o2 = c.graph.getObject(v.$ref);
50063
+ if (o2 && field2(o2, "Name") !== void 0) return o2;
50064
+ }
50065
+ throw new RoonError("Roon knows no station with that key", "not_found");
50066
+ }
50067
+ const mine = await myStations(c);
50068
+ const want = key.toLowerCase();
50069
+ const o = mine.find((x) => String(field2(x, "Name") ?? "").toLowerCase() === want) ?? mine.find((x) => String(field2(x, "Name") ?? "").toLowerCase().includes(want));
50070
+ if (!o) throw new RoonError(`No station called "${key}" among your own; give a key from roon_radio`, "not_found");
50071
+ return o;
50072
+ }
50073
+ __name(findStation, "findStation");
50074
+ async function playRadio(key, zoneName) {
50075
+ const r = await withCore(async (c) => {
50076
+ const st = await findStation(c, key);
50077
+ const z2 = findZone(c, zoneName);
50078
+ const res = await c.call(
50079
+ "Transport",
50080
+ "PlayChannel",
50081
+ [{ type: "Zone", name: "zone" }, { type: "Sooid", name: "profileid" }, { type: "Channel", name: "channel" }, { type: "ResultCallback<PlayFeedback>", name: "cb_result" }],
50082
+ buildArgs([Arg.ref(z2.oid), Arg.sooid(c.profile()), Arg.ref(st.oid)]),
50083
+ c.serviceOid("Transport")
50084
+ );
50085
+ log(`play_radio: ${field2(st, "Name")} on ${z2.name} \u2192 ${res.success ? "ok" : res.status}`);
50086
+ if (!res.success) throw new RoonError(`Roon refused to play the station (${res.status})`);
50087
+ return { zone: z2.name, station: field2(st, "Name") };
50088
+ });
50089
+ return { ok: true, ...r, ...await started2(r.zone, 0) };
50090
+ }
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");
50116
+
50117
+ // src/core/officialtools.ts
50118
+ init_errors();
50119
+ init_official();
50120
+ var call = /* @__PURE__ */ __name((fn) => new Promise((res, rej) => fn((err) => err ? rej(new RoonError(`Roon refused: ${err}`)) : res())), "call");
50121
+ var duration4 = /* @__PURE__ */ __name((s) => {
50122
+ const t = Math.round(s), h = Math.floor(t / 3600), m = Math.floor(t % 3600 / 60), r = t % 60;
50123
+ return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}` : `${m}:${String(r).padStart(2, "0")}`;
50124
+ }, "duration");
50125
+ var wait2 = /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "wait");
50126
+ async function zoneOf(name) {
50127
+ const t = await paired();
50128
+ await officialReady();
50129
+ const z2 = officialZone(name);
50130
+ if (!z2) throw new RoonError(`No Roon zone called "${name}"; Roon has: ${officialZones().map((x) => x.display_name).join(", ")}`, "not_found");
50131
+ return { t, z: z2 };
50132
+ }
50133
+ __name(zoneOf, "zoneOf");
50134
+ async function zones3() {
50135
+ await officialReady();
50136
+ const all = officialZones();
50137
+ return {
50138
+ zones: [...new Set(all.flatMap((z2) => z2.outputs.map((o) => o.display_name)))].sort(),
50139
+ groups: all.filter((z2) => z2.outputs.length > 1).map((z2) => z2.outputs.map((o) => o.display_name))
50140
+ };
50141
+ }
50142
+ __name(zones3, "zones");
50143
+ async function nowPlaying2() {
50144
+ await officialReady();
50145
+ return { zones: officialZones().map((z2) => {
50146
+ const r = { zone: z2.display_name, state: z2.state };
50147
+ const v = z2.outputs.find((o) => o.volume)?.volume;
50148
+ if (v) {
50149
+ r.volume = v.value;
50150
+ r.volume_max = v.max;
50151
+ if (v.is_muted) r.muted = true;
50152
+ }
50153
+ if (z2.settings?.shuffle) r.shuffle = true;
50154
+ if (z2.settings?.loop && z2.settings.loop !== "disabled") r.repeat = z2.settings.loop === "loop_one" ? "one" : "all";
50155
+ if (z2.settings?.auto_radio) r.roon_radio = true;
50156
+ const np = z2.now_playing;
50157
+ if (np && z2.state !== "stopped") {
50158
+ if (np.three_line?.line1) {
50159
+ r.title = np.three_line.line1;
50160
+ if (np.three_line.line2) r.artist = np.three_line.line2;
50161
+ if (np.three_line.line3) r.album = np.three_line.line3;
50162
+ }
50163
+ if (typeof np.seek_position === "number") r.position = duration4(np.seek_position);
50164
+ if (typeof np.length === "number") r.length = duration4(np.length);
50165
+ }
50166
+ return r;
50167
+ }) };
50168
+ }
50169
+ __name(nowPlaying2, "nowPlaying");
50170
+ async function control2(name, action) {
50171
+ const { t, z: z2 } = await zoneOf(name);
50172
+ await call((cb) => t.control(z2.zone_id, action, cb));
50173
+ const expect = { pause: ["paused"], stop: ["stopped", "paused"], play: ["playing", "loading"] };
50174
+ let state = officialZone(z2.display_name)?.state;
50175
+ if (expect[action]) {
50176
+ const end = Date.now() + 3e3;
50177
+ while (Date.now() < end && !expect[action].includes(state ?? "")) {
50178
+ await wait2(150);
50179
+ state = officialZone(z2.display_name)?.state;
50180
+ }
50181
+ }
50182
+ return { ok: !expect[action] || expect[action].includes(state ?? ""), zone: z2.display_name, action, state };
50183
+ }
50184
+ __name(control2, "control");
50185
+ async function seek2(name, seconds) {
50186
+ const { t, z: z2 } = await zoneOf(name);
50187
+ await call((cb) => t.seek(z2.zone_id, "absolute", Math.max(0, Math.round(seconds)), cb));
50188
+ return { ok: true, zone: z2.display_name, position: duration4(seconds) };
50189
+ }
50190
+ __name(seek2, "seek");
50191
+ async function settings2(name, s) {
50192
+ if (s.roon_radio === "now") throw new RoonError('"Start Roon Radio now" needs the internal protocol (switched off)', "invalid");
50193
+ const { t, z: z2 } = await zoneOf(name);
50194
+ const change = {};
50195
+ if (s.shuffle !== void 0) change.shuffle = s.shuffle;
50196
+ if (s.repeat !== void 0) change.loop = { off: "disabled", all: "loop", one: "loop_one" }[s.repeat];
50197
+ if (s.roon_radio !== void 0) change.auto_radio = s.roon_radio;
50198
+ if (!Object.keys(change).length) throw new RoonError("Nothing to set: give shuffle, repeat or roon_radio", "invalid");
50199
+ await call((cb) => t.change_settings(z2.zone_id, change, cb));
50200
+ return { ok: true, zone: z2.display_name, ...s };
50201
+ }
50202
+ __name(settings2, "settings");
50203
+ async function transfer2(from, to) {
50204
+ const a = await zoneOf(from), b = await zoneOf(to);
50205
+ if (a.z.zone_id === b.z.zone_id) throw new RoonError("From and to are the same zone", "invalid");
50206
+ await call((cb) => a.t.transfer_zone(a.z.zone_id, b.z.zone_id, cb));
50207
+ return { ok: true, from: a.z.display_name, to: b.z.display_name };
50208
+ }
50209
+ __name(transfer2, "transfer");
50210
+ async function group2(main2, join) {
50211
+ const m = await zoneOf(main2);
50212
+ const outs = [...m.z.outputs.map((o) => o.output_id)];
50213
+ for (const n of join) {
50214
+ const j = await zoneOf(n);
50215
+ for (const o of j.z.outputs) if (!outs.includes(o.output_id)) outs.push(o.output_id);
50216
+ }
50217
+ if (outs.length === m.z.outputs.length) throw new RoonError("Nothing to group: name at least one other zone", "invalid");
50218
+ await call((cb) => m.t.group_outputs(outs, cb));
50219
+ return { ok: true, group: outs.map((id) => officialZones().flatMap((z2) => z2.outputs).find((o) => o.output_id === id)?.display_name ?? id) };
50220
+ }
50221
+ __name(group2, "group");
50222
+ async function ungroup2(name, only) {
50223
+ const { t, z: z2 } = await zoneOf(name);
50224
+ if (z2.outputs.length < 2) throw new RoonError(`${z2.display_name} isn't in a group`, "invalid");
50225
+ const off = only ? z2.outputs.filter((o) => o.display_name.toLowerCase().includes(only.toLowerCase())) : z2.outputs;
50226
+ if (!off.length) throw new RoonError(`"${only}" isn't in this group`, "not_found");
50227
+ await call((cb) => t.ungroup_outputs(off.map((o) => o.output_id), cb));
50228
+ return { ok: true, released: off.map((o) => o.display_name) };
50229
+ }
50230
+ __name(ungroup2, "ungroup");
50231
+ async function standby2(name, on) {
50232
+ const { t, z: z2 } = await zoneOf(name);
50233
+ for (const o of z2.outputs) await call((cb) => on ? t.standby(o.output_id, {}, cb) : t.convenience_switch(o.output_id, {}, cb));
50234
+ return { ok: true, zone: z2.display_name, standby: on };
50235
+ }
50236
+ __name(standby2, "standby");
50237
+ async function cover2(target2, name) {
50238
+ await officialReady();
50239
+ const z2 = name ? officialZone(name) : officialZones().find((x) => x.state === "playing" && x.now_playing?.image_key) ?? officialZones().find((x) => x.state === "paused" && x.now_playing?.image_key);
50240
+ if (!z2) throw new RoonError(name ? `No Roon zone called "${name}"` : "Nothing plays in Roon right now", "not_found");
50241
+ const key = z2.now_playing?.image_key;
50242
+ if (!key) throw new RoonError(`Roon has no cover for what plays on ${z2.display_name}`, "not_found");
50243
+ const r = await fetch(`http://${target2.host}:${target2.port}/api/image/${key}?scale=fit&width=600&height=600`).catch(() => null);
50244
+ if (!r?.ok) throw new RoonError("Roon doesn't hand over the cover right now", "unavailable");
50245
+ const l = z2.now_playing?.three_line;
50246
+ return {
50247
+ info: { zone: z2.display_name, title: l?.line1, artist: l?.line2, album: l?.line3 },
50248
+ image: { data: Buffer.from(await r.arrayBuffer()).toString("base64"), mimeType: r.headers.get("content-type") ?? "image/jpeg" }
50249
+ };
50250
+ }
50188
50251
  __name(cover2, "cover");
50189
50252
 
50253
+ // src/tools/run.ts
50254
+ init_session();
50255
+ init_log();
50256
+
50257
+ // src/core/status.ts
50258
+ init_package();
50259
+ init_log();
50260
+ var checks = null;
50261
+ var checking = null;
50262
+ function selfTest(run2) {
50263
+ checking ??= run2().then((c) => {
50264
+ checks = c;
50265
+ const bad = c.filter((x) => !x.ok);
50266
+ log(bad.length ? `self-test: ${bad.map((x) => `${x.name} failed (${x.detail})`).join("; ")}` : `self-test: ${c.length} reads ok`);
50267
+ }).catch((e) => {
50268
+ log(`self-test couldn't run: ${e.message}`);
50269
+ });
50270
+ return checking;
50271
+ }
50272
+ __name(selfTest, "selfTest");
50273
+ var selfTestResults = /* @__PURE__ */ __name(() => checks, "selfTestResults");
50274
+ var newer = null;
50275
+ var told = false;
50276
+ var parse3 = /* @__PURE__ */ __name((v) => v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0), "parse");
50277
+ var isNewer = /* @__PURE__ */ __name((a, b) => {
50278
+ const x = parse3(a), y = parse3(b);
50279
+ for (let i = 0; i < 3; i++) {
50280
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
50281
+ }
50282
+ return false;
50283
+ }, "isNewer");
50284
+ async function checkForUpdate() {
50285
+ if (process.env.DEEP_ROON_NO_UPDATE_CHECK === "1") return;
50286
+ try {
50287
+ const r = await fetch("https://api.github.com/repos/renebouwmeester/deep-roon-mcp/releases/latest", {
50288
+ headers: { "User-Agent": `deep-roon-mcp/${version2}`, Accept: "application/vnd.github+json" },
50289
+ signal: AbortSignal.timeout(5e3)
50290
+ });
50291
+ if (!r.ok) return;
50292
+ const j = await r.json();
50293
+ if (j.tag_name && isNewer(j.tag_name, version2)) {
50294
+ newer = { version: j.tag_name.replace(/^v/, ""), url: j.html_url ?? "" };
50295
+ log(`a newer version is out: ${newer.version}`);
50296
+ }
50297
+ } catch {
50298
+ }
50299
+ }
50300
+ __name(checkForUpdate, "checkForUpdate");
50301
+ var updateInfo = /* @__PURE__ */ __name(() => newer, "updateInfo");
50302
+ function updateNotice() {
50303
+ if (!newer || told) return null;
50304
+ told = true;
50305
+ return `Deep Roon MCP ${newer.version} is available (this is ${version2}): ${newer.url}`;
50306
+ }
50307
+ __name(updateNotice, "updateNotice");
50308
+
50309
+ // src/tools/run.ts
50310
+ var compact = /* @__PURE__ */ __name((_k, v) => v === void 0 ? void 0 : typeof v === "bigint" ? v.toString() : v, "compact");
50311
+ async function run(work2) {
50312
+ try {
50313
+ const result = await work2();
50314
+ const notice = updateNotice();
50315
+ return { content: [{ type: "text", text: JSON.stringify(result, compact) }, ...notice ? [{ type: "text", text: notice }] : []] };
50316
+ } catch (e) {
50317
+ const err = e;
50318
+ let text2;
50319
+ if (err instanceof RoonError) text2 = err.message;
50320
+ else if (/MissingMethod|method not found|unknown method/i.test(err.message)) {
50321
+ text2 = `Roon changed this part of its internal protocol in this version (${err.message}). Check for an update of Deep Roon MCP.`;
50322
+ } else text2 = `Roon didn't answer as expected: ${err.message}`;
50323
+ log(`tool error: ${err.message}`);
50324
+ return { isError: true, content: [{ type: "text", text: text2 }] };
50325
+ }
50326
+ }
50327
+ __name(run, "run");
50328
+ async function runImage(work2) {
50329
+ let image = null;
50330
+ const r = await run(async () => {
50331
+ const x = await work2();
50332
+ image = x.image;
50333
+ return x.info;
50334
+ });
50335
+ if (r.isError || !image) return r;
50336
+ const { data, mimeType } = image;
50337
+ return { content: [...r.content, { type: "image", data, mimeType }] };
50338
+ }
50339
+ __name(runImage, "runImage");
50340
+
50341
+ // src/tools/read.ts
50342
+ var READ = { readOnlyHint: true, openWorldHint: false };
50343
+ var service = external_exports.enum(["qobuz", "tidal"]).optional().describe("qobuz or tidal \u2014 default: Qobuz when it is signed in in Roon, else TIDAL.");
50344
+ var limit = /* @__PURE__ */ __name((max, dflt) => external_exports.number().int().min(1).max(max).optional().describe(`How many (default ${dflt}, at most ${max}).`), "limit");
50345
+ function registerReadTools(server, internal = true) {
50346
+ const R = /* @__PURE__ */ __name((name) => internal || name === "roon_zones" || name === "roon_now_playing", "R");
50347
+ const reg = /* @__PURE__ */ __name(((name, cfg, cb) => R(name) ? server.registerTool.call(server, name, cfg, cb) : void 0), "reg");
50348
+ reg("roon_search", {
50349
+ title: "Search Roon",
50350
+ description: 'Search Roon \u2014 the user\'s Roon library and the catalogues of the streaming services signed in (Qobuz, TIDAL) at once; each row says its `source` (library, Qobuz, TIDAL). Rows carry `image`, the key of the cover: the Core serves it at http://<core address>:9330/image/<key>.jpg, or scaled at /image/<key>.300.jpg (for clients that draw; a chat shows covers with roon_cover). The `image` of roon_now_playing is an official key, served at /api/image/<key>?scale=fit&width=600&height=600. Returns albums (with format, e.g. FLAC 96kHz 24bit), artists and tracks. `kind` narrows to album, track or artist \u2014 use `kind: "track"` with the song title (and artist) to find one specific track; a broad query without `kind` can fill up with artists. Albums and tracks carry a `key` for `roon_album`; artists carry a `key` for `roon_artist` and `roon_bio`. Albums say their `year`, and `in_library` when the user has them in Roon. Keys stay valid between calls.',
50351
+ inputSchema: { query: external_exports.string().min(1), kind: external_exports.enum(["album", "track", "artist"]).optional(), limit: limit(100, 30) },
50352
+ annotations: READ
50353
+ }, ({ query, kind, limit: n }) => run(() => search(query, n ?? 30, kind)));
50354
+ reg("roon_new", {
50355
+ title: "Streaming shelves in Roon",
50356
+ description: 'The editorial shelves of Qobuz or TIDAL as Roon shows them (the same for everyone). Qobuz (`service: "qobuz"`): `new`, `qobuzissimes`, `press_awards`, `top`, `trending`, `ideal_discography` \u2014 albums; `genre` narrows a shelf to one or more Qobuz genre ids from `roon_genres`. TIDAL (`service: "tidal"`): `new`, `recommended`, `top`, `rising`, `masters` \u2014 albums, or tracks with `what: "tracks"` (not masters); `genre` (one TIDAL genre from `roon_genres`, e.g. "Jazz") gives TIDAL\'s page for that genre instead of a shelf. Use `genre` for "new jazz", "what\'s new in classical"; TIDAL\'s playlists are in roon_tidal_playlists. Albums carry their format and a `key` for `roon_album`; tracks a `key` for roon_play_tracks. `offset` pages through the shelf; `total` says how many there are.',
50357
+ inputSchema: {
50358
+ service,
50359
+ section: external_exports.string().optional().describe("The shelf (default new) \u2014 see the list for each service."),
50360
+ genre: external_exports.string().optional().describe("Qobuz: genre id(s) from roon_genres, comma-separated. TIDAL: one genre from roon_genres."),
50361
+ what: external_exports.enum(["albums", "tracks"]).optional().describe("TIDAL only: albums (default) or tracks."),
50362
+ limit: limit(100, 30),
50363
+ offset: external_exports.number().int().min(0).optional()
50364
+ },
50365
+ annotations: READ
50366
+ }, ({ service: sv, section: section2, genre, what, limit: n, offset: offset2 }) => run(() => section(section2 ?? "new", n ?? 30, offset2 ?? 0, (genre ?? "").split(",").map((x) => x.trim()).filter(Boolean), sv, what ?? "albums")));
50367
+ reg("roon_genres", {
50368
+ title: "Streaming genres",
50369
+ description: "The genres of Qobuz or TIDAL, for the `genre` filter of `roon_new` (and, for TIDAL, of `roon_tidal_playlists`). Qobuz: the main genres with their ids (names in the account's language); with `parent` (a genre id) its sub-genres, e.g. the kinds of jazz. TIDAL: its genres and its moods (Relax, Focus \u2026), each with an `id` to pass on.",
50370
+ inputSchema: { service, parent: external_exports.string().optional().describe("Qobuz: a genre id, to list its sub-genres.") },
50371
+ annotations: READ
50372
+ }, ({ service: sv, parent }) => run(() => genres(parent, sv)));
50373
+ reg("roon_album", {
50374
+ title: "Album page",
50375
+ description: "One album in Roon, by its `key` (from roon_search, roon_new, roon_artist): the album with year, format, label and whether it is in the library, its genres, release date (and the date of this edition), barcode and \u2014 when Roon has them \u2014 catalogue number and country, its main artist(s) with a `key` for `roon_artist` / `roon_bio`, and every track with its own `key`. A streaming album that is also in the library comes back as the library copy (its key may differ; both work). `credits: true` adds who played, produced, engineered and wrote, grouped by role \u2014 Roon's own data.",
50376
+ inputSchema: { key: external_exports.string().describe("An album key."), credits: external_exports.boolean().optional() },
50377
+ annotations: READ
50378
+ }, ({ key, credits }) => run(() => album(key, credits === true)));
50379
+ reg("roon_review", {
50380
+ title: "Album review",
50381
+ 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.",
50382
+ inputSchema: { key: external_exports.string().describe("An album key."), wikipedia: external_exports.boolean().optional(), max_chars: external_exports.number().int().min(200).optional() },
50383
+ annotations: READ
50384
+ }, ({ key, wikipedia, max_chars }) => run(() => review(key, max_chars ?? 1e9, wikipedia === true)));
50385
+ reg("roon_bio", {
50386
+ title: "Artist biography",
50387
+ description: 'The biography Roon shows on an artist\'s page, in full, with its author \u2014 by artist `key` \u2014 plus where they are from, active since, genres, and the related artists Roon lists: `similar`, `influenced_by`, `influenced`, `worked_with`, `member_of`, `members`, each with a `key` for `roon_artist` / `roon_bio`. When the user asks for the bio ("who is \u2026", "tell me about this artist"), show the text complete and exactly as returned, with the author\'s name \u2014 no paraphrase or shortening unless they ask for a summary. For a narrower question ("who influenced them") answer from the lists. `wikipedia: true` adds the Wikipedia text; `max_chars` only when a shorter excerpt is asked for.',
50388
+ inputSchema: { key: external_exports.string().describe("An artist key."), wikipedia: external_exports.boolean().optional(), max_chars: external_exports.number().int().min(200).optional() },
50389
+ annotations: READ
50390
+ }, ({ key, wikipedia, max_chars }) => run(() => bio(key, max_chars ?? 1e9, wikipedia === true)));
50391
+ reg("roon_artist", {
50392
+ title: "Discography",
50393
+ description: 'An artist\'s discography in Roon: the main albums Qobuz (or, with `service: "tidal"`, TIDAL) has, oldest first, each with year, format and a `key` for `roon_album`. The artist `key` comes from roon_search (`kind: "artist"`) or from the `artists` of roon_album. Use this for "the album before/after \u2026", "their debut", "what else did they make" \u2014 don\'t answer discography questions from memory. The year is the original release where Roon knows it; reissues and compilations are in the list too.',
50394
+ inputSchema: { key: external_exports.string().describe("An artist key."), service },
50395
+ annotations: READ
50396
+ }, ({ key, service: sv }) => run(() => discography(key, sv)));
50397
+ reg("roon_zones", {
50398
+ title: "Roon zones",
50399
+ description: "The zones Roon can play on (Roon's own names), and which are grouped right now (`groups`).",
50400
+ inputSchema: {},
50401
+ annotations: READ
50402
+ }, () => run(() => internal ? zones2() : zones3()));
50403
+ reg("roon_now_playing", {
50404
+ title: "Now playing in Roon",
50405
+ description: "What plays in Roon, per zone: state (playing, loading, paused, stopped), the track with album, artist and format, the position, how many tracks are left in the queue, shuffle, repeat, Roon Radio and a sleep timer. Read this before saying anything about what Roon plays \u2014 it changes all the time.",
50406
+ inputSchema: {},
50407
+ annotations: READ
50408
+ }, () => run(() => internal ? nowPlaying() : nowPlaying2()));
50409
+ reg("roon_radio", {
50410
+ title: "Live Radio",
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.',
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) },
50413
+ annotations: READ
50414
+ }, ({ category, genre, limit: n }) => run(() => radio(category, genre, n ?? 40)));
50415
+ reg("roon_history", {
50416
+ title: "Play history",
50417
+ description: `What was played in Roon, newest first \u2014 Roon's own play history, with how much of each track was heard. For "most played" questions use roon_top instead of counting this.`,
50418
+ inputSchema: { limit: limit(300, 30) },
50419
+ annotations: READ
50420
+ }, ({ limit: n }) => run(() => history(n ?? 30)));
50421
+ reg("roon_top", {
50422
+ title: "Most played",
50423
+ description: 'The most played albums, artists or tracks in Roon, counted over the WHOLE play history (a play counts when at least half the track was heard). Use this for "most listened" questions \u2014 don\'t tally roon_history yourself. `since` (YYYY-MM-DD) limits the period.',
50424
+ inputSchema: { what: external_exports.enum(["albums", "artists", "tracks"]).optional(), limit: limit(100, 20), since: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Only plays from this date on (YYYY-MM-DD).") },
50425
+ annotations: READ
50426
+ }, ({ what, limit: n, since }) => run(() => top(what ?? "albums", n ?? 20, since)));
50427
+ }
50428
+ __name(registerReadTools, "registerReadTools");
50429
+
50190
50430
  // src/tools/control.ts
50191
50431
  init_official();
50192
50432
  init_session();
@@ -50210,18 +50450,24 @@ function registerControlTools(server, internal = true) {
50210
50450
  inputSchema: { keys: external_exports.array(external_exports.string()).min(1).max(60).describe("Track keys, in playing order."), zone, mode },
50211
50451
  annotations: PLAYS
50212
50452
  }, ({ keys, zone: zn, mode: m }) => run(() => playTracks(keys, zn, m ?? "now")));
50453
+ reg("roon_play_radio", {
50454
+ title: "Play a radio station",
50455
+ description: "Play a Live Radio station on a Roon zone: `key` from roon_radio, or the name of one of the user's own stations. Says whether the zone started. Only on an explicit request, never as a probe.",
50456
+ inputSchema: { key: external_exports.string().describe("A station key, or the name of an own station."), zone },
50457
+ annotations: PLAYS
50458
+ }, ({ key, zone: zn }) => run(() => playRadio(key, zn)));
50213
50459
  reg("roon_control", {
50214
50460
  title: "Transport",
50215
50461
  description: "The buttons of a Roon zone: pause, play (resume), playpause, stop, next, previous \u2014 and release: let go of the audio device right away (what Roon does by itself a few seconds after a pause), so another source can use the speaker. Only on an explicit request, never as a probe.",
50216
50462
  inputSchema: { zone, action: external_exports.enum(["pause", "play", "playpause", "stop", "next", "previous", "release"]) },
50217
50463
  annotations: PLAYS
50218
- }, ({ zone: zn, action }) => run(() => internal ? control2(zn, action) : action === "release" ? Promise.reject(new Error("release needs Roon's internal protocol (switched off)")) : control(zn, action)));
50464
+ }, ({ zone: zn, action }) => run(() => internal ? control(zn, action) : action === "release" ? Promise.reject(new Error("release needs Roon's internal protocol (switched off)")) : control2(zn, action)));
50219
50465
  reg("roon_seek", {
50220
50466
  title: "Seek",
50221
50467
  description: "Jump to a position in the track that plays on a Roon zone, in seconds from the start. Only on an explicit request.",
50222
50468
  inputSchema: { zone, seconds: external_exports.number().int().min(0) },
50223
50469
  annotations: PLAYS
50224
- }, ({ zone: zn, seconds }) => run(() => internal ? seek2(zn, seconds) : seek(zn, seconds)));
50470
+ }, ({ zone: zn, seconds }) => run(() => internal ? seek(zn, seconds) : seek2(zn, seconds)));
50225
50471
  reg("roon_volume", {
50226
50472
  title: "Volume",
50227
50473
  description: "Set the volume of a Roon zone to an absolute level, in that zone's own scale (roon_now_playing shows `volume` and `volume_max`), and/or mute or unmute it. Only on an explicit request; small steps unless a level is named. Needs Deep Roon MCP enabled in Roon (Settings \u203A Extensions).",
@@ -50235,7 +50481,7 @@ function registerControlTools(server, internal = true) {
50235
50481
  annotations: PLAYS
50236
50482
  }, ({ zone: zn, shuffle, repeat, roon_radio }) => {
50237
50483
  const s = { shuffle, repeat, roon_radio: roon_radio === void 0 ? void 0 : roon_radio === "now" ? "now" : roon_radio === "on" };
50238
- return run(() => internal ? settings2(zn, s) : settings(zn, s));
50484
+ return run(() => internal ? settings(zn, s) : settings2(zn, s));
50239
50485
  });
50240
50486
  reg("roon_queue", {
50241
50487
  title: "Queue",
@@ -50255,6 +50501,12 @@ function registerControlTools(server, internal = true) {
50255
50501
  inputSchema: { zone, ns: external_exports.array(external_exports.number().int().min(2)).min(1).max(100).describe("Positions from roon_queue (2 and up: 1 is what plays).") },
50256
50502
  annotations: CHANGES_QUEUE
50257
50503
  }, ({ zone: zn, ns }) => run(() => queueRemove(zn, ns)));
50504
+ reg("roon_queue_move", {
50505
+ title: "Move in the queue",
50506
+ description: "Move an item of the zone's queue next to another: `n` goes `before` or `after` item `to` (numbers from roon_queue; 1 is what plays). Only on an explicit request.",
50507
+ inputSchema: { zone, n: external_exports.number().int().min(2), to: external_exports.number().int().min(2), where: external_exports.enum(["before", "after"]).optional().describe("before (default) or after item `to`.") },
50508
+ annotations: CHANGES_QUEUE
50509
+ }, ({ zone: zn, n, to, where }) => run(() => queueMove(zn, n, to, where ?? "before")));
50258
50510
  reg("roon_queue_clear", {
50259
50511
  title: "Clear the queue",
50260
50512
  description: "Clear the queue of a Roon zone. By default the track that plays keeps playing and everything after it is removed; `everything: true` also removes the playing track (the zone falls silent). Only on an explicit request.",
@@ -50266,19 +50518,19 @@ function registerControlTools(server, internal = true) {
50266
50518
  description: `Move what plays \u2014 with its queue and position \u2014 from one Roon zone to another (Roon's own "Transfer zone"). The tool for "move the music to \u2026": never restart the album with roon_play for that. Read roon_now_playing first if the source zone isn't named.`,
50267
50519
  inputSchema: { from: external_exports.string().describe("The zone that plays now."), to: external_exports.string().describe("The zone it should move to.") },
50268
50520
  annotations: PLAYS
50269
- }, ({ from, to }) => run(() => internal ? transfer2(from, to) : transfer(from, to)));
50521
+ }, ({ from, to }) => run(() => internal ? transfer(from, to) : transfer2(from, to)));
50270
50522
  reg("roon_group", {
50271
50523
  title: "Group zones",
50272
50524
  description: 'Group Roon zones so they play the same music in sync (Roon\'s own "Group zones"). `zone` keeps its music and queue; `with` are the zones that join. Roon groups only outputs of the same kind. Only on an explicit request.',
50273
50525
  inputSchema: { zone: external_exports.string().describe("The zone whose music continues."), with: external_exports.array(external_exports.string()).min(1).describe("The zones that join.") },
50274
50526
  annotations: PLAYS
50275
- }, (a) => run(() => internal ? group2(a.zone, a.with) : group(a.zone, a.with)));
50527
+ }, (a) => run(() => internal ? group(a.zone, a.with) : group2(a.zone, a.with)));
50276
50528
  reg("roon_ungroup", {
50277
50529
  title: "Ungroup zones",
50278
50530
  description: "Undo a Roon zone group: `zone` is any member. Without `only` the whole group is dissolved; with `only` (a member's name) just that one leaves. Only on an explicit request.",
50279
50531
  inputSchema: { zone: external_exports.string().describe("A member of the group."), only: external_exports.string().optional() },
50280
50532
  annotations: PLAYS
50281
- }, ({ zone: zn, only }) => run(() => internal ? ungroup2(zn, only) : ungroup(zn, only)));
50533
+ }, ({ zone: zn, only }) => run(() => internal ? ungroup(zn, only) : ungroup2(zn, only)));
50282
50534
  reg("roon_sleep", {
50283
50535
  title: "Sleep timer",
50284
50536
  description: 'The sleep timer of a Roon zone: stop the music after `minutes` (1\u2013600), or `off: true` to cancel it. roon_now_playing shows a running timer. Only on an explicit request ("stop in half an hour").',
@@ -50293,13 +50545,13 @@ function registerControlTools(server, internal = true) {
50293
50545
  description: 'Put a Roon zone\'s device in standby (`action: "standby"`) or wake it (`action: "wake"`), where the device supports it. Only on an explicit request.',
50294
50546
  inputSchema: { zone, action: external_exports.enum(["standby", "wake"]) },
50295
50547
  annotations: PLAYS
50296
- }, ({ zone: zn, action }) => run(() => internal ? standby2(zn, action === "standby") : standby(zn, action === "standby")));
50548
+ }, ({ zone: zn, action }) => run(() => internal ? standby(zn, action === "standby") : standby2(zn, action === "standby")));
50297
50549
  reg("roon_cover", {
50298
50550
  title: "Cover",
50299
50551
  description: "The album cover of what plays in a Roon zone, as an image (about 600 px), with the title in text. Without `zone` it takes the zone that plays. Read-only.",
50300
50552
  inputSchema: { zone: external_exports.string().optional().describe("A Roon zone name (default: the zone that plays).") },
50301
50553
  annotations: { readOnlyHint: true, openWorldHint: false }
50302
- }, ({ zone: zn }) => runImage(async () => internal ? cover2(zn) : cover(await findTarget(), zn)));
50554
+ }, ({ zone: zn }) => runImage(async () => internal ? cover(zn) : cover2(await findTarget(), zn)));
50303
50555
  }
50304
50556
  __name(registerControlTools, "registerControlTools");
50305
50557
 
@@ -50313,11 +50565,12 @@ init_writer();
50313
50565
  init_session();
50314
50566
  init_log();
50315
50567
  init_graph();
50568
+ init_focus();
50316
50569
  var localNow = /* @__PURE__ */ __name(() => {
50317
50570
  const d = /* @__PURE__ */ new Date(), t = /* @__PURE__ */ __name((n) => String(n).padStart(2, "0"), "t");
50318
50571
  return `${d.getFullYear()}-${t(d.getMonth() + 1)}-${t(d.getDate())}T${t(d.getHours())}:${t(d.getMinutes())}:${t(d.getSeconds())}`;
50319
50572
  }, "localNow");
50320
- var decoded = /* @__PURE__ */ __name((c, payload) => payload.length ? c.graph.decodeReturnValue(Uint8Array.from(payload)) : null, "decoded");
50573
+ var decoded2 = /* @__PURE__ */ __name((c, payload) => payload.length ? c.graph.decodeReturnValue(Uint8Array.from(payload)) : null, "decoded");
50321
50574
  async function dailyPicks(perBox) {
50322
50575
  return withCore(async (c) => {
50323
50576
  const DP = "Sooloos.Broker.Api.DailyPicksParameters";
@@ -50333,7 +50586,7 @@ async function dailyPicks(perBox) {
50333
50586
  c.serviceOid("Library")
50334
50587
  );
50335
50588
  if (!res.success) throw new RoonError(`Roon refused the Daily Picks (${res.status})`);
50336
- const boxes = items(c, decoded(c, res.payload));
50589
+ const boxes = items(c, decoded2(c, res.payload));
50337
50590
  const things = /* @__PURE__ */ __name((b) => {
50338
50591
  for (const k of ["DisplayAlbums", "Albums", "Tracks", "Performers"]) {
50339
50592
  const l = obj(c, field2(b, k))?.fields?.$items ?? [];
@@ -50357,14 +50610,25 @@ async function dailyPicks(perBox) {
50357
50610
  });
50358
50611
  }
50359
50612
  __name(dailyPicks, "dailyPicks");
50360
- async function newForYou(limit3, offset2) {
50613
+ async function newForYou(limit3, offset2, genre) {
50361
50614
  return withCore(async (c) => {
50362
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"));
50363
50616
  if (!res.success) throw new RoonError(`Roon refused New releases for you (${res.status})`);
50364
- const all = items(c, decoded(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
+ }
50365
50629
  const part = all.slice(Math.max(0, offset2), Math.max(0, offset2) + Math.min(Math.max(1, limit3), 100));
50366
50630
  await untilLoaded(c, part.map((x) => field2(x, "Album")).filter(isRef2), 3e3);
50367
- 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) => {
50368
50632
  const a = obj(c, field2(x, "Album"));
50369
50633
  if (!a || field2(a, "Title") === void 0) return null;
50370
50634
  const r = albumRow(c, a);
@@ -50389,7 +50653,7 @@ async function readMixes(c) {
50389
50653
  c.serviceOid("Library")
50390
50654
  );
50391
50655
  if (!res.success) throw new RoonError(`Roon refused the Daily Mixes (${res.status})`);
50392
- return items(c, decoded(c, res.payload));
50656
+ return items(c, decoded2(c, res.payload));
50393
50657
  }
50394
50658
  __name(readMixes, "readMixes");
50395
50659
  async function mixes() {
@@ -50398,10 +50662,43 @@ async function mixes() {
50398
50662
  const r = { key: mixKey(m), kind: gd && field2(gd, "GenreName") ? "genre" : "artist", name: mixName(c, m) };
50399
50663
  const ts = (obj(c, field2(pd ?? gd, "Touchstones"))?.fields?.$items ?? []).filter((x) => typeof x === "string").map(clean).slice(0, 8);
50400
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;
50401
50669
  return r;
50402
50670
  }) }));
50403
50671
  }
50404
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");
50405
50702
  async function playMix(key, zoneName, how) {
50406
50703
  const p = PRIORITY[how];
50407
50704
  const r = await withCore(async (c) => {
@@ -50491,6 +50788,10 @@ async function playlists(shelfName, genre, mood, limit3, offset2) {
50491
50788
  const key = playlistKey(p);
50492
50789
  seen.set(key, w);
50493
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;
50494
50795
  const d = field2(p, "Description");
50495
50796
  if (d) r.description = clean(d).replace(/\s+/g, " ").trim().slice(0, 240);
50496
50797
  const n = field2(p, "TrackCount") ?? field2(p, "ItemCount");
@@ -50520,7 +50821,7 @@ var profileObject = /* @__PURE__ */ __name((c) => {
50520
50821
  if (!p) throw new RoonError("Roon gave no profile");
50521
50822
  return p;
50522
50823
  }, "profileObject");
50523
- var decoded2 = /* @__PURE__ */ __name((c, payload) => payload.length ? c.graph.decodeReturnValue(Uint8Array.from(payload)) : null, "decoded");
50824
+ var decoded3 = /* @__PURE__ */ __name((c, payload) => payload.length ? c.graph.decodeReturnValue(Uint8Array.from(payload)) : null, "decoded");
50524
50825
  var PLAYLIST_KIND = { 1: "itunes", 5: "roon", 6: "roon (read-only)", 10: "TIDAL", 11: "file", 12: "qobuz", 13: "roon editorial", 15: "smart" };
50525
50826
  function allPlaylists(c) {
50526
50827
  const service2 = c.graph.findByType("Playlists")[0];
@@ -50536,13 +50837,24 @@ async function findPlaylist2(c, key) {
50536
50837
  }
50537
50838
  __name(findPlaylist2, "findPlaylist");
50538
50839
  async function playlists2() {
50539
- return withCore(async (c) => ({ playlists: allPlaylists(c).list.map((p) => ({
50540
- key: playlistKey(p),
50541
- name: field2(p, "Name") ?? "",
50542
- kind: PLAYLIST_KIND[field2(p, "Type")] ?? String(field2(p, "Type") ?? ""),
50543
- tracks: field2(p, "TrackCount") ?? field2(p, "ItemCount") ?? void 0,
50544
- read_only: field2(p, "IsReadOnly") === true || void 0
50545
- })) }));
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
+ }) }));
50546
50858
  }
50547
50859
  __name(playlists2, "playlists");
50548
50860
  async function playlist(key, limit3) {
@@ -50550,7 +50862,7 @@ async function playlist(key, limit3) {
50550
50862
  const p = await findPlaylist2(c, key);
50551
50863
  const res = await c.call("Playlist", "GetItems", [{ type: "ResultCallback<Query<PlaylistItem>>", name: "cb_result" }], Buffer.alloc(0), p.oid);
50552
50864
  if (!res.success) throw new RoonError(`Roon refused the playlist (${res.status})`);
50553
- const v = decoded2(c, res.payload);
50865
+ const v = decoded3(c, res.payload);
50554
50866
  const n = Math.min(Math.max(1, limit3), 300);
50555
50867
  const end = Date.now() + 3e3;
50556
50868
  let list = [];
@@ -50562,7 +50874,14 @@ async function playlist(key, limit3) {
50562
50874
  return { name: field2(p, "Name"), total: list.length, rows: list.slice(0, n).map((x, i) => {
50563
50875
  const tr = obj(c, field2(obj(c, x), "Track"));
50564
50876
  const al = obj(c, field2(tr, "Album"));
50565
- 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;
50566
50885
  }) };
50567
50886
  });
50568
50887
  }
@@ -50684,7 +51003,7 @@ async function listenLater(includePlayed) {
50684
51003
  c.serviceOid("Library")
50685
51004
  );
50686
51005
  if (!res.success) throw new RoonError(`Roon refused Listen Later (${res.status})`);
50687
- const list = items(c, decoded2(c, res.payload));
51006
+ const list = items(c, decoded3(c, res.payload));
50688
51007
  const thing = /* @__PURE__ */ __name((it) => field2(it, "Album") ?? field2(it, "Track") ?? field2(it, "Performer"), "thing");
50689
51008
  await untilLoaded(c, list.map(thing).filter(isRef2), 2500);
50690
51009
  const rows = list.map((it) => {
@@ -50794,7 +51113,7 @@ async function similarAlbum(key, limit3) {
50794
51113
  buildArgs([Arg.ref(al.oid), Arg.sooid(c.profile())]),
50795
51114
  c.serviceOid("Library")
50796
51115
  ), 8e3);
50797
- const recommended = res?.success ? items(c, decoded2(c, res.payload)) : [];
51116
+ const recommended = res?.success ? items(c, decoded3(c, res.payload)) : [];
50798
51117
  const similar = ["SimilarAlbums", "SoundsSimilar", "SeeAlso", "WasInfluencedBy", "WasInspiredBy"].flatMap((k) => items(c, field2(A, k)));
50799
51118
  await untilLoaded(c, [...recommended, ...similar].map((o) => ({ $ref: o.oid })), 2500);
50800
51119
  return { album: albumRow(c, al), similar: unique(similar), recommended: unique(recommended) };
@@ -50812,7 +51131,7 @@ async function similarArtist(key, limit3) {
50812
51131
  buildArgs([Arg.ref(lv.$ref), Arg.sooid(c.profile())]),
50813
51132
  c.serviceOid("Library")
50814
51133
  ), 8e3);
50815
- const w = res?.success ? obj(c, decoded2(c, res.payload)) : null;
51134
+ const w = res?.success ? obj(c, decoded3(c, res.payload)) : null;
50816
51135
  const list = /* @__PURE__ */ __name((k) => items(c, field2(w, k)), "list");
50817
51136
  await untilLoaded(c, ["RecommendedAlbums", "LatestAlbums", "AppearsOnAlbums"].flatMap(list).map((o) => ({ $ref: o.oid })), 2500);
50818
51137
  const rows = /* @__PURE__ */ __name((k) => list(k).filter((o) => field2(o, "Title") !== void 0).slice(0, limit3).map((o) => albumRow(c, o)), "rows");
@@ -50941,7 +51260,7 @@ async function dspOf(c, endpoint) {
50941
51260
  const cc = obj(c, field2(obj(c, field2(endpoint, "Device")), "CommonConfig"));
50942
51261
  if (!cc || field2(cc, "SupportsDspConfig") !== true) return null;
50943
51262
  const res = await short(c.call("AudioDeviceCommonConfig", "GetDspConfig", [{ type: "ResultCallback<DspConfig>", name: "conf" }], Buffer.alloc(0), cc.oid), 6e3);
50944
- const v = res?.success ? decoded2(c, res.payload) : null;
51263
+ const v = res?.success ? decoded3(c, res.payload) : null;
50945
51264
  if (!isRef2(v)) return null;
50946
51265
  const end = Date.now() + 2500;
50947
51266
  while (Date.now() < end && field2(c.graph.getObject(v.$ref), "BeforeItems") === void 0 && field2(c.graph.getObject(v.$ref), "Items") === void 0) await wait(50);
@@ -51079,6 +51398,10 @@ var FOCUS = {
51079
51398
  picks: external_exports.boolean().optional().describe("Roon's own picks."),
51080
51399
  text: external_exports.string().optional().describe("A word in the title or artist."),
51081
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."),
51082
51405
  played_within_days: days,
51083
51406
  not_played_within_days: days,
51084
51407
  added_within_days: days
@@ -51167,12 +51490,18 @@ function registerLibraryTools(server) {
51167
51490
  inputSchema: { limit: limit2(30, 8) },
51168
51491
  annotations: READ2
51169
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)));
51170
51499
  server.registerTool("roon_new_for_you", {
51171
51500
  title: "New releases for you",
51172
- 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`.',
51173
- 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).") },
51174
51503
  annotations: READ2
51175
- }, ({ limit: n, offset: o }) => run(() => newForYou(n ?? 30, o ?? 0)));
51504
+ }, ({ limit: n, offset: o, genre }) => run(() => newForYou(n ?? 30, o ?? 0, genre)));
51176
51505
  server.registerTool("roon_mixes", {
51177
51506
  title: "Daily Mixes",
51178
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.',
@@ -51227,6 +51556,12 @@ function registerLibraryTools(server) {
51227
51556
  inputSchema: { source: external_exports.enum(["roon", "qobuz", "tidal"]).optional(), limit: limit2(100, 50) },
51228
51557
  annotations: READ2
51229
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)));
51230
51565
  server.registerTool("roon_favorite", {
51231
51566
  title: "Heart an album or track",
51232
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.",
@@ -51330,7 +51665,39 @@ async function runSelfTest() {
51330
51665
  __name(runSelfTest, "runSelfTest");
51331
51666
 
51332
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");
51333
51699
  function registerStatusTool(server, internal) {
51700
+ if (process.env.DEEP_ROON_DEBUG === "1") registerDebugTool(server);
51334
51701
  server.registerTool("roon_status", {
51335
51702
  title: "Deep Roon MCP status",
51336
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.",