deep-roon-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Deep Roon MCP
2
2
 
3
3
  The parts of Roon the official API doesn't reach, for Claude and any other MCP client: the reviews and biographies Roon
4
- shows, search across your library and Qobuz at once, Qobuz's editorial shelves, album pages with credits, artist
5
- discographies, and your own play history with real "most played" counts.
4
+ shows, search across your library and Qobuz or TIDAL at once, their editorial shelves and TIDAL's playlists and mixes,
5
+ album pages with credits, artist discographies, and your own play history with real "most played" counts.
6
6
 
7
7
  > **Read this first.** Deep Roon MCP is unofficial: it is not made, endorsed or supported by Roon Labs. It uses Roon's
8
8
  > internal protocol — the one Roon's own apps use — through Arthur Soares'
@@ -22,19 +22,19 @@ server of its own and sends nothing anywhere else. What you ask goes to your MCP
22
22
 
23
23
  ## Status
24
24
 
25
- Version 0.1: fifty tools.
25
+ Version 0.2: fifty-one tools. Qobuz, TIDAL or both — whatever is signed in on your Core.
26
26
 
27
27
  **Reading** — changes nothing in Roon:
28
28
 
29
29
  | Tool | What it does |
30
30
  |---|---|
31
- | `roon_search` | Your library and Qobuz at once: albums (with format), artists, tracks |
32
- | `roon_new` | Qobuz's shelves: new, qobuzissimes, press awards, top, trending, ideal discography — by genre |
33
- | `roon_genres` | Qobuz's genres and their ids |
31
+ | `roon_search` | Your library, Qobuz and TIDAL at once: albums (with format), artists, tracks — each says where it's from |
32
+ | `roon_new` | Qobuz's shelves (new, qobuzissimes, press awards, top, trending, ideal discography) or TIDAL's (new, recommended, top, rising, masters; albums or tracks) — by genre |
33
+ | `roon_genres` | Qobuz's genres and their ids, or TIDAL's genres and moods |
34
34
  | `roon_album` | An album's page: tracks, release dates, label, barcode, credits |
35
35
  | `roon_review` | The review Roon shows, in full, with its author |
36
36
  | `roon_bio` | The biography Roon shows, in full, with related artists |
37
- | `roon_artist` | An artist's discography, oldest first |
37
+ | `roon_artist` | An artist's discography at Qobuz or TIDAL, oldest first |
38
38
  | `roon_status` | How Deep Roon MCP stands: Core, pairing, internal protocol, self-test, newer version |
39
39
  | `roon_zones` | Roon's zones and groups |
40
40
  | `roon_now_playing` | What plays in each zone, with the volume |
@@ -50,9 +50,10 @@ Version 0.1: fifty tools.
50
50
  | `roon_new_for_you` | New releases Roon picked for you |
51
51
  | `roon_mixes` | Today's Daily Mixes |
52
52
  | `roon_playlists`, `roon_playlist` | Your playlists, and one playlist's tracks |
53
- | `roon_favorites` | Albums with a heart in Roon, or your Qobuz favourites |
53
+ | `roon_tidal_playlists` | TIDAL's playlists: its editorial shelves, by genre or mood, and your own TIDAL mixes |
54
+ | `roon_favorites` | Albums with a heart in Roon, or your Qobuz or TIDAL favourites |
54
55
  | `roon_listen_later` | Your Listen Later list |
55
- | `roon_versions` | Other versions of an album: remasters, hi-res, in your library and at Qobuz |
56
+ | `roon_versions` | Other versions of an album: remasters, hi-res, in your library and streaming |
56
57
  | `roon_similar` | More like this, for an album or an artist |
57
58
  | `roon_signal_path` | Roon's signal path for what plays: is it bit-perfect, what happens on the way |
58
59
  | `roon_dsp` | MUSE: the DSP set up for a zone |
@@ -72,7 +73,7 @@ Version 0.1: fifty tools.
72
73
  | `roon_group`, `roon_ungroup` | Group zones, and undo it |
73
74
  | `roon_sleep` | A sleep timer |
74
75
  | `roon_standby` | Put a device in standby, or wake it |
75
- | `roon_play_playlist` | A playlist on a zone, optionally shuffled |
76
+ | `roon_play_playlist` | A playlist on a zone (also a TIDAL one), optionally shuffled |
76
77
  | `roon_play_mix` | One of today's Daily Mixes |
77
78
 
78
79
  **Changing your library or sound** — only on your request; each one reads the result back and says if Roon didn't follow:
@@ -81,7 +82,7 @@ Version 0.1: fifty tools.
81
82
  |---|---|
82
83
  | `roon_favorite` | A heart on an album or track, or off |
83
84
  | `roon_listen_later_set` | On or off the Listen Later list |
84
- | `roon_library_add` | A Qobuz album into your library |
85
+ | `roon_library_add` | A Qobuz or TIDAL album into your library |
85
86
  | `roon_playlist_create` | A new playlist from tracks |
86
87
  | `roon_dsp_set` | MUSE on or off, one part on or off, or a preset |
87
88
 
@@ -103,7 +104,7 @@ The settings (Claude Desktop › Settings › Extensions › Deep Roon MCP):
103
104
  | Setting | What it does |
104
105
  |---|---|
105
106
  | Roon Core address | Empty: found on your network. Fill in the Core's address for another subnet or a private VPN (Tailscale) |
106
- | Essentials only | Seventeen tools instead of fifty, for small models |
107
+ | Essentials only | Seventeen tools instead of fifty-one, for small models |
107
108
  | Use Roon's internal protocol | Off: only the official API — zones, playback, volume, grouping; no search, reviews, Focus or history |
108
109
 
109
110
  ### Other clients
