@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/README.md +27 -2
- package/dist/client.d.ts +33 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/dedup.js +215 -0
- package/dist/errors.d.ts +5 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/filter.d.ts +122 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.test.d.ts +2 -0
- package/dist/filter.test.d.ts.map +1 -0
- package/dist/generated/types.d.ts +20 -0
- package/dist/generated/types.d.ts.map +1 -1
- package/dist/hash.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +204 -213
- package/dist/remote.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/agent.ts +3 -3
- package/src/client.ts +91 -10
- package/src/errors.ts +7 -2
- package/src/filter.test.ts +94 -0
- package/src/filter.ts +192 -0
- package/src/generated/types.ts +21 -0
- package/src/hash.ts +4 -2
- package/src/index.ts +11 -1
- package/src/library.ts +2 -2
- package/src/remote.ts +4 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/generated/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1
|
+
// Lightweight entry: the remote-control player + controller only.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,
|