@rocksky/sdk 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for Rocksky — built on atcute: AppView reads, AT Protocol PDS writes (scrobble, like, follow, shout), a local dedup index, and Jetstream real-time sync.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/agent.ts CHANGED
@@ -153,6 +153,52 @@ export class Agent {
153
153
  return uri;
154
154
  }
155
155
 
156
+ /** Scrobble from just a title + artist (album optional, plus optional
157
+ * `mbId`/`isrc` anchors): resolve full metadata via `matchSong`, then write.
158
+ * Matching uses the public AppView unless `appview` is given; an empty match
159
+ * falls back to a minimal record. */
160
+ async scrobbleMatch(
161
+ title: string,
162
+ artist: string,
163
+ album?: string,
164
+ mbId?: string,
165
+ isrc?: string,
166
+ appview?: string,
167
+ ): Promise<string> {
168
+ const { RockskyClient } = await import("./client.js");
169
+ const m = (await new RockskyClient(appview).matchSong(title, artist, mbId, isrc)) as Record<
170
+ string,
171
+ unknown
172
+ > | null;
173
+ const s = (k: string): string | undefined => (m && typeof m[k] === "string" ? (m[k] as string) : undefined);
174
+ const n = (k: string): number | undefined => (m && typeof m[k] === "number" ? (m[k] as number) : undefined);
175
+ const rec: ScrobbleInput =
176
+ m && m.title
177
+ ? {
178
+ title: s("title")!,
179
+ artist: s("artist")!,
180
+ albumArtist: s("albumArtist") ?? artist,
181
+ album: album ?? s("album") ?? "",
182
+ albumArtUrl: s("albumArt"),
183
+ duration: n("duration") ?? 0,
184
+ trackNumber: n("trackNumber"),
185
+ discNumber: n("discNumber"),
186
+ year: n("year"),
187
+ releaseDate: s("releaseDate"),
188
+ genre: s("genre"),
189
+ composer: s("composer"),
190
+ label: s("label"),
191
+ mbid: s("mbId"),
192
+ isrc: s("isrc"),
193
+ spotifyLink: s("spotifyLink"),
194
+ youtubeLink: s("youtubeLink"),
195
+ tidalLink: s("tidalLink"),
196
+ appleMusicLink: s("appleMusicLink"),
197
+ }
198
+ : { title, artist, album: album ?? "", albumArtist: artist, duration: 0 };
199
+ return this.scrobble(rec);
200
+ }
201
+
156
202
  /** Create a canonical track record (app.rocksky.song). */