@@ -116,6 +117,25 @@ Needs [Node.js](https://nodejs.org) 18 or newer. The package runs straight from
116
117
  claude mcp add deep-roon -- npx -y deep-roon-mcp
117
118
  ```
118
119
 
120
+ **ChatGPT desktop app and Codex** — they share one configuration. In the ChatGPT desktop app: Settings › MCP servers ›
121
+ Add server, name `deep-roon`, type STDIO, command `npx`, arguments `-y deep-roon-mcp`; save, then Restart. Or from a
122
+ terminal:
123
+
124
+ ```sh
125
+ codex mcp add deep-roon -- npx -y deep-roon-mcp
126
+ ```
127
+
128
+ or by hand in `~/.codex/config.toml`:
129
+
130
+ ```toml
131
+ [mcp_servers.deep-roon]
132
+ command = "npx"
133
+ args = ["-y", "deep-roon-mcp"]
134
+ # env = { ROON_HOST = "", DEEP_ROON_TOOLS = "", DEEP_ROON_INTERNAL = "" }
135
+ ```
136
+
137
+ ChatGPT on the web can't run a server on your computer — use the desktop app.
138
+
119
139
  **Cursor, VS Code, Windsurf, Cline, LM Studio, Claude Desktop by hand …** — in the client's MCP configuration:
120
140
 
121
141
  ```json
@@ -146,7 +166,8 @@ cd deep-roon-mcp && npm install && npm run build
146
166
  ## When something's wrong
147
167
 
148
168
  **"No Roon Core found" on a Mac:** macOS asks once whether the app may use the local network — for Claude Desktop that's
149
- Claude and Claude Helper. Allow both (System Settings › Privacy & Security › Local Network), then try again.
169
+ Claude and Claude Helper, for the ChatGPT desktop app ChatGPT. Allow it (System Settings › Privacy & Security › Local
170
+ Network), then try again.
150
171
 
151
172
  Ask your client to run **`roon_status`**: the Core it found, whether it's enabled in Roon, whether the internal protocol
152
173
  works, and a self-test of a few reads with the tools each stands for. After a Roon update a tool may break until Deep Roon
@@ -171,4 +192,4 @@ Arthur Soares for the protocol work (MIT, in `src/intern`). Roon Labs for allowi
171
192
 
172
193
  ## License
173
194
 
174
- Apache 2.0 — see `LICENSE` and `NOTICE`. Roon is a trademark of Roon Labs LLC; Qobuz of Xandrie SA.
195
+ Apache 2.0 — see `LICENSE` and `NOTICE`. Roon is a trademark of Roon Labs LLC; Qobuz of Xandrie SA; TIDAL of TIDAL Music AS.
@@ -8628,9 +8628,9 @@ function formatType(t) {
8628
8628
  if (EXPLICIT[t]) return EXPLICIT[t];
8629
8629
  return `${API_NS}${t}`;
8630
8630
  }
8631
- function formatMethodSignature(service, method, params) {
8631
+ function formatMethodSignature(service2, method, params) {
8632
8632
  const args = params.map((p) => formatType(p.type)).join(", ");
8633
- return `${API_NS}${service}::${method}(${args})`;
8633
+ return `${API_NS}${service2}::${method}(${args})`;
8634
8634
  }
8635
8635
  var API_NS, KEYWORD_PRIMITIVES, EXPLICIT, GENERIC_NS;
8636
8636
  var init_signature = __esm({
@@ -9250,9 +9250,9 @@ var init_client = __esm({
9250
9250
  }
9251
9251
  // --- generic call ---
9252
9252
  /** Call a method by service + name + params + pre-built args; returns the result. */
9253
- call(service, method, params, args, objectId) {
9254
- const sig = formatMethodSignature(service, method, params);
9255
- return this.remoting.callMethod(objectId ?? this.serviceOid(service), sig, args);
9253
+ call(service2, method, params, args, objectId) {
9254
+ const sig = formatMethodSignature(service2, method, params);
9255
+ return this.remoting.callMethod(objectId ?? this.serviceOid(service2), sig, args);
9256
9256
  }
9257
9257
  /** Build a sparse value using the shared full schema and stable field indexes. */
9258
9258
  structArg(typeName, fields) {
@@ -9348,7 +9348,7 @@ var init_client = __esm({
9348
9348
  rootMembers.splice(3, 0, "Playlists", "Genres");
9349
9349
  for (const type of ["Playlist", "BrowserGenre", "GenreLite"]) leaves.add(type);
9350
9350
  }
9351
- const seen = /* @__PURE__ */ new Set();
9351
+ const seen2 = /* @__PURE__ */ new Set();
9352
9352
  const out = [];
9353
9353
  let complete = true;
9354
9354
  const member2 = /* @__PURE__ */ __name((fields, name) => Object.entries(fields).find(([key]) => key.endsWith(`::${name}`))?.[1], "member");
@@ -9357,8 +9357,8 @@ var init_client = __esm({
9357
9357
  let type;
9358
9358
  let fields;
9359
9359
  if (isRef2(value)) {
9360
- if (seen.has(value.$ref)) return;
9361
- seen.add(value.$ref);
9360
+ if (seen2.has(value.$ref)) return;
9361
+ seen2.add(value.$ref);
9362
9362
  const object3 = this.graph.getObject(value.$ref);
9363
9363
  if (!object3) {
9364
9364
  complete = false;
@@ -15416,7 +15416,7 @@ var require_lib2 = __commonJS({
15416
15416
  var version2;
15417
15417
  var init_package = __esm({
15418
15418
  "package.json"() {
15419
- version2 = "0.1.0";
15419
+ version2 = "0.2.1";
15420
15420
  }
15421
15421
  });
15422
15422
 
@@ -15731,41 +15731,12 @@ var init_session = __esm({
15731
15731
  });
15732
15732
 
15733
15733
  // src/roon/graph.ts
15734
- var graph_exports = {};
15735
- __export(graph_exports, {
15736
- albumRow: () => albumRow,
15737
- clean: () => clean,
15738
- dateText: () => dateText,
15739
- dotnetDate: () => dotnetDate,
15740
- duration: () => duration3,
15741
- field: () => field2,
15742
- flag: () => flag,
15743
- imageKey: () => imageKey,
15744
- items: () => items,
15745
- localTime: () => localTime,
15746
- obj: () => obj,
15747
- partialList: () => partialList,
15748
- refList: () => refList,
15749
- resultRow: () => resultRow,
15750
- short: () => short,
15751
- strings: () => strings,
15752
- typeOf: () => typeOf,
15753
- untilField: () => untilField,
15754
- untilLoaded: () => untilLoaded,
15755
- wait: () => wait,
15756
- yearOf: () => yearOf
15757
- });
15758
15734
  function field2(o, name) {
15759
15735
  if (!o) return void 0;
15760
15736
  const f = o.fields || o;
15761
15737
  for (const [k, v] of Object.entries(f)) if (k.endsWith("::" + name)) return v;
15762
15738
  return void 0;
15763
15739
  }
15764
- function imageKey(c, o, name = "Image") {
15765
- const url2 = field2(obj(c, field2(o, name)), "Url");
15766
- const m = /^broker:\/\/\/image\/([^.]+)\./.exec(String(url2 ?? ""));
15767
- return m ? m[1] : void 0;
15768
- }
15769
15740
  function dateParts(v) {
15770
15741
  if (!Buffer.isBuffer(v) || v.length < 2) return void 0;
15771
15742
  const n = new BinaryReader(Uint8Array.from(v)).flexInt();
@@ -15842,12 +15813,20 @@ function localTime(iso) {
15842
15813
  const t = /* @__PURE__ */ __name((x) => String(x).padStart(2, "0"), "t");
15843
15814
  return `${d.getFullYear()}-${t(d.getMonth() + 1)}-${t(d.getDate())} ${t(d.getHours())}:${t(d.getMinutes())}`;
15844
15815
  }
15816
+ function sourceOf(...os2) {
15817
+ for (const o of os2) {
15818
+ const v = field2(o, "ContentSource") ?? field2(o, "Source");
15819
+ if (v === 128) return "TIDAL";
15820
+ if (v === 256) return "Qobuz";
15821
+ if (v === 1) return "library";
15822
+ }
15823
+ return void 0;
15824
+ }
15845
15825
  function albumRow(c, o) {
15846
15826
  const fm = obj(c, field2(o, "AlbumFormat"));
15847
- const r = { kind: "album", key: String(field2(o, "AlbumId") ?? ""), title: field2(o, "Title") ?? "", artist: clean(field2(o, "PerformedBy")), source: "library" };
15827
+ const r = { kind: "album", key: String(field2(o, "AlbumId") ?? ""), title: field2(o, "Title") ?? "", artist: clean(field2(o, "PerformedBy")), source: sourceOf(o) ?? (field2(fm, "IsStreaming") === true ? "streaming" : "library") };
15848
15828
  const f = field2(fm, "FormatString");
15849
15829
  if (f) r.format = f;
15850
- if (field2(fm, "IsStreaming") === true) r.source = "Qobuz";
15851
15830
  const y = yearOf(field2(o, "OriginalReleaseDate")) ?? yearOf(field2(o, "ReleaseDate"));
15852
15831
  if (y) r.year = y;
15853
15832
  const st = field2(o, "Rating");
@@ -15881,7 +15860,7 @@ function resultRow(c, o) {
15881
15860
  if (f) r.format = f;
15882
15861
  const l = field2(o, "LengthSeconds");
15883
15862
  if (typeof l === "number") r.length = duration3(l);
15884
- r.source = field2(o, "Source") === 256 ? "Qobuz" : "library";
15863
+ r.source = sourceOf(o, al) ?? "library";
15885
15864
  return r;
15886
15865
  }
15887
15866
  return { kind: t, title: field2(o, "Title") ?? field2(o, "Name") ?? "" };
@@ -15902,7 +15881,6 @@ var init_graph = __esm({
15902
15881
  return (o?.fields?.$items ?? o?.$items ?? []).filter((x) => typeof x === "string");
15903
15882
  }, "strings");
15904
15883
  flag = /* @__PURE__ */ __name((v) => v === true || Buffer.isBuffer(v) && v.length > 1 && v[0] >= 1, "flag");
15905
- __name(imageKey, "imageKey");
15906
15884
  __name(dateParts, "dateParts");
15907
15885
  yearOf = /* @__PURE__ */ __name((v) => dateParts(v)?.[0], "yearOf");
15908
15886
  __name(dateText, "dateText");
@@ -15915,12 +15893,44 @@ var init_graph = __esm({
15915
15893
  __name(short, "short");
15916
15894
  __name(duration3, "duration");
15917
15895
  __name(localTime, "localTime");
15896
+ __name(sourceOf, "sourceOf");
15918
15897
  __name(albumRow, "albumRow");
15919
15898
  __name(resultRow, "resultRow");
15920
15899
  }
15921
15900
  });
15922
15901
 
15923
15902
  // src/roon/library.ts
15903
+ function signedIn(c, s) {
15904
+ const o = c.graph.findByType(SERVICE_TYPE[s])[0];
15905
+ if (!o) return null;
15906
+ const st = field2(o, "LoginStatus");
15907
+ return st === void 0 || st === 4 ? o : null;
15908
+ }
15909
+ function streaming(c, wanted) {
15910
+ if (wanted) {
15911
+ const o = signedIn(c, wanted);
15912
+ if (!o) throw new RoonError(`${SERVICE_NAME[wanted]} isn't signed in on this Roon Core`, "unavailable");
15913
+ return { service: wanted, o };
15914
+ }
15915
+ for (const s of ["qobuz", "tidal"]) {
15916
+ const o = signedIn(c, s);
15917
+ if (o) return { service: s, o };
15918
+ }
15919
+ throw new RoonError("Neither Qobuz nor TIDAL is signed in on this Roon Core", "unavailable");
15920
+ }
15921
+ async function shelf(c, s, o, method, of, offset2, count, before = NONE2, after = NONE2) {
15922
+ const res = await c.call(
15923
+ SERVICE_TYPE[s],
15924
+ method,
15925
+ [...before.types, { type: "int", name: "offset" }, { type: "int", name: "count" }, ...after.types, { type: `ResultCallback<PartialList<${of}>>`, name: "cb_result" }],
15926
+ Buffer.concat([before.args, buildArgs([Arg.int(Math.max(0, offset2)), Arg.int(Math.min(Math.max(1, count), 100))]), after.args]),
15927
+ o.oid
15928
+ );
15929
+ if (!res.success) throw new RoonError(`${SERVICE_NAME[s]}::${method}: ${res.status}`);
15930
+ const list = partialList(c, c.graph.decodeReturnValue(Uint8Array.from(res.payload)));
15931
+ await untilLoaded(c, list.items);
15932
+ return { total: list.total, items: list.items.map((x) => obj(c, x)).filter(Boolean) };
15933
+ }
15924
15934
  async function search(query, limit3, kind) {
15925
15935
  return withCore(async (c) => {
15926
15936
  const n = Math.min(Math.max(1, limit3), 100);
@@ -15930,39 +15940,107 @@ async function search(query, limit3, kind) {
15930
15940
  return { query, rows };
15931
15941
  });
15932
15942
  }
15933
- async function section(name, limit3, offset2, genres2 = []) {
15934
- const method = SECTIONS[name];
15935
- if (!method) throw new RoonError(`Unknown section; choose from ${Object.keys(SECTIONS).join(", ")}`, "invalid");
15943
+ async function section(name, limit3, offset2, genres2 = [], wanted, what = "albums") {
15936
15944
  return withCore(async (c) => {
15937
- const q = qobuz(c);
15938
- const res = await c.call(
15939
- "Qobuz",
15940
- method,
15941
- [{ type: "int", name: "offset" }, { type: "int", name: "count" }, { type: "IList<string>", name: "filter_genre_ids" }, { type: "ResultCallback<PartialList<AlbumLite>>", name: "cb_result" }],
15942
- buildArgs([Arg.int(Math.max(0, offset2)), Arg.int(Math.min(Math.max(1, limit3), 100)), Arg.collection(genres2.map((g) => buildArgs([Arg.str(g)])))]),
15943
- q.oid
15944
- );
15945
- if (!res.success) throw new RoonError(`Qobuz::${method}: ${res.status}`);
15946
- const list = partialList(c, c.graph.decodeReturnValue(Uint8Array.from(res.payload)));
15947
- await untilLoaded(c, list.items);
15945
+ const { service: service2, o } = streaming(c, wanted);
15946
+ const methods = SECTIONS[service2];
15947
+ if (!methods[name] && !(service2 === "tidal" && genres2.length)) throw new RoonError(`${SERVICE_NAME[service2]} has no shelf "${name}"; choose from ${Object.keys(methods).join(", ")}`, "invalid");
15948
+ if (what === "tracks" && service2 !== "tidal") throw new RoonError("Shelves of tracks are TIDAL's; Qobuz's shelves are albums", "invalid");
15949
+ if (what === "tracks" && name === "masters") throw new RoonError("TIDAL's masters shelf is albums only", "invalid");
15950
+ let res;
15951
+ if (service2 === "qobuz") {
15952
+ if (genres2.some((g) => !/^\d+$/.test(g))) throw new RoonError("Qobuz genres are ids (numbers) \u2014 see roon_genres", "invalid");
15953
+ res = await shelf(
15954
+ c,
15955
+ service2,
15956
+ o,
15957
+ methods[name],
15958
+ "AlbumLite",
15959
+ offset2,
15960
+ limit3,
15961
+ NONE2,
15962
+ { types: [{ type: "IList<string>", name: "filter_genre_ids" }], args: buildArgs([Arg.collection(genres2.map((g) => buildArgs([Arg.str(g)])))]) }
15963
+ );
15964
+ } else if (genres2.length) {
15965
+ const g = await tidalPick(c, o, "GetGenres", genres2[0]);
15966
+ res = await shelf(
15967
+ c,
15968
+ service2,
15969
+ o,
15970
+ what === "tracks" ? "GetGenreTracks" : "GetGenreAlbums",
15971
+ what === "tracks" ? "TrackLite" : "AlbumLite",
15972
+ offset2,
15973
+ limit3,
15974
+ { types: [{ type: "TidalGenre", name: "genre" }], args: tidalStruct(c, "TidalGenre", g) }
15975
+ );
15976
+ } else {
15977
+ res = await shelf(c, service2, o, methods[name] + (what === "tracks" ? "Tracks" : "Albums"), what === "tracks" ? "TrackLite" : "AlbumLite", offset2, limit3);
15978
+ }
15948
15979
  return {
15949
- section: name,
15980
+ service: SERVICE_NAME[service2],
15981
+ section: genres2.length && service2 === "tidal" ? void 0 : name,
15950
15982
  genre: genres2.length ? genres2 : void 0,
15951
- total: list.total,
15983
+ what: service2 === "tidal" ? what : void 0,
15984
+ total: res.total,
15952
15985
  offset: offset2,
15953
- rows: list.items.map((x) => obj(c, x)).filter(Boolean).map((o) => albumRow(c, o))
15986
+ rows: res.items.map((x) => resultRow(c, x))
15954
15987
  };
15955
15988
  });
15956
15989
  }
15957
- async function genres(parent) {
15990
+ async function tidalList(c, o, method) {
15991
+ const res = await c.call("Tidal", method, [{ type: `ResultCallback<IList<${method === "GetGenres" ? "TidalGenre" : "TidalMood"}>>`, name: "cb_result" }], Buffer.alloc(0), o.oid);
15992
+ if (!res.success) throw new RoonError(`TIDAL::${method}: ${res.status}`);
15993
+ const rows = [];
15994
+ if (res.payload.length > 2) {
15995
+ const r = new BinaryReader(Uint8Array.from(res.payload));
15996
+ r.integer();
15997
+ const n = r.flexInt();
15998
+ for (let i = 0; i < n && r.remaining > 0; i++) {
15999
+ r.flexInt();
16000
+ r.flexInt();
16001
+ const len = r.flexInt();
16002
+ const m = new BinaryReader(r.bytes(len));
16003
+ const g = {};
16004
+ while (m.remaining > 0) {
16005
+ const idx = m.flexInt();
16006
+ if (idx !== 1 && idx !== 2) break;
16007
+ const t = m.string();
16008
+ if (idx === 1) g.name = t;
16009
+ else g.path = t;
16010
+ }
16011
+ if (g.path) rows.push({ name: g.name ?? g.path, path: g.path });
16012
+ }
16013
+ }
16014
+ return rows;
16015
+ }
16016
+ async function tidalPick(c, o, method, want) {
16017
+ const all = await tidalList(c, o, method);
16018
+ const w = want.trim().toLowerCase();
16019
+ const g = all.find((x) => x.path.toLowerCase() === w) ?? all.find((x) => x.name.toLowerCase() === w);
16020
+ if (!g) throw new RoonError(`TIDAL has no ${method === "GetGenres" ? "genre" : "mood"} "${want}"; choose from ${all.map((x) => x.path).join(", ")}`, "invalid");
16021
+ return g;
16022
+ }
16023
+ function tidalStruct(c, type, g) {
16024
+ const T = `Sooloos.Broker.Api.${type}`;
16025
+ return c.structArg(T, [
16026
+ { name: `string ${T}::Name`, propType: 20, value: new BinaryWriter().string(g.name).toBuffer() },
16027
+ { name: `string ${T}::Path`, propType: 20, value: new BinaryWriter().string(g.path).toBuffer() }
16028
+ ]);
16029
+ }
16030
+ async function genres(parent, wanted) {
15958
16031
  return withCore(async (c) => {
15959
- const q = qobuz(c);
16032
+ const { service: service2, o } = streaming(c, wanted);
16033
+ if (service2 === "tidal") {
16034
+ if (parent) throw new RoonError("TIDAL's genres have no sub-genres", "invalid");
16035
+ const [g, m] = [await tidalList(c, o, "GetGenres"), await tidalList(c, o, "GetMoods")];
16036
+ return { service: "TIDAL", genres: g.map((x) => ({ id: x.path, name: x.name })), moods: m.map((x) => ({ id: x.path, name: x.name })) };
16037
+ }
15960
16038
  const res = await c.call(
15961
16039
  "Qobuz",
15962
16040
  "GetGenres",
15963
16041
  [{ type: "string", name: "parentGenreId" }, { type: "ResultCallback<IList<QobuzGenre>>", name: "cb_result" }],
15964
16042
  buildArgs([Arg.str(parent || null)]),
15965
- q.oid
16043
+ o.oid
15966
16044
  );
15967
16045
  if (!res.success) throw new RoonError(`Qobuz::GetGenres: ${res.status}`);
15968
16046
  const rows = [];
@@ -15986,7 +16064,7 @@ async function genres(parent) {
15986
16064
  if (g.id) rows.push({ id: g.id, name: g.name });
15987
16065
  }
15988
16066
  }
15989
- return { parent: parent || void 0, genres: rows };
16067
+ return { service: "Qobuz", parent: parent || void 0, genres: rows };
15990
16068
  });
15991
16069
  }
15992
16070
  async function findAlbum(c, key) {
@@ -16177,16 +16255,16 @@ async function bio(key, max, wiki) {
16177
16255
  };
16178
16256
  });
16179
16257
  }
16180
- async function discography(key) {
16258
+ async function discography(key, wanted) {
16181
16259
  return withCore(async (c) => {
16182
- const q = qobuz(c);
16260
+ const { service: service2, o: q } = streaming(c, wanted);
16183
16261
  const pv = await performer(c, key);
16184
16262
  const end = Date.now() + 1500;
16185
16263
  while (Date.now() < end && field2(c.graph.getObject(pv.$ref), "Name") === void 0) await wait(40);
16186
16264
  const p = c.graph.getObject(pv.$ref);
16187
16265
  if (!p || field2(p, "Name") === void 0) throw new RoonError(`Roon knows no artist with key ${key}`, "not_found");
16188
- const res = await c.call("Qobuz", "GetPerformerMainAlbumLites", [{ type: "long", name: "performerid" }, { type: "ResultCallback<IList<AlbumLite>>", name: "cb_result" }], buildArgs([Arg.long(BigInt(key))]), q.oid);
16189
- if (!res.success) throw new RoonError(`Qobuz::GetPerformerMainAlbumLites: ${res.status}`);
16266
+ const res = await c.call(SERVICE_TYPE[service2], "GetPerformerMainAlbumLites", [{ type: "long", name: "performerid" }, { type: "ResultCallback<IList<AlbumLite>>", name: "cb_result" }], buildArgs([Arg.long(BigInt(key))]), q.oid);
16267
+ if (!res.success) throw new RoonError(`${SERVICE_NAME[service2]}::GetPerformerMainAlbumLites: ${res.status}`);
16190
16268
  const refs = refList(res.payload);
16191
16269
  await untilLoaded(c, refs.map((oid) => ({ $ref: oid })), 3e3);
16192
16270
  const albums = refs.map((oid) => c.graph.getObject(oid)).filter((o) => o && field2(o, "Title") !== void 0).map((o) => albumRow(c, o)).map((r, i) => ({ r, i })).sort((a, b) => (a.r.year ?? 9999) - (b.r.year ?? 9999) || a.i - b.i).map((x) => x.r);
@@ -16195,10 +16273,10 @@ async function discography(key) {
16195
16273
  if (typeof n === "number") artist.albums_in_library = n;
16196
16274
  const from = field2(p, "BirthPlace");
16197
16275
  if (from) artist.from = from;
16198
- return { artist, albums };
16276
+ return { service: SERVICE_NAME[service2], artist, albums };
16199
16277
  });
16200
16278
  }
16201
- var qobuz, SECTIONS, library, CREDIT_KIND, cut, choose;
16279
+ var SERVICE_NAME, SERVICE_TYPE, NONE2, SECTIONS, library, CREDIT_KIND, cut, choose;
16202
16280
  var init_library = __esm({
16203
16281
  "src/roon/library.ts"() {
16204
16282
  "use strict";
@@ -16208,21 +16286,28 @@ var init_library = __esm({
16208
16286
  init_writer();
16209
16287
  init_session();
16210
16288
  init_graph();
16211
- qobuz = /* @__PURE__ */ __name((c) => {
16212
- const q = c.graph.findByType("Qobuz")[0];
16213
- if (!q) throw new RoonError("This Roon Core has no Qobuz service (Qobuz isn't signed in?)", "unavailable");
16214
- return q;
16215
- }, "qobuz");
16289
+ SERVICE_NAME = { qobuz: "Qobuz", tidal: "TIDAL" };
16290
+ SERVICE_TYPE = { qobuz: "Qobuz", tidal: "Tidal" };
16291
+ __name(signedIn, "signedIn");
16292
+ __name(streaming, "streaming");
16293
+ NONE2 = { types: [], args: Buffer.alloc(0) };
16294
+ __name(shelf, "shelf");
16216
16295
  __name(search, "search");
16217
16296
  SECTIONS = {
16218
- new: "GetNewAlbums",
16219
- qobuzissimes: "GetQobuzissimesAlbums",
16220
- press_awards: "GetPressAwardsAlbums",
16221
- top: "GetTopAlbums",
16222
- trending: "GetStillTrendingAlbums",
16223
- ideal_discography: "GetIdealDiscographyAlbums"
16297
+ qobuz: {
16298
+ new: "GetNewAlbums",
16299
+ qobuzissimes: "GetQobuzissimesAlbums",
16300
+ press_awards: "GetPressAwardsAlbums",
16301
+ top: "GetTopAlbums",
16302
+ trending: "GetStillTrendingAlbums",
16303
+ ideal_discography: "GetIdealDiscographyAlbums"
16304
+ },
16305
+ tidal: { new: "GetNew", recommended: "GetRecommended", top: "GetTop", rising: "GetRising", masters: "GetMasters" }
16224
16306
  };
16225
16307
  __name(section, "section");
16308
+ __name(tidalList, "tidalList");
16309
+ __name(tidalPick, "tidalPick");
16310
+ __name(tidalStruct, "tidalStruct");
16226
16311
  __name(genres, "genres");
16227
16312
  library = /* @__PURE__ */ __name((c) => c.serviceOid("Library"), "library");
16228
16313
  __name(findAlbum, "findAlbum");
@@ -24017,12 +24102,12 @@ function handleCatchall(proms, input2, payload, ctx, def, inst, abortEarly) {
24017
24102
  const t = _catchall.def.type;
24018
24103
  const optin = _catchall.optin;
24019
24104
  const optout = _catchall.optout;
24020
- let seen = 0;
24105
+ let seen2 = 0;
24021
24106
  for (const key in input2) {
24022
- if (abortEarly && payload.issues.length !== seen) {
24023
- if (aborted(payload, seen))
24107
+ if (abortEarly && payload.issues.length !== seen2) {
24108
+ if (aborted(payload, seen2))
24024
24109
  break;
24025
- seen = payload.issues.length;
24110
+ seen2 = payload.issues.length;
24026
24111
  }
24027
24112
  if (keySet.has(key))
24028
24113
  continue;
@@ -24112,12 +24197,12 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
24112
24197
  const proms = [];
24113
24198
  const shape = value.shape;
24114
24199
  const abortEarly = ctx?.abortEarly;
24115
- let seen = payload.issues.length;
24200
+ let seen2 = payload.issues.length;
24116
24201
  for (const key of value.allKeys) {
24117
- if (abortEarly && payload.issues.length !== seen) {
24118
- if (aborted(payload, seen))
24202
+ if (abortEarly && payload.issues.length !== seen2) {
24203
+ if (aborted(payload, seen2))
24119
24204
  break;
24120
- seen = payload.issues.length;
24205
+ seen2 = payload.issues.length;
24121
24206
  }
24122
24207
  if (key === "__proto__")
24123
24208
  continue;
@@ -24663,12 +24748,12 @@ var $ZodTuple = /* @__PURE__ */ $constructor("$ZodTuple", (inst, def) => {
24663
24748
  if (def.rest && !itemAborted) {
24664
24749
  let i = items2.length - 1;
24665
24750
  const rest = input2.slice(items2.length);
24666
- let seen = payload.issues.length;
24751
+ let seen2 = payload.issues.length;
24667
24752
  for (const el of rest) {
24668
- if (abortEarly && payload.issues.length !== seen) {
24669
- if (aborted(payload, seen))
24753
+ if (abortEarly && payload.issues.length !== seen2) {
24754
+ if (aborted(payload, seen2))
24670
24755
  break;
24671
- seen = payload.issues.length;
24756
+ seen2 = payload.issues.length;
24672
24757
  }
24673
24758
  i++;
24674
24759
  const result = def.rest._zod.run({ value: el, issues: [] }, ctx);
@@ -24901,12 +24986,12 @@ var $ZodMap = /* @__PURE__ */ $constructor("$ZodMap", (inst, def) => {
24901
24986
  const proms = [];
24902
24987
  payload.value = memo2 ? memo2.alloc(inst, payload, /* @__PURE__ */ new Map(), ctx) : /* @__PURE__ */ new Map();
24903
24988
  const abortEarly = ctx?.abortEarly;
24904
- let seen = payload.issues.length;
24989
+ let seen2 = payload.issues.length;
24905
24990
  for (const [key, value] of input2) {
24906
- if (abortEarly && payload.issues.length !== seen) {
24907
- if (aborted(payload, seen))
24991
+ if (abortEarly && payload.issues.length !== seen2) {
24992
+ if (aborted(payload, seen2))
24908
24993
  break;
24909
- seen = payload.issues.length;
24994
+ seen2 = payload.issues.length;
24910
24995
  }
24911
24996
  const keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx);
24912
24997
  const valueResult = def.valueType._zod.run({ value, issues: [] }, ctx);
@@ -24973,12 +25058,12 @@ var $ZodSet = /* @__PURE__ */ $constructor("$ZodSet", (inst, def) => {
24973
25058
  const proms = [];
24974
25059
  payload.value = memo2 ? memo2.alloc(inst, payload, /* @__PURE__ */ new Set(), ctx) : /* @__PURE__ */ new Set();
24975
25060
  const abortEarly = ctx?.abortEarly;
24976
- let seen = payload.issues.length;
25061
+ let seen2 = payload.issues.length;
24977
25062
  for (const item of input2) {
24978
- if (abortEarly && payload.issues.length !== seen) {
24979
- if (aborted(payload, seen))
25063
+ if (abortEarly && payload.issues.length !== seen2) {
25064
+ if (aborted(payload, seen2))
24980
25065
  break;
24981
- seen = payload.issues.length;
25066
+ seen2 = payload.issues.length;
24982
25067
  }
24983
25068
  const result = def.valueType._zod.run({ value: item, issues: [] }, ctx);
24984
25069
  if (result instanceof Promise) {
@@ -36558,14 +36643,14 @@ __name(handleUnrepresentable, "handleUnrepresentable");
36558
36643
  function processSchema(schema, ctx, _params = { path: [], schemaPath: [] }) {
36559
36644
  var _a3;
36560
36645
  const def = schema._zod.def;
36561
- const seen = ctx.seen.get(schema);
36562
- if (seen) {
36563
- seen.count++;
36646
+ const seen2 = ctx.seen.get(schema);
36647
+ if (seen2) {
36648
+ seen2.count++;
36564
36649
  const isCycle = _params.schemaPath.includes(schema);
36565
36650
  if (isCycle) {
36566
- seen.cycle = _params.path;
36651
+ seen2.cycle = _params.path;
36567
36652
  }
36568
- return seen.schema;
36653
+ return seen2.schema;
36569
36654
  }
36570
36655
  const result = { schema: {}, count: 1, cycle: void 0, path: _params.path };
36571
36656
  ctx.seen.set(schema, result);
@@ -36657,12 +36742,12 @@ function extractDefs(ctx, schema) {
36657
36742
  if (entry[1].schema.$ref) {
36658
36743
  return;
36659
36744
  }
36660
- const seen = entry[1];
36745
+ const seen2 = entry[1];
36661
36746
  const { ref, defId } = makeURI(entry);
36662
- seen.def = { ...seen.schema };
36747
+ seen2.def = { ...seen2.schema };
36663
36748
  if (defId)
36664
- seen.defId = defId;
36665
- const schema2 = seen.schema;
36749
+ seen2.defId = defId;
36750
+ const schema2 = seen2.schema;
36666
36751
  for (const key in schema2) {
36667
36752
  delete schema2[key];
36668
36753
  }
@@ -36670,16 +36755,16 @@ function extractDefs(ctx, schema) {
36670
36755
  }, "extractToDef");
36671
36756
  if (ctx.cycles === "throw") {
36672
36757
  for (const entry of ctx.seen.entries()) {
36673
- const seen = entry[1];
36674
- if (seen.cycle) {
36675
- throw new Error(`Cycle detected: #/${seen.cycle?.join("/")}/<root>
36758
+ const seen2 = entry[1];
36759
+ if (seen2.cycle) {
36760
+ throw new Error(`Cycle detected: #/${seen2.cycle?.join("/")}/<root>
36676
36761
 
36677
36762
  Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`);
36678
36763
  }
36679
36764
  }
36680
36765
  }
36681
36766
  for (const entry of ctx.seen.entries()) {
36682
- const seen = entry[1];
36767
+ const seen2 = entry[1];
36683
36768
  if (schema === entry[0]) {
36684
36769
  extractToDef(entry);
36685
36770
  continue;
@@ -36696,11 +36781,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
36696
36781
  extractToDef(entry);
36697
36782
  continue;
36698
36783
  }
36699
- if (seen.cycle) {
36784
+ if (seen2.cycle) {
36700
36785
  extractToDef(entry);
36701
36786
  continue;
36702
36787
  }
36703
- if (seen.count > 1) {
36788
+ if (seen2.count > 1) {
36704
36789
  if (ctx.reused === "ref") {
36705
36790
  extractToDef(entry);
36706
36791
  }
@@ -36765,7 +36850,7 @@ function foldObjects(members2) {
36765
36850
  const part = other.properties?.[key] ?? undeclaredConstraint(other);
36766
36851
  if (part === null || part === void 0)
36767
36852
  continue;
36768
- if (!parts.some((seen) => JSON.stringify(seen) === JSON.stringify(part)))
36853
+ if (!parts.some((seen2) => JSON.stringify(seen2) === JSON.stringify(part)))
36769
36854
  parts.push(part);
36770
36855
  }
36771
36856
  const merged = parts.length === 1 ? parts[0] : foldObjects(parts) ?? { allOf: parts };
@@ -36783,7 +36868,7 @@ function foldObjects(members2) {
36783
36868
  const constraints = [];
36784
36869
  for (const object3 of objects) {
36785
36870
  const constraint = undeclaredConstraint(object3);
36786
- if (constraint && !constraints.some((seen) => JSON.stringify(seen) === JSON.stringify(constraint)))
36871
+ if (constraint && !constraints.some((seen2) => JSON.stringify(seen2) === JSON.stringify(constraint)))
36787
36872
  constraints.push(constraint);
36788
36873
  }
36789
36874
  if (constraints.length === 1)
@@ -36827,13 +36912,13 @@ function finalize(ctx, schema) {
36827
36912
  if (!root)
36828
36913
  throw new Error("Unprocessed schema. This is a bug in Zod.");
36829
36914
  const flattenRef = /* @__PURE__ */ __name((zodSchema) => {
36830
- const seen = ctx.seen.get(zodSchema);
36831
- if (seen.ref === null)
36915
+ const seen2 = ctx.seen.get(zodSchema);
36916
+ if (seen2.ref === null)
36832
36917
  return;
36833
- const schema2 = seen.def ?? seen.schema;
36918
+ const schema2 = seen2.def ?? seen2.schema;
36834
36919
  const _cached = { ...schema2 };
36835
- const ref = seen.ref;
36836
- seen.ref = null;
36920
+ const ref = seen2.ref;
36921
+ seen2.ref = null;
36837
36922
  if (ref) {
36838
36923
  flattenRef(ref);
36839
36924
  const refSeen = ctx.seen.get(ref);
@@ -36885,7 +36970,7 @@ function finalize(ctx, schema) {
36885
36970
  ctx.override({
36886
36971
  zodSchema,
36887
36972
  jsonSchema: schema2,
36888
- path: seen.path ?? []
36973
+ path: seen2.path ?? []
36889
36974
  });
36890
36975
  }, "flattenRef");
36891
36976
  if (!ctx.external || ctx.sharedEmitDoneFor !== ctx.external) {
@@ -36901,8 +36986,8 @@ function finalize(ctx, schema) {
36901
36986
  rewrite();
36902
36987
  if (ctx.intersections.length) {
36903
36988
  const carriers = /* @__PURE__ */ new Map();
36904
- for (const seen of ctx.seen.values()) {
36905
- for (const json2 of [seen.schema, seen.def]) {
36989
+ for (const seen2 of ctx.seen.values()) {
36990
+ for (const json2 of [seen2.schema, seen2.def]) {
36906
36991
  const allOf = json2?.allOf;
36907
36992
  if (!Array.isArray(allOf))
36908
36993
  continue;
@@ -36942,11 +37027,11 @@ function finalize(ctx, schema) {
36942
37027
  const defs = ctx.external?.defs ?? {};
36943
37028
  if (!ctx.external || ctx.sharedEmitDoneFor !== ctx.external) {
36944
37029
  for (const entry of ctx.seen.entries()) {
36945
- const seen = entry[1];
36946
- if (seen.def && seen.defId) {
36947
- if (seen.def.id === seen.defId)
36948
- delete seen.def.id;
36949
- assignProp(defs, seen.defId, seen.def);
37030
+ const seen2 = entry[1];
37031
+ if (seen2.def && seen2.defId) {
37032
+ if (seen2.def.id === seen2.defId)
37033
+ delete seen2.def.id;
37034
+ assignProp(defs, seen2.defId, seen2.def);
36950
37035
  }
36951
37036
  }
36952
37037
  }
@@ -37565,8 +37650,8 @@ function rewriteKeyNames(ctx) {
37565
37650
  }
37566
37651
  const rewrites = /* @__PURE__ */ new Map();
37567
37652
  for (const record2 of pendingRecords.get(ctx) ?? []) {
37568
- const seen = ctx.seen.get(record2);
37569
- const names2 = (seen?.def ?? seen?.schema)?.propertyNames;
37653
+ const seen2 = ctx.seen.get(record2);
37654
+ const names2 = (seen2?.def ?? seen2?.schema)?.propertyNames;
37570
37655
  if (!names2 || names2 === true || rewrites.has(names2))
37571
37656
  continue;
37572
37657
  const rewritten = stringifyKeyNames(bySchema, names2, /* @__PURE__ */ new Set());
@@ -37630,9 +37715,9 @@ var recordProcessor = /* @__PURE__ */ __name((schema, ctx, _json, params) => {
37630
37715
  var nullableProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37631
37716
  const def = schema._zod.def;
37632
37717
  const inner = processSchema(def.innerType, ctx, params);
37633
- const seen = ctx.seen.get(schema);
37718
+ const seen2 = ctx.seen.get(schema);
37634
37719
  if (ctx.target === "openapi-3.0") {
37635
- seen.ref = def.innerType;
37720
+ seen2.ref = def.innerType;
37636
37721
  json2.nullable = true;
37637
37722
  } else {
37638
37723
  json2.anyOf = [inner, { type: "null" }];
@@ -37641,8 +37726,8 @@ var nullableProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37641
37726
  var nonoptionalProcessor = /* @__PURE__ */ __name((schema, ctx, _json, params) => {
37642
37727
  const def = schema._zod.def;
37643
37728
  processSchema(def.innerType, ctx, params);
37644
- const seen = ctx.seen.get(schema);
37645
- seen.ref = def.innerType;
37729
+ const seen2 = ctx.seen.get(schema);
37730
+ seen2.ref = def.innerType;
37646
37731
  }, "nonoptionalProcessor");
37647
37732
  var UNREPRESENTABLE_DEFAULT = /* @__PURE__ */ Symbol();
37648
37733
  function serializeDefaultValue(value, schema, ctx, json2, params) {
@@ -37662,8 +37747,8 @@ __name(serializeDefaultValue, "serializeDefaultValue");
37662
37747
  var defaultProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37663
37748
  const def = schema._zod.def;
37664
37749
  processSchema(def.innerType, ctx, params);
37665
- const seen = ctx.seen.get(schema);
37666
- seen.ref = def.innerType;
37750
+ const seen2 = ctx.seen.get(schema);
37751
+ seen2.ref = def.innerType;
37667
37752
  const value = serializeDefaultValue(def.defaultValue, schema, ctx, json2, params);
37668
37753
  if (value !== UNREPRESENTABLE_DEFAULT)
37669
37754
  json2.default = value;
@@ -37671,8 +37756,8 @@ var defaultProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37671
37756
  var prefaultProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37672
37757
  const def = schema._zod.def;
37673
37758
  processSchema(def.innerType, ctx, params);
37674
- const seen = ctx.seen.get(schema);
37675
- seen.ref = def.innerType;
37759
+ const seen2 = ctx.seen.get(schema);
37760
+ seen2.ref = def.innerType;
37676
37761
  if (ctx.io !== "input")
37677
37762
  return;
37678
37763
  const value = serializeDefaultValue(def.defaultValue, schema, ctx, json2, params);
@@ -37682,8 +37767,8 @@ var prefaultProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37682
37767
  var catchProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37683
37768
  const def = schema._zod.def;
37684
37769
  processSchema(def.innerType, ctx, params);
37685
- const seen = ctx.seen.get(schema);
37686
- seen.ref = def.innerType;
37770
+ const seen2 = ctx.seen.get(schema);
37771
+ seen2.ref = def.innerType;
37687
37772
  let catchValue;
37688
37773
  try {
37689
37774
  catchValue = def.catchValue(void 0);
@@ -37698,33 +37783,33 @@ var pipeProcessor = /* @__PURE__ */ __name((schema, ctx, _json, params) => {
37698
37783
  const inIsTransform = def.in._zod.traits.has("$ZodTransform");
37699
37784
  const innerType = ctx.io === "input" ? inIsTransform ? def.out : def.in : def.out;
37700
37785
  processSchema(innerType, ctx, params);
37701
- const seen = ctx.seen.get(schema);
37702
- seen.ref = innerType;
37786
+ const seen2 = ctx.seen.get(schema);
37787
+ seen2.ref = innerType;
37703
37788
  }, "pipeProcessor");
37704
37789
  var readonlyProcessor = /* @__PURE__ */ __name((schema, ctx, json2, params) => {
37705
37790
  const def = schema._zod.def;
37706
37791
  processSchema(def.innerType, ctx, params);
37707
- const seen = ctx.seen.get(schema);
37708
- seen.ref = def.innerType;
37792
+ const seen2 = ctx.seen.get(schema);
37793
+ seen2.ref = def.innerType;
37709
37794
  json2.readOnly = true;
37710
37795
  }, "readonlyProcessor");
37711
37796
  var promiseProcessor = /* @__PURE__ */ __name((schema, ctx, _json, params) => {
37712
37797
  const def = schema._zod.def;
37713
37798
  processSchema(def.innerType, ctx, params);
37714
- const seen = ctx.seen.get(schema);
37715
- seen.ref = def.innerType;
37799
+ const seen2 = ctx.seen.get(schema);
37800
+ seen2.ref = def.innerType;
37716
37801
  }, "promiseProcessor");
37717
37802
  var optionalProcessor = /* @__PURE__ */ __name((schema, ctx, _json, params) => {
37718
37803
  const def = schema._zod.def;
37719
37804
  processSchema(def.innerType, ctx, params);
37720
- const seen = ctx.seen.get(schema);
37721
- seen.ref = def.innerType;
37805
+ const seen2 = ctx.seen.get(schema);
37806
+ seen2.ref = def.innerType;
37722
37807
  }, "optionalProcessor");
37723
37808
  var lazyProcessor = /* @__PURE__ */ __name((schema, ctx, _json, params) => {
37724
37809
  const innerType = schema._zod.innerType;
37725
37810
  processSchema(innerType, ctx, params);
37726
- const seen = ctx.seen.get(schema);
37727
- seen.ref = innerType;
37811
+ const seen2 = ctx.seen.get(schema);
37812
+ seen2.ref = innerType;
37728
37813
  }, "lazyProcessor");
37729
37814
  var allProcessors = {
37730
37815
  string: stringProcessor,
@@ -40609,7 +40694,7 @@ function checkObjectGuards(objectSchema, guards) {
40609
40694
  return guard.pipe(objectSchema);
40610
40695
  }
40611
40696
  __name(checkObjectGuards, "checkObjectGuards");
40612
- function canonicalKey(value, seen) {
40697
+ function canonicalKey(value, seen2) {
40613
40698
  if (value === null)
40614
40699
  return "z";
40615
40700
  const type = typeof value;
@@ -40619,14 +40704,14 @@ function canonicalKey(value, seen) {
40619
40704
  const raw = String(value);
40620
40705
  return `${type[0]}${raw.length}:${raw}`;
40621
40706
  }
40622
- if (seen.has(value))
40707
+ if (seen2.has(value))
40623
40708
  return null;
40624
- seen.add(value);
40709
+ seen2.add(value);
40625
40710
  try {
40626
40711
  if (Array.isArray(value)) {
40627
40712
  const parts2 = [];
40628
40713
  for (const item of value) {
40629
- const key = canonicalKey(item, seen);
40714
+ const key = canonicalKey(item, seen2);
40630
40715
  if (key === null)
40631
40716
  return null;
40632
40717
  parts2.push(key);
@@ -40636,14 +40721,14 @@ function canonicalKey(value, seen) {
40636
40721
  const keys = Object.keys(value).sort();
40637
40722
  const parts = [];
40638
40723
  for (const k of keys) {
40639
- const key = canonicalKey(value[k], seen);
40724
+ const key = canonicalKey(value[k], seen2);
40640
40725
  if (key === null)
40641
40726
  return null;
40642
40727
  parts.push(`${k.length}:${k}=${key}`);
40643
40728
  }
40644
40729
  return `o${parts.length}:{${parts.join(",")}}`;
40645
40730
  } finally {
40646
- seen.delete(value);
40731
+ seen2.delete(value);
40647
40732
  }
40648
40733
  }
40649
40734
  __name(canonicalKey, "canonicalKey");
@@ -47297,36 +47382,39 @@ __name(runImage, "runImage");
47297
47382
 
47298
47383
  // src/tools/read.ts
47299
47384
  var READ = { readOnlyHint: true, openWorldHint: false };
47385
+ var service = external_exports.enum(["qobuz", "tidal"]).optional().describe("qobuz or tidal \u2014 default: Qobuz when it is signed in in Roon, else TIDAL.");
47300
47386
  var limit = /* @__PURE__ */ __name((max, dflt) => external_exports.number().int().min(1).max(max).optional().describe(`How many (default ${dflt}, at most ${max}).`), "limit");
47301
47387
  function registerReadTools(server, internal = true) {
47302
47388
  const R = /* @__PURE__ */ __name((name) => internal || name === "roon_zones" || name === "roon_now_playing", "R");
47303
47389
  const reg = /* @__PURE__ */ __name(((name, cfg, cb) => R(name) ? server.registerTool.call(server, name, cfg, cb) : void 0), "reg");
47304
47390
  reg("roon_search", {
47305
47391
  title: "Search Roon",
47306
- description: 'Search Roon \u2014 the user\'s Roon library and the whole Qobuz catalogue at once. 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.',
47392
+ 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). 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.',
47307
47393
  inputSchema: { query: external_exports.string().min(1), kind: external_exports.enum(["album", "track", "artist"]).optional(), limit: limit(100, 30) },
47308
47394
  annotations: READ
47309
47395
  }, ({ query, kind, limit: n }) => run(() => search(query, n ?? 30, kind)));
47310
47396
  reg("roon_new", {
47311
- title: "Qobuz shelves in Roon",
47312
- description: 'Qobuz\'s editorial shelves as Roon shows them (the same for everyone): `new` (new albums), `qobuzissimes`, `press_awards`, `top`, `trending`, `ideal_discography`. Albums carry their format and a `key` for `roon_album`. `genre` narrows a shelf to one or more Qobuz genres \u2014 ids from `roon_genres`; use it for "new jazz", "what\'s new in classical". `offset` pages through the shelf; `total` says how many there are.',
47397
+ title: "Streaming shelves in Roon",
47398
+ 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.',
47313
47399
  inputSchema: {
47314
- section: external_exports.enum(["new", "qobuzissimes", "press_awards", "top", "trending", "ideal_discography"]).optional(),
47315
- genre: external_exports.string().regex(/^\s*\d+(\s*,\s*\d+)*\s*$/, "Qobuz genre ids (numbers), comma-separated \u2014 see roon_genres").optional().describe("Qobuz genre id(s) from roon_genres, comma-separated."),
47400
+ service,
47401
+ section: external_exports.string().optional().describe("The shelf (default new) \u2014 see the list for each service."),
47402
+ genre: external_exports.string().optional().describe("Qobuz: genre id(s) from roon_genres, comma-separated. TIDAL: one genre from roon_genres."),
47403
+ what: external_exports.enum(["albums", "tracks"]).optional().describe("TIDAL only: albums (default) or tracks."),
47316
47404
  limit: limit(100, 30),
47317
47405
  offset: external_exports.number().int().min(0).optional()
47318
47406
  },
47319
47407
  annotations: READ
47320
- }, ({ section: section2, genre, limit: n, offset: offset2 }) => run(() => section(section2 ?? "new", n ?? 30, offset2 ?? 0, (genre ?? "").split(",").map((x) => x.trim()).filter(Boolean))));
47408
+ }, ({ 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")));
47321
47409
  reg("roon_genres", {
47322
- title: "Qobuz genres",
47323
- description: "The genres of Qobuz with their ids, for the `genre` filter of `roon_new`. Without `parent`: the main genres (names as Qobuz gives them in the account's language). With `parent` (a genre id): its sub-genres, e.g. the kinds of jazz.",
47324
- inputSchema: { parent: external_exports.string().optional().describe("A genre id, to list its sub-genres.") },
47410
+ title: "Streaming genres",
47411
+ 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.",
47412
+ inputSchema: { service, parent: external_exports.string().optional().describe("Qobuz: a genre id, to list its sub-genres.") },
47325
47413
  annotations: READ
47326
- }, ({ parent }) => run(() => genres(parent)));
47414
+ }, ({ service: sv, parent }) => run(() => genres(parent, sv)));
47327
47415
  reg("roon_album", {
47328
47416
  title: "Album page",
47329
- 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 Qobuz 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.",
47417
+ 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.",
47330
47418
  inputSchema: { key: external_exports.string().describe("An album key."), credits: external_exports.boolean().optional() },
47331
47419
  annotations: READ
47332
47420
  }, ({ key, credits }) => run(() => album(key, credits === true)));
@@ -47344,10 +47432,10 @@ function registerReadTools(server, internal = true) {
47344
47432
  }, ({ key, wikipedia, max_chars }) => run(() => bio(key, max_chars ?? 1e9, wikipedia === true)));
47345
47433
  reg("roon_artist", {
47346
47434
  title: "Discography",
47347
- description: 'An artist\'s discography in Roon: the main albums Qobuz 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.',
47348
- inputSchema: { key: external_exports.string().describe("An artist key.") },
47435
+ 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.',
47436
+ inputSchema: { key: external_exports.string().describe("An artist key."), service },
47349
47437
  annotations: READ
47350
- }, ({ key }) => run(() => discography(key)));
47438
+ }, ({ key, service: sv }) => run(() => discography(key, sv)));
47351
47439
  reg("roon_zones", {
47352
47440
  title: "Roon zones",
47353
47441
  description: "The zones Roon can play on (Roon's own names), and which are grouped right now (`groups`).",
@@ -47988,31 +48076,100 @@ init_official();
47988
48076
  init_log();
47989
48077
  init_graph();
47990
48078
  init_library();
48079
+
48080
+ // src/roon/tidal.ts
48081
+ init_session();
48082
+ init_graph();
48083
+ init_library();
48084
+ var PLAYLIST_SHELVES = {
48085
+ new: "GetNewPlaylists",
48086
+ recommended: "GetRecommendedPlaylists",
48087
+ exclusive: "GetExclusivePlaylists",
48088
+ masters: "GetMastersPlaylists",
48089
+ popular: "GetPopularPlaylists",
48090
+ essential: "GetEssentialPlaylists",
48091
+ hires_classics: "GetHiResHeadphoneClassicsPlaylists",
48092
+ recently_updated: "GetRecentlyUpdatedPlaylists",
48093
+ forgotten_favorites: "GetForgottenFavoritePlaylists",
48094
+ my_mixes: "GetDailyMixes",
48095
+ history_mixes: "GetHistoryMixes"
48096
+ };
48097
+ var seen = /* @__PURE__ */ new Map();
48098
+ var playlistKey = /* @__PURE__ */ __name((p) => {
48099
+ const id = field2(p, "PlaylistId");
48100
+ return Buffer.isBuffer(id) ? id.toString("hex") : String(id ?? "");
48101
+ }, "playlistKey");
48102
+ async function fetch2(c, w) {
48103
+ const { o } = streaming(c, "tidal");
48104
+ if (w.genre) {
48105
+ const g = await tidalPick(c, o, "GetGenres", w.genre);
48106
+ const r2 = await shelf(c, "tidal", o, "GetGenrePlaylists", "Playlist", w.offset, w.count, { types: [{ type: "TidalGenre", name: "genre" }], args: tidalStruct(c, "TidalGenre", g) });
48107
+ return Object.assign(r2.items, { total: r2.total });
48108
+ }
48109
+ if (w.mood) {
48110
+ const m = await tidalPick(c, o, "GetMoods", w.mood);
48111
+ const r2 = await shelf(c, "tidal", o, "GetMoodPlaylists", "Playlist", w.offset, w.count, { types: [{ type: "TidalMood", name: "mood" }], args: tidalStruct(c, "TidalMood", m) });
48112
+ return Object.assign(r2.items, { total: r2.total });
48113
+ }
48114
+ const method = PLAYLIST_SHELVES[w.shelf];
48115
+ if (!method) throw new RoonError(`TIDAL has no playlist shelf "${w.shelf}"; choose from ${Object.keys(PLAYLIST_SHELVES).join(", ")}`, "invalid");
48116
+ const r = await shelf(c, "tidal", o, method, "Playlist", w.offset, w.count);
48117
+ return Object.assign(r.items, { total: r.total });
48118
+ }
48119
+ __name(fetch2, "fetch");
48120
+ async function playlists(shelfName, genre, mood, limit3, offset2) {
48121
+ return withCore(async (c) => {
48122
+ const w = { shelf: genre ? "genre" : mood ? "mood" : shelfName, genre, mood, offset: Math.max(0, offset2), count: Math.min(Math.max(1, limit3), 100) };
48123
+ const list = await fetch2(c, w);
48124
+ const rows = list.map((p) => {
48125
+ const key = playlistKey(p);
48126
+ seen.set(key, w);
48127
+ const r = { key, name: String(field2(p, "Name") ?? "").trim() };
48128
+ const d = field2(p, "Description");
48129
+ if (d) r.description = clean(d).replace(/\s+/g, " ").trim().slice(0, 240);
48130
+ const n = field2(p, "TrackCount") ?? field2(p, "ItemCount");
48131
+ if (typeof n === "number") r.tracks = n;
48132
+ const l = field2(p, "LengthSeconds");
48133
+ if (typeof l === "number") r.length = duration3(l);
48134
+ const a = strings(c, field2(p, "SampleArtistNames"));
48135
+ if (a.length) r.artists = a.slice(0, 5);
48136
+ return r;
48137
+ });
48138
+ return { service: "TIDAL", shelf: genre || mood ? void 0 : shelfName, genre, mood, total: list.total ?? void 0, offset: w.offset, rows };
48139
+ });
48140
+ }
48141
+ __name(playlists, "playlists");
48142
+ async function findPlaylist(c, key) {
48143
+ const here = [...c.graph.objects.values()].find((o) => /\.Playlist$/.test(o.typeName) && playlistKey(o) === key && field2(o, "Name") !== void 0);
48144
+ if (here) return here;
48145
+ const w = seen.get(key);
48146
+ if (!w) return null;
48147
+ return (await fetch2(c, w)).map((p) => obj(c, p)).find((p) => playlistKey(p) === key) ?? null;
48148
+ }
48149
+ __name(findPlaylist, "findPlaylist");
48150
+
48151
+ // src/roon/more.ts
47991
48152
  var profileObject = /* @__PURE__ */ __name((c) => {
47992
48153
  const p = [...c.graph.objects.values()].find((o) => o.typeName.endsWith(".Profile"));
47993
48154
  if (!p) throw new RoonError("Roon gave no profile");
47994
48155
  return p;
47995
48156
  }, "profileObject");
47996
48157
  var decoded2 = /* @__PURE__ */ __name((c, payload) => payload.length ? c.graph.decodeReturnValue(Uint8Array.from(payload)) : null, "decoded");
47997
- var PLAYLIST_KIND = { 1: "itunes", 5: "roon", 6: "roon (read-only)", 10: "tidal", 11: "file", 12: "qobuz", 13: "roon editorial", 15: "smart" };
48158
+ var PLAYLIST_KIND = { 1: "itunes", 5: "roon", 6: "roon (read-only)", 10: "TIDAL", 11: "file", 12: "qobuz", 13: "roon editorial", 15: "smart" };
47998
48159
  function allPlaylists(c) {
47999
- const service = c.graph.findByType("Playlists")[0];
48000
- if (!service) throw new RoonError("Roon gives no playlists service", "unavailable");
48001
- return { service, list: items(c, field2(service, "AllPlaylists")) };
48160
+ const service2 = c.graph.findByType("Playlists")[0];
48161
+ if (!service2) throw new RoonError("Roon gives no playlists service", "unavailable");
48162
+ return { service: service2, list: items(c, field2(service2, "AllPlaylists")) };
48002
48163
  }
48003
48164
  __name(allPlaylists, "allPlaylists");
48004
- var playlistKey = /* @__PURE__ */ __name((p) => {
48005
- const id = field2(p, "PlaylistId");
48006
- return Buffer.isBuffer(id) ? id.toString("hex") : String(id ?? "");
48007
- }, "playlistKey");
48008
- function findPlaylist(c, key) {
48165
+ async function findPlaylist2(c, key) {
48009
48166
  const { list } = allPlaylists(c);
48010
- const p = list.find((x) => playlistKey(x) === key) ?? list.find((x) => String(field2(x, "Name") ?? "").toLowerCase() === key.toLowerCase());
48011
- if (!p) throw new RoonError("Unknown playlist \u2014 read roon_playlists for the keys", "not_found");
48167
+ const p = list.find((x) => playlistKey(x) === key) ?? list.find((x) => String(field2(x, "Name") ?? "").toLowerCase() === key.toLowerCase()) ?? await findPlaylist(c, key);
48168
+ if (!p) throw new RoonError("Unknown playlist \u2014 read roon_playlists (or roon_tidal_playlists) for the keys", "not_found");
48012
48169
  return p;
48013
48170
  }
48014
- __name(findPlaylist, "findPlaylist");
48015
- async function playlists() {
48171
+ __name(findPlaylist2, "findPlaylist");
48172
+ async function playlists2() {
48016
48173
  return withCore(async (c) => ({ playlists: allPlaylists(c).list.map((p) => ({
48017
48174
  key: playlistKey(p),
48018
48175
  name: field2(p, "Name") ?? "",
@@ -48021,10 +48178,10 @@ async function playlists() {
48021
48178
  read_only: field2(p, "IsReadOnly") === true || void 0
48022
48179
  })) }));
48023
48180
  }
48024
- __name(playlists, "playlists");
48181
+ __name(playlists2, "playlists");
48025
48182
  async function playlist(key, limit3) {
48026
48183
  return withCore(async (c) => {
48027
- const p = findPlaylist(c, key);
48184
+ const p = await findPlaylist2(c, key);
48028
48185
  const res = await c.call("Playlist", "GetItems", [{ type: "ResultCallback<Query<PlaylistItem>>", name: "cb_result" }], Buffer.alloc(0), p.oid);
48029
48186
  if (!res.success) throw new RoonError(`Roon refused the playlist (${res.status})`);
48030
48187
  const v = decoded2(c, res.payload);
@@ -48047,7 +48204,7 @@ __name(playlist, "playlist");
48047
48204
  async function playPlaylist(key, zoneName, how, shuffle) {
48048
48205
  const p = PRIORITY[how];
48049
48206
  const r = await withCore(async (c) => {
48050
- const pl = findPlaylist(c, key);
48207
+ const pl = await findPlaylist2(c, key);
48051
48208
  const z2 = findZone(c, zoneName);
48052
48209
  const args = Buffer.concat([buildArgs([Arg.ref(z2.oid), Arg.sooid(c.profile())]), playParams(c, p, shuffle), buildArgs([Arg.ref(pl.oid), Arg.ref(0n)])]);
48053
48210
  const res = await c.call(
@@ -48073,7 +48230,7 @@ async function playPlaylist(key, zoneName, how, shuffle) {
48073
48230
  __name(playPlaylist, "playPlaylist");
48074
48231
  async function playlistCreate(name, keys) {
48075
48232
  return withCore(async (c) => {
48076
- const { service } = allPlaylists(c);
48233
+ const { service: service2 } = allPlaylists(c);
48077
48234
  await findTracks(c, keys);
48078
48235
  const res = await c.call(
48079
48236
  "Playlists",
@@ -48087,7 +48244,7 @@ async function playlistCreate(name, keys) {
48087
48244
  { type: "ResultCallback<Playlist>", name: "cb_result" }
48088
48245
  ],
48089
48246
  buildArgs([Arg.ref(profileObject(c).oid), Arg.str(name), Arg.str(""), Arg.bool(false), Arg.collection(keys.map((k) => buildArgs([Arg.long(BigInt(k))])))]),
48090
- service.oid
48247
+ service2.oid
48091
48248
  );
48092
48249
  if (!res.success) throw new RoonError(`Roon refused the playlist (${res.status})`);
48093
48250
  const end = Date.now() + 3e3;
@@ -48106,20 +48263,9 @@ async function favorites(source, limit3) {
48106
48263
  return { source, total: r.total, rows: r.rows };
48107
48264
  }
48108
48265
  return withCore(async (c) => {
48109
- const q = c.graph.findByType("Qobuz")[0];
48110
- if (!q) throw new RoonError("This Roon Core has no Qobuz service", "unavailable");
48111
- const res = await c.call(
48112
- "Qobuz",
48113
- "GetFavoriteAlbums",
48114
- [{ type: "int", name: "offset" }, { type: "int", name: "count" }, { type: "ResultCallback<PartialList<AlbumLite>>", name: "cb_result" }],
48115
- buildArgs([Arg.int(0), Arg.int(n)]),
48116
- q.oid
48117
- );
48118
- if (!res.success) throw new RoonError(`Roon refused the Qobuz favourites (${res.status})`);
48119
- const { partialList: partialList2 } = await Promise.resolve().then(() => (init_graph(), graph_exports));
48120
- const list = partialList2(c, c.graph.decodeReturnValue(Uint8Array.from(res.payload)));
48121
- await untilLoaded(c, list.items);
48122
- return { source, total: list.total, rows: list.items.map((x) => obj(c, x)).filter(Boolean).map((o) => albumRow(c, o)) };
48266
+ const { service: service2, o } = streaming(c, source);
48267
+ const list = await shelf(c, service2, o, "GetFavoriteAlbums", "AlbumLite", 0, n);
48268
+ return { source, total: list.total, rows: list.items.map((x) => albumRow(c, x)) };
48123
48269
  });
48124
48270
  }
48125
48271
  __name(favorites, "favorites");
@@ -48272,8 +48418,8 @@ async function similarAlbum(key, limit3) {
48272
48418
  const A = isRef2(full) ? c.graph.getObject(full.$ref) : null;
48273
48419
  const self = String(field2(al, "AlbumId"));
48274
48420
  const unique = /* @__PURE__ */ __name((l) => {
48275
- const seen = /* @__PURE__ */ new Set([self]);
48276
- return l.filter((o) => o && field2(o, "Title") !== void 0 && !seen.has(String(field2(o, "AlbumId"))) && seen.add(String(field2(o, "AlbumId")))).slice(0, limit3).map((o) => albumRow(c, o));
48421
+ const seen2 = /* @__PURE__ */ new Set([self]);
48422
+ return l.filter((o) => o && field2(o, "Title") !== void 0 && !seen2.has(String(field2(o, "AlbumId"))) && seen2.add(String(field2(o, "AlbumId")))).slice(0, limit3).map((o) => albumRow(c, o));
48277
48423
  }, "unique");
48278
48424
  const res = await short(c.call(
48279
48425
  "Library",
@@ -48657,13 +48803,13 @@ function registerLibraryTools(server) {
48657
48803
  }, ({ limit: n }) => run(() => dailyPicks(n ?? 8)));
48658
48804
  server.registerTool("roon_new_for_you", {
48659
48805
  title: "New releases for you",
48660
- 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 (Qobuz\'s 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`.',
48806
+ 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`.',
48661
48807
  inputSchema: { limit: limit2(100, 30), offset },
48662
48808
  annotations: READ2
48663
48809
  }, ({ limit: n, offset: o }) => run(() => newForYou(n ?? 30, o ?? 0)));
48664
48810
  server.registerTool("roon_mixes", {
48665
48811
  title: "Daily Mixes",
48666
- description: "The Daily Mixes from Roon's Home: today's personal 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.",
48812
+ 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.',
48667
48813
  inputSchema: {},
48668
48814
  annotations: READ2
48669
48815
  }, () => run(() => mixes()));
@@ -48675,19 +48821,31 @@ function registerLibraryTools(server) {
48675
48821
  }, ({ key, zone: zn, mode: m }) => run(() => playMix(key, zn, m ?? "now")));
48676
48822
  server.registerTool("roon_playlists", {
48677
48823
  title: "Playlists",
48678
- description: "The playlists Roon knows: the user's own Roon playlists, Qobuz playlists and editorial ones \u2014 each with a `key` for roon_playlist and roon_play_playlist, the number of tracks, and whether it is read-only.",
48824
+ description: "The playlists in the user's Roon: their own Roon playlists, Qobuz and TIDAL playlists they added, and editorial ones \u2014 each with a `key` for roon_playlist and roon_play_playlist, the number of tracks, and whether it is read-only.",
48679
48825
  inputSchema: {},
48680
48826
  annotations: READ2
48681
- }, () => run(() => playlists()));
48827
+ }, () => run(() => playlists2()));
48828
+ server.registerTool("roon_tidal_playlists", {
48829
+ title: "TIDAL playlists",
48830
+ description: "TIDAL's playlists as Roon shows them, when TIDAL is signed in: the editorial shelves `new`, `recommended`, `exclusive`, `masters`, `popular`, `essential`, `hires_classics`, `recently_updated`, `forgotten_favorites`; the user's own TIDAL mixes `my_mixes` (My Daily Discovery, My Mix 1 \u2026, My New Arrivals \u2014 what the user means by their TIDAL (daily) mixes; not roon_mixes, which are Roon's) and `history_mixes` (My Most Listened, the yearly ones \u2026); or the playlists of one TIDAL `genre` or `mood` (ids from roon_genres with `service: \"tidal\"`). Each playlist has a `key` for roon_playlist (its tracks \u2014 readable without playing) and roon_play_playlist, a description, the number of tracks and a few of its artists.",
48831
+ inputSchema: {
48832
+ shelf: external_exports.enum(Object.keys(PLAYLIST_SHELVES)).optional().describe("Default new."),
48833
+ genre: external_exports.string().optional().describe("A TIDAL genre (roon_genres, service tidal)."),
48834
+ mood: external_exports.string().optional().describe("A TIDAL mood (roon_genres, service tidal)."),
48835
+ limit: limit2(100, 20),
48836
+ offset
48837
+ },
48838
+ annotations: READ2
48839
+ }, ({ shelf: shelf2, genre, mood, limit: n, offset: o }) => run(() => playlists(shelf2 ?? "new", genre, mood, n ?? 20, o ?? 0)));
48682
48840
  server.registerTool("roon_playlist", {
48683
48841
  title: "A playlist's tracks",
48684
- description: "The tracks of one Roon playlist (by its `key` from roon_playlists, or its exact name), each with a `key` for roon_play_tracks.",
48842
+ description: "The tracks of one Roon playlist (by its `key` from roon_playlists or roon_tidal_playlists, or its exact name), each with a `key` for roon_play_tracks.",
48685
48843
  inputSchema: { key: external_exports.string(), limit: limit2(300, 100) },
48686
48844
  annotations: READ2
48687
48845
  }, ({ key, limit: n }) => run(() => playlist(key, n ?? 100)));
48688
48846
  server.registerTool("roon_play_playlist", {
48689
48847
  title: "Play a playlist",
48690
- description: 'Play a Roon playlist on a Roon zone (`key` from roon_playlists, or its exact name). `mode`: now (default), next or queue. `shuffle: true` starts it shuffled from the very first track (Roon\'s own Shuffle button) \u2014 THE way to "shuffle a playlist": one call, no roon_settings, no skip. Only on an explicit request, never as a probe.',
48848
+ description: 'Play a Roon playlist on a Roon zone (`key` from roon_playlists or roon_tidal_playlists, or its exact name). `mode`: now (default), next or queue. `shuffle: true` starts it shuffled from the very first track (Roon\'s own Shuffle button) \u2014 THE way to "shuffle a playlist": one call, no roon_settings, no skip. Only on an explicit request, never as a probe.',
48691
48849
  inputSchema: { key: external_exports.string(), zone: zone2, mode: mode2, shuffle: external_exports.boolean().optional() },
48692
48850
  annotations: PLAYS2
48693
48851
  }, ({ key, zone: zn, mode: m, shuffle }) => run(() => playPlaylist(key, zn, m ?? "now", shuffle === true)));
@@ -48699,8 +48857,8 @@ function registerLibraryTools(server) {
48699
48857
  }, ({ name, keys }) => run(() => playlistCreate(name, keys)));
48700
48858
  server.registerTool("roon_favorites", {
48701
48859
  title: "Favourite albums",
48702
- description: 'The user\'s favourite albums: `source: "roon"` (default) are the albums with a heart in Roon, newest first; `source: "qobuz"` the favourites at Qobuz. Albums carry a `key`.',
48703
- inputSchema: { source: external_exports.enum(["roon", "qobuz"]).optional(), limit: limit2(100, 50) },
48860
+ description: 'The user\'s favourite albums: `source: "roon"` (default) are the albums with a heart in Roon, newest first; `source: "qobuz"` or `"tidal"` the favourites at that service. Albums carry a `key`.',
48861
+ inputSchema: { source: external_exports.enum(["roon", "qobuz", "tidal"]).optional(), limit: limit2(100, 50) },
48704
48862
  annotations: READ2
48705
48863
  }, ({ source, limit: n }) => run(() => favorites(source ?? "roon", n ?? 50)));
48706
48864
  server.registerTool("roon_favorite", {
@@ -48723,13 +48881,13 @@ function registerLibraryTools(server) {
48723
48881
  }, ({ kind, key, on }) => run(() => listenLaterSet(kind, key, on !== false)));
48724
48882
  server.registerTool("roon_library_add", {
48725
48883
  title: "Add to the library",
48726
- description: "Add a Qobuz album to the user's Roon library (the album `key` from a search, shelf or discography). Changes the library: only on an explicit request.",
48884
+ description: "Add a Qobuz or TIDAL album to the user's Roon library (the album `key` from a search, shelf or discography). Changes the library: only on an explicit request.",
48727
48885
  inputSchema: { key: external_exports.string() },
48728
48886
  annotations: CHANGES
48729
48887
  }, ({ key }) => run(() => libraryAdd(key)));
48730
48888
  server.registerTool("roon_versions", {
48731
48889
  title: "Other versions",
48732
- description: 'The other versions Roon knows of an album (by album `key`): remasters, deluxe editions, mono/stereo, hi-res \u2014 `in_library` and `streaming` (at Qobuz), best format first, each with year, format, number of tracks and a `key`. For "is there a hi-res version", "which editions exist".',
48890
+ description: 'The other versions Roon knows of an album (by album `key`): remasters, deluxe editions, mono/stereo, hi-res \u2014 `in_library` and `streaming` (at Qobuz or TIDAL \u2014 each row says its `source`), best format first, each with year, format, number of tracks and a `key`. For "is there a hi-res version", "which editions exist".',
48733
48891
  inputSchema: { key: external_exports.string().describe("An album key.") },
48734
48892
  annotations: READ2
48735
48893
  }, ({ key }) => run(() => versions(key)));
@@ -48792,12 +48950,12 @@ async function runSelfTest() {
48792
48950
  if (!a.tracks?.length) throw new Error("an album without tracks");
48793
48951
  return `${a.tracks.length} tracks`;
48794
48952
  }));
48795
- out.push(await timed("Qobuz shelf", "roon_new, roon_genres, roon_artist", async () => {
48953
+ out.push(await timed("streaming shelf", "roon_new, roon_genres, roon_artist, roon_tidal_playlists", async () => {
48796
48954
  try {
48797
48955
  const s = await section("new", 1, 0);
48798
- return `${s.total ?? "?"} new albums`;
48956
+ return `${s.total ?? "?"} new albums at ${s.service}`;
48799
48957
  } catch (e) {
48800
- if (/no Qobuz/i.test(e.message)) return "no Qobuz on this Core (not an error)";
48958
+ if (/Neither Qobuz nor TIDAL/i.test(e.message)) return "no Qobuz or TIDAL signed in on this Core (not an error)";
48801
48959
  throw e;
48802
48960
  }
48803
48961
  }));
@@ -48836,10 +48994,11 @@ init_session();
48836
48994
  init_session();
48837
48995
  init_log();
48838
48996
  init_package();
48839
- var INSTRUCTIONS = `Deep Roon MCP connects to the user's own Roon Core (music player software, with Qobuz behind it when the user has it).
48997
+ var INSTRUCTIONS = `Deep Roon MCP connects to the user's own Roon Core (music player software, with Qobuz and/or TIDAL behind it when the user has them).
48840
48998
  - Albums, tracks and artists come with a \`key\`; keys stay valid between calls \u2014 no need to search again.
48841
48999
  - Reviews and biographies are Roon's own texts: when asked for them, show them in full with the author's name.
48842
49000
  - What plays changes all the time: read roon_now_playing afresh before describing it.
49001
+ - "Daily Mixes" are Roon's own (roon_mixes); the user's TIDAL mixes (My Mix 1 \u2026, My Daily Discovery) are roon_tidal_playlists with shelf my_mixes, and their tracks can be read with roon_playlist.
48843
49002
  - Unofficial: this uses Roon's internal protocol, which a Roon update can change; if a tool says so, tell the user to check for an update.`;
48844
49003
  async function main() {
48845
49004
  const server = new McpServer({ name: "deep-roon-mcp", title: "Deep Roon MCP", version: version2, websiteUrl: "https://github.com/renebouwmeester/deep-roon-mcp" }, { instructions: INSTRUCTIONS });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deep-roon-mcp",
3
- "version": "0.1.0",
4
- "description": "An MCP server for Roon that reaches the parts the official API doesn't: reviews, biographies, Qobuz's shelves, your history, search across your library and Qobuz",
3
+ "version": "0.2.1",
4
+ "description": "An MCP server for Roon that reaches the parts the official API doesn't: reviews, biographies, Qobuz's and TIDAL's shelves, TIDAL's playlists, your history, search across your library and the streaming services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "René Bouwmeester <21277119+renebouwmeester@users.noreply.github.com>",
7
7
  "homepage": "https://github.com/renebouwmeester/deep-roon-mcp",
@@ -15,6 +15,7 @@
15
15
  "model-context-protocol",
16
16
  "claude",
17
17
  "qobuz",
18
+ "tidal",
18
19
  "music"
19
20
  ],
20
21
  "type": "commonjs",