@rocksky/sdk 0.12.0 → 0.14.0

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/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Client, simpleFetchHandler } from "@atcute/client";
2
2
 
3
3
  import { RockskyError } from "./errors.js";
4
+ import type { Filter } from "./filter.js";
4
5
  import { RockskyLibrary } from "./library.js";
5
6
  import type {
6
7
  ActorProfileViewBasic,
@@ -10,6 +11,8 @@ import type {
10
11
  ArtistViewBasic,
11
12
  ArtistViewDetailed,
12
13
  ChartsView,
14
+ CreateScrobbleInput,
15
+ FollowAccountOutput,
13
16
  FeedGeneratorsView,
14
17
  FeedRecommendationsView,
15
18
  FeedRecommendedAlbumsView,
@@ -36,10 +39,13 @@ import type {
36
39
  GetTrackShoutsOutput,
37
40
  GetUnreadCountOutput,
38
41
  ListNotificationsOutput,
42
+ MirrorSourceView,
39
43
  PlayerCurrentlyPlayingViewDetailed,
40
44
  PlayerPlaybackQueueViewDetailed,
41
45
  PlaylistGetPlaylistsOutput,
42
46
  PlaylistViewDetailed,
47
+ PutAudioSettingsInput,
48
+ PutMirrorSourceInput,
43
49
  RockboxSettingsView,
44
50
  ScrobbleViewBasic,
45
51
  SongViewBasic,
@@ -47,6 +53,7 @@ import type {
47
53
  StatsGlobalStatsView,
48
54
  StatsView,
49
55
  StatsWrappedView,
56
+ UnfollowAccountOutput,
50
57
  UpdateSeenOutput,
51
58
  } from "./generated/types.js";
52
59
 
@@ -135,7 +142,7 @@ export class RockskyClient {
135
142
  if (v !== undefined && v !== "") clean[k] = v;
136
143
  }
137
144
  const res = await this.rpc.get(nsid as never, { params: clean } as never);
138
- if (!res.ok) throw new RockskyError(res.data);
145
+ if (!res.ok) throw new RockskyError(res.data, res.status);
139
146
  return res.data as T;
140
147
  }
141
148
 
@@ -160,6 +167,24 @@ export class RockskyClient {
160
167
  return this.query(nsid, params);
161
168
  }
162
169
 
170
+ /** Call any AppView procedure by nsid. `params` ride the query string (some
171
+ * procedures take their arguments there), `body` is the JSON input — omitted
172
+ * entirely when `undefined`. Escape hatch for procedures without a wrapper. */
173
+ async post<T = unknown>(
174
+ nsid: string,
175
+ opts: { params?: Record<string, unknown>; body?: unknown } = {},
176
+ ): Promise<T> {
177
+ const clean: Record<string, unknown> = {};
178
+ for (const [k, v] of Object.entries(opts.params ?? {})) {
179
+ if (v !== undefined && v !== "") clean[k] = v;
180
+ }
181
+ const call: Record<string, unknown> = { params: clean };
182
+ if (opts.body !== undefined) call.input = opts.body;
183
+ const res = await this.rpc.post(nsid as never, call as never);
184
+ if (!res.ok) throw new RockskyError(res.data, res.status);
185
+ return res.data as T;
186
+ }
187
+
163
188
  /** An actor's most-played songs. */
164
189
  async songs(actor: string, limit = 50, offset = 0): Promise<SongViewBasic[]> {
165
190
  const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.actor.getActorSongs", {
@@ -229,32 +254,53 @@ export class RockskyClient {
229
254
  return out.artists ?? [];
230
255
  }
231
256
 
232
- /** The album catalog, optionally filtered by `genre`. */
233
- async catalogAlbums(limit = 50, offset = 0, genre?: string): Promise<AlbumViewBasic[]> {
257
+ /** The album catalog, optionally filtered by `genre` and/or an RSQL
258
+ * {@link Filter} expression (see {@link AlbumFields}). */
259
+ async catalogAlbums(
260
+ limit = 50,
261
+ offset = 0,
262
+ genre?: string,
263
+ filter?: string | Filter,
264
+ ): Promise<AlbumViewBasic[]> {
234
265
  const out = await this.query<{ albums?: AlbumViewBasic[] }>("app.rocksky.album.getAlbums", {
235
266
  limit,
236
267
  offset,
237
268
  genre,
269
+ filter: filter?.toString(),
238
270
  });
239
271
  return out.albums ?? [];
240
272
  }
241
273
 
242
- /** The artist catalog, optionally filtered by `genre`. */
243
- async catalogArtists(limit = 50, offset = 0, genre?: string): Promise<ArtistViewBasic[]> {
274
+ /** The artist catalog, optionally filtered by `genre` and/or an RSQL
275
+ * {@link Filter} expression (see {@link ArtistFields}). */
276
+ async catalogArtists(
277
+ limit = 50,
278
+ offset = 0,
279
+ genre?: string,
280
+ filter?: string | Filter,
281
+ ): Promise<ArtistViewBasic[]> {
244
282
  const out = await this.query<{ artists?: ArtistViewBasic[] }>("app.rocksky.artist.getArtists", {
245
283
  limit,
246
284
  offset,
247
285
  genre,
286
+ filter: filter?.toString(),
248
287
  });
249
288
  return out.artists ?? [];
250
289
  }
251
290
 
252
- /** The song catalog, optionally filtered by `genre`. */
253
- async catalogSongs(limit = 50, offset = 0, genre?: string): Promise<SongViewBasic[]> {
291
+ /** The song catalog, optionally filtered by `genre` and/or an RSQL
292
+ * {@link Filter} expression (see {@link SongFields}). */
293
+ async catalogSongs(
294
+ limit = 50,
295
+ offset = 0,
296
+ genre?: string,
297
+ filter?: string | Filter,
298
+ ): Promise<SongViewBasic[]> {
254
299
  const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.song.getSongs", {
255
300
  limit,
256
301
  offset,
257
302
  genre,
303
+ filter: filter?.toString(),
258
304
  });
259
305
  return out.tracks ?? [];
260
306
  }
@@ -282,13 +328,21 @@ export class RockskyClient {
282
328
  }
283
329
 
284
330
  /** A social/global scrobbles feed. Pass `did` to scope to an actor and
285
- * `following = true` for their follow graph. */
286
- async scrobbleFeed(did?: string, following = false, limit = 50, offset = 0): Promise<ScrobbleViewBasic[]> {
331
+ * `following = true` for their follow graph. `filter` takes an RSQL
332
+ * {@link Filter} expression (see {@link ScrobbleFields}). */
333
+ async scrobbleFeed(
334
+ did?: string,
335
+ following = false,
336
+ limit = 50,
337
+ offset = 0,
338
+ filter?: string | Filter,
339
+ ): Promise<ScrobbleViewBasic[]> {
287
340
  const out = await this.query<{ scrobbles?: ScrobbleViewBasic[] }>("app.rocksky.scrobble.getScrobbles", {
288
341
  did,
289
342
  following,
290
343
  limit,
291
344
  offset,
345
+ filter: filter?.toString(),
292
346
  });
293
347
  return out.scrobbles ?? [];
294
348
  }
@@ -509,7 +563,34 @@ export class RockskyClient {
509
563
  const res = await this.rpc.post("app.rocksky.notification.updateSeen" as never, {
510
564
  input: (ids && ids.length ? { ids } : {}) as never,
511
565
  } as never);
512
- if (!res.ok) throw new RockskyError(res.data);
566
+ if (!res.ok) throw new RockskyError(res.data, res.status);
513
567
  return res.data as UpdateSeenOutput;
514
568
  }
569
+
570
+ /** Follow an account by DID or handle (`app.rocksky.graph.followAccount`). Auth required. */
571
+ followAccount(account: string): Promise<FollowAccountOutput> {
572
+ return this.post("app.rocksky.graph.followAccount", { params: { account } });
573
+ }
574
+
575
+ /** Unfollow an account by DID or handle (`app.rocksky.graph.unfollowAccount`). Auth required. */
576
+ unfollowAccount(account: string): Promise<UnfollowAccountOutput> {
577
+ return this.post("app.rocksky.graph.unfollowAccount", { params: { account } });
578
+ }
579
+
580
+ /** Submit a scrobble through the AppView (`app.rocksky.scrobble.createScrobble`).
581
+ * Auth required. For direct-to-PDS scrobbling use {@link Agent} instead. */
582
+ createScrobble(input: CreateScrobbleInput): Promise<ScrobbleViewBasic> {
583
+ return this.post("app.rocksky.scrobble.createScrobble", { body: input });
584
+ }
585
+
586
+ /** Create or update a mirror source (`app.rocksky.mirror.putMirrorSource`). Auth required. */
587
+ putMirrorSource(input: PutMirrorSourceInput): Promise<MirrorSourceView> {
588
+ return this.post("app.rocksky.mirror.putMirrorSource", { body: input });
589
+ }
590
+
591
+ /** Patch the viewer's Rockbox audio settings (`app.rocksky.rockbox.putAudioSettings`).
592
+ * Auth required. */
593
+ putAudioSettings(input: PutAudioSettingsInput): Promise<RockboxSettingsView> {
594
+ return this.post("app.rocksky.rockbox.putAudioSettings", { body: input });
595
+ }
515
596
  }
package/src/errors.ts CHANGED
@@ -1,10 +1,15 @@
1
- /** Error thrown when a Rocksky XRPC call returns a non-2xx `{ error, message }`. */
2
1
  export class RockskyError extends Error {
2
+ /** The XRPC error code (e.g. "InvalidRequest", "AuthMissing"), when the
3
+ * server sent one. */
3
4
  readonly kind?: string;
4
- constructor(payload: unknown) {
5
+ /** The HTTP status of the failed response, when known. */
6
+ readonly status?: number;
7
+
8
+ constructor(payload: unknown, status?: number) {
5
9
  const p = payload as { error?: string; message?: string } | undefined;
6
10
  super(p?.message || p?.error || "rocksky request failed");
7
11
  this.name = "RockskyError";
8
12
  this.kind = p?.error;
13
+ this.status = status;
9
14
  }
10
15
  }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AlbumFields, ArtistFields, Filter, ScrobbleFields, SongFields } from "./filter.js";
3
+
4
+ describe("Filter", () => {
5
+ test("eq renders a bare value when safe", () => {
6
+ expect(Filter.eq("artist", "Radiohead").build()).toBe("artist==Radiohead");
7
+ });
8
+
9
+ test("eq quotes values with spaces", () => {
10
+ expect(Filter.eq("artist", "Daft Punk").build()).toBe('artist=="Daft Punk"');
11
+ });
12
+
13
+ test("eq escapes embedded quotes and backslashes", () => {
14
+ expect(Filter.eq("title", 'He said "hi"').build()).toBe('title=="He said \\"hi\\""');
15
+ expect(Filter.eq("title", "back\\slash").build()).toBe('title=="back\\\\slash"');
16
+ });
17
+
18
+ test("wildcards pass through unquoted", () => {
19
+ expect(Filter.eq("artist", "Daft*").build()).toBe("artist==Daft*");
20
+ });
21
+
22
+ test("ne", () => {
23
+ expect(Filter.ne("artist", "Eminem").build()).toBe("artist!=Eminem");
24
+ });
25
+
26
+ test("ordered comparisons render verbose operators", () => {
27
+ expect(Filter.gt("duration", 200000).build()).toBe("duration=gt=200000");
28
+ expect(Filter.ge("year", 2000).build()).toBe("year=ge=2000");
29
+ expect(Filter.lt("trackNumber", 5).build()).toBe("trackNumber=lt=5");
30
+ expect(Filter.le("year", 1999).build()).toBe("year=le=1999");
31
+ });
32
+
33
+ test("in and out lists", () => {
34
+ expect(Filter.in("genre", ["house", "electro"]).build()).toBe("genre=in=(house,electro)");
35
+ expect(Filter.out("genre", ["hip hop"]).build()).toBe('genre=out=("hip hop")');
36
+ });
37
+
38
+ test("in/out reject empty lists", () => {
39
+ expect(() => Filter.in("genre", [])).toThrow();
40
+ expect(() => Filter.out("genre", [])).toThrow();
41
+ });
42
+
43
+ test("null checks", () => {
44
+ expect(Filter.isNull("uri").build()).toBe("uri==null");
45
+ expect(Filter.isNotNull("uri").build()).toBe("uri!=null");
46
+ });
47
+
48
+ test("and joins with ;", () => {
49
+ const f = Filter.eq("artist", "Radiohead").and(Filter.gt("duration", 200000));
50
+ expect(f.build()).toBe("artist==Radiohead;duration=gt=200000");
51
+ });
52
+
53
+ test("or joins with ,", () => {
54
+ const f = Filter.eq("artist", "Radiohead").or(Filter.eq("artist", "Muse"));
55
+ expect(f.build()).toBe("artist==Radiohead,artist==Muse");
56
+ });
57
+
58
+ test("or nested in and is parenthesized", () => {
59
+ const left = Filter.eq("artist", "Radiohead").or(Filter.eq("artist", "Muse"));
60
+ expect(left.and(Filter.gt("duration", 200000)).build()).toBe(
61
+ "(artist==Radiohead,artist==Muse);duration=gt=200000",
62
+ );
63
+ const right = Filter.eq("genre", "house").or(Filter.eq("genre", "electro"));
64
+ expect(Filter.eq("artist", "Radiohead").and(right).build()).toBe(
65
+ "artist==Radiohead;(genre==house,genre==electro)",
66
+ );
67
+ });
68
+
69
+ test("and nested in or needs no parentheses", () => {
70
+ const f = Filter.eq("artist", "Radiohead")
71
+ .and(Filter.gt("duration", 200000))
72
+ .or(Filter.eq("genre", "house"));
73
+ expect(f.build()).toBe("artist==Radiohead;duration=gt=200000,genre==house");
74
+ });
75
+
76
+ test("booleans and dotted fields", () => {
77
+ expect(Filter.eq("user.handle", "tsiry.dev").build()).toBe("user.handle==tsiry.dev");
78
+ expect(Filter.eq("liked", true).build()).toBe("liked==true");
79
+ });
80
+
81
+ test("toString matches build so filters interpolate", () => {
82
+ const f = Filter.eq("artist", "Radiohead");
83
+ expect(`${f}`).toBe(f.build());
84
+ });
85
+
86
+ test("field constants map to server selectors", () => {
87
+ expect(SongFields.albumArtist).toBe("albumArtist");
88
+ expect(AlbumFields.releaseDate).toBe("releaseDate");
89
+ expect(ArtistFields.genres).toBe("genres");
90
+ expect(ScrobbleFields.trackArtist).toBe("track.artist");
91
+ expect(ScrobbleFields.userHandle).toBe("user.handle");
92
+ expect(Filter.eq(ScrobbleFields.artistGenres, "house").build()).toBe("artist.genres==house");
93
+ });
94
+ });
package/src/filter.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Fluent builder for RSQL filter expressions, accepted by the `filter`
3
+ * parameter of the catalog and scrobble-feed queries
4
+ * (app.rocksky.song.getSongs, app.rocksky.artist.getArtists,
5
+ * app.rocksky.album.getAlbums, app.rocksky.scrobble.getScrobbles).
6
+ *
7
+ * ```ts
8
+ * import { Filter } from "@rocksky/sdk";
9
+ *
10
+ * const filter = Filter.eq("artist", "Daft Punk")
11
+ * .and(Filter.gt("duration", 200_000))
12
+ * .or(Filter.in("genre", ["house", "electro"]));
13
+ *
14
+ * await client.catalogSongs(50, 0, undefined, filter);
15
+ * // artist=="Daft Punk";duration=gt=200000,genre=in=(house,electro)
16
+ * ```
17
+ *
18
+ * String values are quoted and escaped automatically when they contain
19
+ * characters RSQL reserves; `*` wildcards pass through unquoted so
20
+ * `Filter.eq("artist", "Daft*")` performs a case-insensitive match.
21
+ */
22
+ export type FilterValue = string | number | boolean;
23
+
24
+ /** Characters that never need quoting in an RSQL value (`*` kept bare so wildcards work). */
25
+ const SAFE_VALUE = /^[A-Za-z0-9_.:@*+-]+$/;
26
+
27
+ const renderValue = (value: FilterValue): string => {
28
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
29
+ if (value.length > 0 && SAFE_VALUE.test(value)) return value;
30
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
31
+ };
32
+
33
+ type NodeKind = "comparison" | "and" | "or";
34
+
35
+ export class Filter {
36
+ private constructor(
37
+ private readonly expr: string,
38
+ private readonly kind: NodeKind,
39
+ ) {}
40
+
41
+ private static comparison(field: string, op: string, value: FilterValue): Filter {
42
+ return new Filter(`${field}${op}${renderValue(value)}`, "comparison");
43
+ }
44
+
45
+ private static list(field: string, op: string, values: FilterValue[]): Filter {
46
+ if (values.length === 0) {
47
+ throw new Error(`Filter.${op === "=in=" ? "in" : "out"}("${field}", ...) needs at least one value`);
48
+ }
49
+ return new Filter(`${field}${op}(${values.map(renderValue).join(",")})`, "comparison");
50
+ }
51
+
52
+ /** `field==value` — equals; `*` in string values is a wildcard. */
53
+ static eq(field: string, value: FilterValue): Filter {
54
+ return Filter.comparison(field, "==", value);
55
+ }
56
+
57
+ /** `field!=value` — not equals. */
58
+ static ne(field: string, value: FilterValue): Filter {
59
+ return Filter.comparison(field, "!=", value);
60
+ }
61
+
62
+ /** `field=gt=value` — greater than. */
63
+ static gt(field: string, value: FilterValue): Filter {
64
+ return Filter.comparison(field, "=gt=", value);
65
+ }
66
+
67
+ /** `field=ge=value` — greater than or equal. */
68
+ static ge(field: string, value: FilterValue): Filter {
69
+ return Filter.comparison(field, "=ge=", value);
70
+ }
71
+
72
+ /** `field=lt=value` — less than. */
73
+ static lt(field: string, value: FilterValue): Filter {
74
+ return Filter.comparison(field, "=lt=", value);
75
+ }
76
+
77
+ /** `field=le=value` — less than or equal. */
78
+ static le(field: string, value: FilterValue): Filter {
79
+ return Filter.comparison(field, "=le=", value);
80
+ }
81
+
82
+ /** `field=in=(a,b)` — matches any of the values. */
83
+ static in(field: string, values: FilterValue[]): Filter {
84
+ return Filter.list(field, "=in=", values);
85
+ }
86
+
87
+ /** `field=out=(a,b)` — matches none of the values. */
88
+ static out(field: string, values: FilterValue[]): Filter {
89
+ return Filter.list(field, "=out=", values);
90
+ }
91
+
92
+ /** `field==null` — the field is NULL. */
93
+ static isNull(field: string): Filter {
94
+ return new Filter(`${field}==null`, "comparison");
95
+ }
96
+
97
+ /** `field!=null` — the field is not NULL. */
98
+ static isNotNull(field: string): Filter {
99
+ return new Filter(`${field}!=null`, "comparison");
100
+ }
101
+
102
+ /** Both sides must match (`;`). An `or` operand is parenthesized to keep RSQL precedence. */
103
+ and(other: Filter): Filter {
104
+ return new Filter(`${this.renderIn("and")};${other.renderIn("and")}`, "and");
105
+ }
106
+
107
+ /** Either side may match (`,`). */
108
+ or(other: Filter): Filter {
109
+ return new Filter(`${this.expr},${other.expr}`, "or");
110
+ }
111
+
112
+ private renderIn(parent: "and"): string {
113
+ return this.kind === "or" ? `(${this.expr})` : this.expr;
114
+ }
115
+
116
+ /** The RSQL expression string to send as the `filter` query param. */
117
+ build(): string {
118
+ return this.expr;
119
+ }
120
+
121
+ toString(): string {
122
+ return this.expr;
123
+ }
124
+ }
125
+
126
+ /** Filterable fields of app.rocksky.song.getSongs. */
127
+ export const SongFields = {
128
+ title: "title",
129
+ artist: "artist",
130
+ album: "album",
131
+ albumArtist: "albumArtist",
132
+ genre: "genre",
133
+ composer: "composer",
134
+ label: "label",
135
+ duration: "duration",
136
+ trackNumber: "trackNumber",
137
+ discNumber: "discNumber",
138
+ mbId: "mbId",
139
+ isrc: "isrc",
140
+ sha256: "sha256",
141
+ uri: "uri",
142
+ albumUri: "albumUri",
143
+ artistUri: "artistUri",
144
+ createdAt: "createdAt",
145
+ } as const;
146
+
147
+ /** Filterable fields of app.rocksky.album.getAlbums. */
148
+ export const AlbumFields = {
149
+ title: "title",
150
+ artist: "artist",
151
+ year: "year",
152
+ releaseDate: "releaseDate",
153
+ sha256: "sha256",
154
+ uri: "uri",
155
+ artistUri: "artistUri",
156
+ createdAt: "createdAt",
157
+ } as const;
158
+
159
+ /** Filterable fields of app.rocksky.artist.getArtists. */
160
+ export const ArtistFields = {
161
+ name: "name",
162
+ genres: "genres",
163
+ bornIn: "bornIn",
164
+ born: "born",
165
+ died: "died",
166
+ sha256: "sha256",
167
+ uri: "uri",
168
+ createdAt: "createdAt",
169
+ } as const;
170
+
171
+ /** Filterable fields of app.rocksky.scrobble.getScrobbles (dotted selectors reach the joined track/user/artist). */
172
+ export const ScrobbleFields = {
173
+ uri: "uri",
174
+ date: "date",
175
+ timestamp: "timestamp",
176
+ title: "title",
177
+ artist: "artist",
178
+ album: "album",
179
+ trackTitle: "track.title",
180
+ trackArtist: "track.artist",
181
+ trackAlbum: "track.album",
182
+ trackAlbumArtist: "track.albumArtist",
183
+ trackGenre: "track.genre",
184
+ trackDuration: "track.duration",
185
+ trackIsrc: "track.isrc",
186
+ trackMbId: "track.mbId",
187
+ userDid: "user.did",
188
+ userHandle: "user.handle",
189
+ userDisplayName: "user.displayName",
190
+ artistName: "artist.name",
191
+ artistGenres: "artist.genres",
192
+ } as const;
@@ -243,6 +243,8 @@ export interface ArtistGetArtistsParams {
243
243
  names?: string;
244
244
  /** The genre to filter artists by */
245
245
  genre?: string;
246
+ /** RSQL filter expression, e.g. `name==Daft*;genres=in=(house,electro)`. Supports ==, !=, <, <=, >, >=, =in=, =out=, and `;`/`and`, `,`/`or` combinators, `*` wildcards in string values. Filterable fields: name, genres, bornIn, born, died, sha256, uri, createdAt */
247
+ filter?: string;
246
248
  }
247
249
 
248
250
  export interface ArtistListenerViewBasic {
@@ -847,6 +849,8 @@ export interface GetAlbumsParams {
847
849
  offset?: number;
848
850
  /** The genre to filter artists by */
849
851
  genre?: string;
852
+ /** RSQL filter expression, e.g. `artist=="Daft Punk";year=ge=2000`. Supports ==, !=, <, <=, >, >=, =in=, =out=, and `;`/`and`, `,`/`or` combinators, `*` wildcards in string values. Filterable fields: title, artist, year, releaseDate, sha256, uri, artistUri, createdAt */
853
+ filter?: string;
850
854
  }
851
855
 
852
856
  export interface GetAlbumTracksOutput {
@@ -1243,6 +1247,8 @@ export interface GetScrobblesParams {
1243
1247
  limit?: number;
1244
1248
  /** The offset for pagination */
1245
1249
  offset?: number;
1250
+ /** RSQL filter expression, e.g. `track.artist=="Daft Punk";date=ge=2025-01-01`. Supports ==, !=, <, <=, >, >=, =in=, =out=, and `;`/`and`, `,`/`or` combinators, `*` wildcards in string values. Filterable fields: uri, date, timestamp, title, artist, album, track.title, track.artist, track.album, track.albumArtist, track.genre, track.duration, track.isrc, track.mbId, user.did, user.handle, user.displayName, artist.name, artist.genres */
1251
+ filter?: string;
1246
1252
  }
1247
1253
 
1248
1254
  export interface GetShoutRepliesOutput {
@@ -1312,6 +1318,8 @@ export interface GetSongsParams {
1312
1318
  isrc?: string;
1313
1319
  /** Filter songs by Spotify track ID (resolved internally to the Spotify track URL) */
1314
1320
  spotifyId?: string;
1321
+ /** RSQL filter expression, e.g. `artist=="Daft Punk";duration=gt=200000`. Supports ==, !=, <, <=, >, >=, =in=, =out=, and `;`/`and`, `,`/`or` combinators, `*` wildcards in string values. Filterable fields: title, artist, album, albumArtist, genre, composer, label, duration, trackNumber, discNumber, mbId, isrc, sha256, uri, albumUri, artistUri, createdAt */
1322
+ filter?: string;
1315
1323
  }
1316
1324
 
1317
1325
  export interface GetStarredOutput {
@@ -1626,6 +1634,18 @@ export interface NotificationActor {
1626
1634
  avatar?: Uri;
1627
1635
  }
1628
1636
 
1637
+ /** The song, album, or scrobble a notification relates to, for rich display. */
1638
+ export interface NotificationSubjectView {
1639
+ /** The at-uri of the subject. */
1640
+ uri: string;
1641
+ /** The title of the track or album. */
1642
+ title?: string;
1643
+ /** The artist of the track or album. */
1644
+ artist?: string;
1645
+ /** The album art image URL. */
1646
+ albumArt?: Uri;
1647
+ }
1648
+
1629
1649
  export interface NotificationView {
1630
1650
  /** The unique identifier of the notification. */
1631
1651
  id: string;
@@ -1642,6 +1662,7 @@ export interface NotificationView {
1642
1662
  /** The content of the related shout, if any. */
1643
1663
  shoutContent?: string;
1644
1664
  actor?: NotificationActor;
1665
+ subject?: NotificationSubjectView;
1645
1666
  }
1646
1667
 
1647
1668
  export interface PingOutput {
package/src/hash.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { createHash } from "node:crypto";
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import { bytesToHex } from "@noble/hashes/utils.js";
2
3
 
3
4
  // Lowercase-hex SHA-256 of s.toLowerCase() — the lowercasing is applied to the
4
5
  // whole string (not per field), matching Rocksky's server and every other SDK.
6
+ // Pure-JS digest (@noble/hashes) so this module stays browser-safe.
5
7
  function sha256Lower(s: string): string {
6
- return createHash("sha256").update(s.toLowerCase()).digest("hex");
8
+ return bytesToHex(sha256(new TextEncoder().encode(s.toLowerCase())));
7
9
  }
8
10
 
9
11
  /** Identity hash of a song: sha256(lower("{title} - {artist} - {album}")). */
package/src/index.ts CHANGED
@@ -23,7 +23,9 @@ export {
23
23
  type RateLimitOptions,
24
24
  type RateLimitState,
25
25
  } from "./agent.js";
26
- export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
26
+ // The dedup index (classic-level, Node-only) lives on the `@rocksky/sdk/dedup`
27
+ // subpath so this root entry stays browser-safe.
28
+ export type { IndexStats, RockskyIndex } from "./dedup.js";
27
29
  export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
28
30
  export {
29
31
  RemotePlayer,
@@ -41,6 +43,14 @@ export {
41
43
  type RemoteDevice,
42
44
  type RemoteStatus,
43
45
  } from "./remote-controller.js";
46
+ export {
47
+ Filter,
48
+ SongFields,
49
+ AlbumFields,
50
+ ArtistFields,
51
+ ScrobbleFields,
52
+ type FilterValue,
53
+ } from "./filter.js";
44
54
  export { songHash, albumHash, artistHash } from "./hash.js";
45
55
  export { RockskyError } from "./errors.js";
46
56
  export type * from "./generated/types.js";
package/src/library.ts CHANGED
@@ -20,7 +20,7 @@ export class RockskyLibrary {
20
20
  if (v !== undefined && v !== "") clean[k] = v;
21
21
  }
22
22
  const res = await this.rpc.get(nsid as never, { params: clean } as never);
23
- if (!res.ok) throw new RockskyError(res.data);
23
+ if (!res.ok) throw new RockskyError(res.data, res.status);
24
24
  return res.data as T;
25
25
  }
26
26
 
@@ -30,7 +30,7 @@ export class RockskyLibrary {
30
30
  if (v !== undefined && v !== "") clean[k] = v;
31
31
  }
32
32
  const res = await this.rpc.post(nsid as never, { input: clean } as never);
33
- if (!res.ok) throw new RockskyError(res.data);
33
+ if (!res.ok) throw new RockskyError(res.data, res.status);
34
34
  return res.data as T;
35
35
  }
36
36
 
package/src/remote.ts CHANGED
@@ -1,9 +1,8 @@
1
- // Browser-safe entry: the remote-control player + controller only.
1
+ // Lightweight entry: the remote-control player + controller only.
2
2
  //
3
- // The main entry (`@rocksky/sdk`) bundles the dedup index (classic-level) and
4
- // the identity hashes (node:crypto), which are Node-only. The remote player /
5
- // controller are pure WebSocket + JSON with zero Node dependencies, so this
6
- // subpath (`@rocksky/sdk/remote`) is safe to import from a browser bundle.
3
+ // Since 0.14.0 the main entry (`@rocksky/sdk`) is browser-safe too the
4
+ // dedup index (classic-level, Node-only) moved to `@rocksky/sdk/dedup`. This
5
+ // subpath remains for consumers that only need the remote player/controller.
7
6
 
8
7
  export {
9
8
  RemotePlayer,