157
203
  async createSong(rec: SongInput): Promise<string> {
158
204
  const record = { ...rec, createdAt: rec.createdAt || nowISO() };
package/src/client.ts CHANGED
@@ -2,6 +2,7 @@ import { Client, simpleFetchHandler } from "@atcute/client";
2
2
 
3
3
  import { RockskyError } from "./errors.js";
4
4
  import type {
5
+ ActorProfileViewBasic,
5
6
  ActorProfileViewDetailed,
6
7
  AlbumViewBasic,
7
8
  ArtistViewBasic,
@@ -9,14 +10,48 @@ import type {
9
10
  GetActorAlbumsOutput,
10
11
  GetActorArtistsOutput,
11
12
  GetActorScrobblesOutput,
12
- GetActorSongsOutput,
13
- GetTopArtistsOutput,
14
- GetTopTracksOutput,
15
13
  ScrobbleViewBasic,
16
14
  SongViewBasic,
17
15
  StatsGlobalStatsView,
18
16
  } from "./generated/types.js";
19
17
 
18
+ /**
19
+ * A typed date window for the `top*` charts. Build one with the {@link Interval}
20
+ * factories; `range` bounds are RFC-3339 datetimes.
21
+ */
22
+ export interface DateInterval {
23
+ startDate?: string;
24
+ endDate?: string;
25
+ }
26
+
27
+ function since(days = 0, months = 0, years = 0): DateInterval {
28
+ const now = new Date();
29
+ const start = new Date(now);
30
+ start.setUTCFullYear(start.getUTCFullYear() - years);
31
+ start.setUTCMonth(start.getUTCMonth() - months);
32
+ start.setUTCDate(start.getUTCDate() - days);
33
+ return { startDate: start.toISOString(), endDate: now.toISOString() };
34
+ }
35
+
36
+ /** Factories for {@link DateInterval} windows used by the `top*Interval` charts. */
37
+ export const Interval = {
38
+ /** No bounds — the all-time chart. */
39
+ allTime: (): DateInterval => ({}),
40
+ /** The last `n` days ending now. */
41
+ lastDays: (n: number): DateInterval => since(n),
42
+ /** The last `n` weeks ending now. */
43
+ lastWeeks: (n: number): DateInterval => since(7 * n),
44
+ /** The last `n` months ending now. */
45
+ lastMonths: (n: number): DateInterval => since(0, n),
46
+ /** The last `n` years ending now. */
47
+ lastYears: (n: number): DateInterval => since(0, 0, n),
48
+ /** An explicit closed `[start, end]` window. */
49
+ range: (start: Date, end: Date): DateInterval => ({
50
+ startDate: start.toISOString(),
51
+ endDate: end.toISOString(),
52
+ }),
53
+ };
54
+
20
55
  /** The default public Rocksky AppView base URL. */
21
56
  export const DEFAULT_APPVIEW = "https://api.rocksky.app";
22
57
 
@@ -24,9 +59,23 @@ export const DEFAULT_APPVIEW = "https://api.rocksky.app";
24
59
  export class RockskyClient {
25
60
  private rpc: Client;
26
61
 
27
- /** Build a read client against an AppView base URL (defaults to {@link DEFAULT_APPVIEW}). */
28
- constructor(appview: string = DEFAULT_APPVIEW) {
29
- this.rpc = new Client({ handler: simpleFetchHandler({ service: appview }) });
62
+ /**
63
+ * Build a read client against an AppView base URL (defaults to
64
+ * {@link DEFAULT_APPVIEW}). Pass `token` to send it as
65
+ * `Authorization: Bearer <token>` on every read — needed only for auth-gated
66
+ * queries.
67
+ */
68
+ constructor(appview: string = DEFAULT_APPVIEW, token?: string) {
69
+ let handler = simpleFetchHandler({ service: appview });
70
+ if (token) {
71
+ const inner = handler;
72
+ handler = ((pathname: string, init?: RequestInit) =>
73
+ inner(pathname, {
74
+ ...init,
75
+ headers: { ...(init?.headers as Record<string, string>), authorization: `Bearer ${token}` },
76
+ })) as typeof handler;
77
+ }
78
+ this.rpc = new Client({ handler });
30
79
  }
31
80
 
32
81
  private async query<T>(nsid: string, params: Record<string, unknown>): Promise<T> {
@@ -54,14 +103,29 @@ export class RockskyClient {
54
103
  return out.scrobbles ?? [];
55
104
  }
56
105
 
106
+ /** Call any AppView read query by nsid; returns the raw JSON response. Every
107
+ * method here is sugar over this — use it for queries without a wrapper. */
108
+ get(nsid: string, params: Record<string, unknown> = {}): Promise<unknown> {
109
+ return this.query(nsid, params);
110
+ }
111
+
57
112
  /** An actor's most-played songs. */
58
113
  async songs(actor: string, limit = 50, offset = 0): Promise<SongViewBasic[]> {
59
- const out = await this.query<GetActorSongsOutput>("app.rocksky.actor.getActorSongs", {
114
+ const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.actor.getActorSongs", {
60
115
  did: actor,
61
116
  limit,
62
117
  offset,
63
118
  });
64
- return out.songs ?? [];
119
+ return out.tracks ?? [];
120
+ }
121
+
122
+ /** An actor's loved (liked) songs. */
123
+ async lovedSongs(actor: string, limit = 50, offset = 0): Promise<SongViewBasic[]> {
124
+ const out = await this.query<{ tracks?: SongViewBasic[] }>(
125
+ "app.rocksky.actor.getActorLovedSongs",
126
+ { did: actor, limit, offset },
127
+ );
128
+ return out.tracks ?? [];
65
129
  }
66
130
 
67
131
  /** An actor's most-played albums. */
@@ -84,18 +148,134 @@ export class RockskyClient {
84
148
  return out.artists ?? [];
85
149
  }
86
150
 
87
- /** The platform-wide top tracks chart. */
88
- async topTracks(limit = 50, offset = 0): Promise<SongViewBasic[]> {
89
- const out = await this.query<GetTopTracksOutput>("app.rocksky.charts.getTopTracks", { limit, offset });
151
+ /** The platform-wide top tracks chart (all-time). */
152
+ topTracks(limit = 50, offset = 0): Promise<SongViewBasic[]> {
153
+ return this.topTracksInterval(limit, offset, Interval.allTime());
154
+ }
155
+
156
+ /** The platform-wide top artists chart (all-time). */
157
+ topArtists(limit = 50, offset = 0): Promise<ArtistViewBasic[]> {
158
+ return this.topArtistsInterval(limit, offset, Interval.allTime());
159
+ }
160
+
161
+ /** The top tracks chart over a typed {@link DateInterval}. */
162
+ async topTracksInterval(limit: number, offset: number, interval: DateInterval): Promise<SongViewBasic[]> {
163
+ const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.charts.getTopTracks", {
164
+ limit,
165
+ offset,
166
+ ...interval,
167
+ });
90
168
  return out.tracks ?? [];
91
169
  }
92
170
 
93
- /** The platform-wide top artists chart. */
94
- async topArtists(limit = 50, offset = 0): Promise<ArtistViewBasic[]> {
95
- const out = await this.query<GetTopArtistsOutput>("app.rocksky.charts.getTopArtists", { limit, offset });
171
+ /** The top artists chart over a typed {@link DateInterval}. */
172
+ async topArtistsInterval(limit: number, offset: number, interval: DateInterval): Promise<ArtistViewBasic[]> {
173
+ const out = await this.query<{ artists?: ArtistViewBasic[] }>("app.rocksky.charts.getTopArtists", {
174
+ limit,
175
+ offset,
176
+ ...interval,
177
+ });
96
178
  return out.artists ?? [];
97
179
  }
98
180
 
181
+ /** The album catalog, optionally filtered by `genre`. */
182
+ async catalogAlbums(limit = 50, offset = 0, genre?: string): Promise<AlbumViewBasic[]> {
183
+ const out = await this.query<{ albums?: AlbumViewBasic[] }>("app.rocksky.album.getAlbums", {
184
+ limit,
185
+ offset,
186
+ genre,
187
+ });
188
+ return out.albums ?? [];
189
+ }
190
+
191
+ /** The artist catalog, optionally filtered by `genre`. */
192
+ async catalogArtists(limit = 50, offset = 0, genre?: string): Promise<ArtistViewBasic[]> {
193
+ const out = await this.query<{ artists?: ArtistViewBasic[] }>("app.rocksky.artist.getArtists", {
194
+ limit,
195
+ offset,
196
+ genre,
197
+ });
198
+ return out.artists ?? [];
199
+ }
200
+
201
+ /** The song catalog, optionally filtered by `genre`. */
202
+ async catalogSongs(limit = 50, offset = 0, genre?: string): Promise<SongViewBasic[]> {
203
+ const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.song.getSongs", {
204
+ limit,
205
+ offset,
206
+ genre,
207
+ });
208
+ return out.tracks ?? [];
209
+ }
210
+
211
+ /** An album's tracklist by album at:// URI. */
212
+ async albumTracks(uri: string): Promise<SongViewBasic[]> {
213
+ const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.album.getAlbumTracks", { uri });
214
+ return out.tracks ?? [];
215
+ }
216
+
217
+ /** An artist's albums by artist at:// URI. */
218
+ async artistAlbums(uri: string): Promise<AlbumViewBasic[]> {
219
+ const out = await this.query<{ albums?: AlbumViewBasic[] }>("app.rocksky.artist.getArtistAlbums", { uri });
220
+ return out.albums ?? [];
221
+ }
222
+
223
+ /** An artist's top tracks by artist at:// URI. */
224
+ async artistTracks(uri: string, limit = 50, offset = 0): Promise<SongViewBasic[]> {
225
+ const out = await this.query<{ tracks?: SongViewBasic[] }>("app.rocksky.artist.getArtistTracks", {
226
+ uri,
227
+ limit,
228
+ offset,
229
+ });
230
+ return out.tracks ?? [];
231
+ }
232
+
233
+ /** A social/global scrobbles feed. Pass `did` to scope to an actor and
234
+ * `following = true` for their follow graph. */
235
+ async scrobbleFeed(did?: string, following = false, limit = 50, offset = 0): Promise<ScrobbleViewBasic[]> {
236
+ const out = await this.query<{ scrobbles?: ScrobbleViewBasic[] }>("app.rocksky.scrobble.getScrobbles", {
237
+ did,
238
+ following,
239
+ limit,
240
+ offset,
241
+ });
242
+ return out.scrobbles ?? [];
243
+ }
244
+
245
+ /** A single scrobble by its at:// URI. */
246
+ scrobble(uri: string): Promise<ScrobbleViewBasic> {
247
+ return this.query("app.rocksky.scrobble.getScrobble", { uri });
248
+ }
249
+
250
+ /** The accounts `actor` follows. */
251
+ async follows(actor: string, limit = 50, cursor?: string): Promise<ActorProfileViewBasic[]> {
252
+ const out = await this.query<{ follows?: ActorProfileViewBasic[] }>("app.rocksky.graph.getFollows", {
253
+ actor,
254
+ limit,
255
+ cursor,
256
+ });
257
+ return out.follows ?? [];
258
+ }
259
+
260
+ /** The accounts that follow `actor`. */
261
+ async followers(actor: string, limit = 50, cursor?: string): Promise<ActorProfileViewBasic[]> {
262
+ const out = await this.query<{ followers?: ActorProfileViewBasic[] }>("app.rocksky.graph.getFollowers", {
263
+ actor,
264
+ limit,
265
+ cursor,
266
+ });
267
+ return out.followers ?? [];
268
+ }
269
+
270
+ /** Followers of `actor` that the viewer also follows. */
271
+ async knownFollowers(actor: string, limit = 50, cursor?: string): Promise<ActorProfileViewBasic[]> {
272
+ const out = await this.query<{ followers?: ActorProfileViewBasic[] }>(
273
+ "app.rocksky.graph.getKnownFollowers",
274
+ { actor, limit, cursor },
275
+ );
276
+ return out.followers ?? [];
277
+ }
278
+
99
279
  /** Full-text search across songs, albums, artists, playlists, actors. */
100
280
  search(query: string): Promise<FeedSearchResultsView> {
101
281
  return this.query("app.rocksky.feed.search", { query });
@@ -105,4 +285,149 @@ export class RockskyClient {
105
285
  globalStats(): Promise<StatsGlobalStatsView> {
106
286
  return this.query("app.rocksky.stats.getGlobalStats", {});
107
287
  }
288
+
289
+ // ---- raw-JSON long tail: bespoke shapes returned as `unknown` ----------
290
+
291
+ /** A feed by its at:// URI (paginate via `cursor`). */
292
+ feed(feed: string, limit = 50, cursor?: string): Promise<unknown> {
293
+ return this.query("app.rocksky.feed.getFeed", { feed, limit, cursor });
294
+ }
295
+ /** A single album with its tracklist. */
296
+ album(uri: string): Promise<unknown> {
297
+ return this.query("app.rocksky.album.getAlbum", { uri });
298
+ }
299
+ /** A single artist with detail. */
300
+ artist(uri: string): Promise<unknown> {
301
+ return this.query("app.rocksky.artist.getArtist", { uri });
302
+ }
303
+ /** Resolve full canonical metadata for a bare title + artist
304
+ * (`app.rocksky.song.matchSong`); optionally anchor with `mbId` / `isrc`. */
305
+ matchSong(title: string, artist: string, mbId?: string, isrc?: string): Promise<unknown> {
306
+ return this.query("app.rocksky.song.matchSong", { title, artist, mbId, isrc });
307
+ }
308
+ /** A single song by at:// `uri` (or by `mbid` / `isrc` / `spotifyId`). */
309
+ song(opts: { uri?: string; mbid?: string; isrc?: string; spotifyId?: string }): Promise<unknown> {
310
+ return this.query("app.rocksky.song.getSong", opts);
311
+ }
312
+ /** An actor's playlists. */
313
+ actorPlaylists(actor: string, limit = 50, offset = 0): Promise<unknown> {
314
+ return this.query("app.rocksky.actor.getActorPlaylists", { did: actor, limit, offset });
315
+ }
316
+ /** Actors with similar taste to `actor`. */
317
+ neighbours(actor: string): Promise<unknown> {
318
+ return this.query("app.rocksky.actor.getActorNeighbours", { did: actor });
319
+ }
320
+ /** Music compatibility between the viewer and `actor` (auth). */
321
+ compatibility(actor: string): Promise<unknown> {
322
+ return this.query("app.rocksky.actor.getActorCompatibility", { did: actor });
323
+ }
324
+ /** An artist's all-time listeners. */
325
+ artistListeners(uri: string, limit = 50, offset = 0): Promise<unknown> {
326
+ return this.query("app.rocksky.artist.getArtistListeners", { uri, limit, offset });
327
+ }
328
+ /** An artist's recent listeners. */
329
+ artistRecentListeners(uri: string, limit = 50, offset = 0): Promise<unknown> {
330
+ return this.query("app.rocksky.artist.getArtistRecentListeners", { uri, limit, offset });
331
+ }
332
+ /** A song's recent listeners. */
333
+ songRecentListeners(uri: string, limit = 50, offset = 0): Promise<unknown> {
334
+ return this.query("app.rocksky.song.getSongRecentListeners", { uri, limit, offset });
335
+ }
336
+ /** A scrobble time-series chart. Scope with any of `did` / `artisturi` /
337
+ * `albumuri` / `songuri` / `genre` and bound with `from` / `to`. */
338
+ scrobblesChart(opts: {
339
+ did?: string;
340
+ artisturi?: string;
341
+ albumuri?: string;
342
+ songuri?: string;
343
+ genre?: string;
344
+ from?: string;
345
+ to?: string;
346
+ }): Promise<unknown> {
347
+ return this.query("app.rocksky.charts.getScrobblesChart", opts);
348
+ }
349
+ /** List the available feed generators. */
350
+ feedGenerators(size?: number): Promise<unknown> {
351
+ return this.query("app.rocksky.feed.getFeedGenerators", { size });
352
+ }
353
+ /** A single feed generator's record. */
354
+ feedGenerator(feed: string): Promise<unknown> {
355
+ return this.query("app.rocksky.feed.getFeedGenerator", { feed });
356
+ }
357
+ /** The stories row. */
358
+ stories(size?: number, feed?: string, following?: boolean): Promise<unknown> {
359
+ return this.query("app.rocksky.feed.getStories", { size, feed, following });
360
+ }
361
+ /** Track recommendations for `actor`. */
362
+ recommendations(actor: string, limit?: number): Promise<unknown> {
363
+ return this.query("app.rocksky.feed.getRecommendations", { did: actor, limit });
364
+ }
365
+ /** Artist recommendations for `actor`. */
366
+ artistRecommendations(actor: string, limit?: number): Promise<unknown> {
367
+ return this.query("app.rocksky.feed.getArtistRecommendations", { did: actor, limit });
368
+ }
369
+ /** Album recommendations for `actor`. */
370
+ albumRecommendations(actor: string, limit?: number): Promise<unknown> {
371
+ return this.query("app.rocksky.feed.getAlbumRecommendations", { did: actor, limit });
372
+ }
373
+ /** An actor's aggregate stats. */
374
+ stats(actor: string): Promise<unknown> {
375
+ return this.query("app.rocksky.stats.getStats", { did: actor });
376
+ }
377
+ /** An actor's year-in-review. */
378
+ wrapped(actor: string, year?: number): Promise<unknown> {
379
+ return this.query("app.rocksky.stats.getWrapped", { did: actor, year });
380
+ }
381
+ /** The viewer's configured scrobble mirror sources (auth). */
382
+ mirrorSources(): Promise<unknown> {
383
+ return this.query("app.rocksky.mirror.getMirrorSources", {});
384
+ }
385
+ /** What `actor` is playing now. */
386
+ currentlyPlaying(playerId?: string, actor?: string): Promise<unknown> {
387
+ return this.query("app.rocksky.player.getCurrentlyPlaying", { playerId, actor });
388
+ }
389
+ /** A player's playback queue. */
390
+ playbackQueue(playerId: string): Promise<unknown> {
391
+ return this.query("app.rocksky.player.getPlaybackQueue", { playerId });
392
+ }
393
+ /** What `actor` is playing now on Spotify. */
394
+ spotifyCurrentlyPlaying(actor: string): Promise<unknown> {
395
+ return this.query("app.rocksky.spotify.getCurrentlyPlaying", { actor });
396
+ }
397
+ /** The playlist catalog. */
398
+ playlists(limit = 50, offset = 0): Promise<unknown> {
399
+ return this.query("app.rocksky.playlist.getPlaylists", { limit, offset });
400
+ }
401
+ /** A single playlist with its items. */
402
+ playlist(uri: string): Promise<unknown> {
403
+ return this.query("app.rocksky.playlist.getPlaylist", { uri });
404
+ }
405
+ /** Shouts on an album. */
406
+ albumShouts(uri: string, limit = 50, offset = 0): Promise<unknown> {
407
+ return this.query("app.rocksky.shout.getAlbumShouts", { uri, limit, offset });
408
+ }
409
+ /** Shouts on an artist. */
410
+ artistShouts(uri: string, limit = 50, offset = 0): Promise<unknown> {
411
+ return this.query("app.rocksky.shout.getArtistShouts", { uri, limit, offset });
412
+ }
413
+ /** Shouts on a profile. */
414
+ profileShouts(actor: string, limit = 50, offset = 0): Promise<unknown> {
415
+ return this.query("app.rocksky.shout.getProfileShouts", { did: actor, limit, offset });
416
+ }
417
+ /** Shouts on a track. */
418
+ trackShouts(uri: string): Promise<unknown> {
419
+ return this.query("app.rocksky.shout.getTrackShouts", { uri });
420
+ }
421
+ /** Replies to a shout. */
422
+ shoutReplies(uri: string, limit = 50, offset = 0): Promise<unknown> {
423
+ return this.query("app.rocksky.shout.getShoutReplies", { uri, limit, offset });
424
+ }
425
+ /** An actor's Rockbox EQ / audio settings. */
426
+ audioSettings(actor: string): Promise<unknown> {
427
+ return this.query("app.rocksky.rockbox.getAudioSettings", { did: actor });
428
+ }
429
+ /** The viewer's API keys (auth). */
430
+ apikeys(limit = 50, offset = 0): Promise<unknown> {
431
+ return this.query("app.rocksky.apikey.getApikeys", { limit, offset });
432
+ }
108
433
  }
package/src/index.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * ({@link Agent.syncRepo}) and kept live off the Jetstream firehose
9
9
  * ({@link Agent.hydrateFromJetstream}).
10
10
  */
11
- export { RockskyClient, DEFAULT_APPVIEW } from "./client.js";
11
+ export { RockskyClient, DEFAULT_APPVIEW, Interval } from "./client.js";
12
+ export type { DateInterval } from "./client.js";
12
13
  export { Agent, type ScrobbleInput, type SongInput, type AlbumInput, type ArtistInput } from "./agent.js";
13
14
  export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
14
15
  export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";