@rocksky/sdk 0.3.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.
Files changed (51) hide show
  1. package/README.md +71 -245
  2. package/dist/agent.d.ts +82 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/client.d.ts +165 -111
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/dedup.d.ts +38 -0
  7. package/dist/dedup.d.ts.map +1 -0
  8. package/dist/errors.d.ts +3 -23
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/generated/types.d.ts +106 -8
  11. package/dist/generated/types.d.ts.map +1 -1
  12. package/dist/hash.d.ts +7 -0
  13. package/dist/hash.d.ts.map +1 -0
  14. package/dist/index.d.ts +17 -17
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6956 -1267
  17. package/dist/jetstream.d.ts +17 -0
  18. package/dist/jetstream.d.ts.map +1 -0
  19. package/package.json +15 -11
  20. package/src/agent.ts +278 -0
  21. package/src/client.ts +393 -212
  22. package/src/dedup.ts +207 -0
  23. package/src/errors.ts +6 -43
  24. package/src/generated/types.ts +115 -8
  25. package/src/hash.ts +22 -0
  26. package/src/index.ts +17 -86
  27. package/src/jetstream.ts +122 -0
  28. package/src/http.ts +0 -195
  29. package/src/namespaces/_helpers.ts +0 -27
  30. package/src/namespaces/actor.ts +0 -93
  31. package/src/namespaces/album.ts +0 -34
  32. package/src/namespaces/apikey.ts +0 -50
  33. package/src/namespaces/artist.ts +0 -68
  34. package/src/namespaces/charts.ts +0 -38
  35. package/src/namespaces/dropbox.ts +0 -53
  36. package/src/namespaces/feed.ts +0 -112
  37. package/src/namespaces/googledrive.ts +0 -41
  38. package/src/namespaces/graph.ts +0 -62
  39. package/src/namespaces/like.ts +0 -46
  40. package/src/namespaces/mirror.ts +0 -27
  41. package/src/namespaces/player.ts +0 -125
  42. package/src/namespaces/playlist.ts +0 -95
  43. package/src/namespaces/scrobble.ts +0 -41
  44. package/src/namespaces/shout.ts +0 -99
  45. package/src/namespaces/song.ts +0 -60
  46. package/src/namespaces/spotify.ts +0 -56
  47. package/src/namespaces/stats.ts +0 -27
  48. package/src/paginate.ts +0 -90
  49. package/src/pipe.ts +0 -146
  50. package/src/realtime.ts +0 -408
  51. package/src/types.ts +0 -41
package/src/dedup.ts ADDED
@@ -0,0 +1,207 @@
1
+ import { fromUint8Array } from "@atcute/car";
2
+ import { decode as cborDecode, fromBytes, isBytes, isCidLink } from "@atcute/cbor";
3
+ import { toString as cidToString } from "@atcute/cid";
4
+ import { ClassicLevel } from "classic-level";
5
+
6
+ import { albumHash, artistHash, songHash } from "./hash.js";
7
+
8
+ const C_ARTIST = "app.rocksky.artist";
9
+ const C_ALBUM = "app.rocksky.album";
10
+ const C_SONG = "app.rocksky.song";
11
+ const C_SCROBBLE = "app.rocksky.scrobble";
12
+ const SEP = "\x00";
13
+
14
+ /** What an {@link RockskyIndex.indexCar} pass added. */
15
+ export interface IndexStats {
16
+ artists: number;
17
+ albums: number;
18
+ songs: number;
19
+ scrobbles: number;
20
+ }
21
+
22
+ export function totalIndexed(s: IndexStats): number {
23
+ return s.artists + s.albums + s.songs + s.scrobbles;
24
+ }
25
+
26
+ function identKey(did: string, col: string, hash: string): string {
27
+ return `${did}${SEP}${col}${SEP}${hash}`;
28
+ }
29
+ function scrobbleKey(did: string, songH: string, secs: number): string {
30
+ return `${did}${SEP}${C_SCROBBLE}${SEP}${songH}${SEP}${secs}`;
31
+ }
32
+
33
+ function primaryFor(did: string, col: string, rec: Record<string, unknown>): string | undefined {
34
+ const s = (k: string) => (typeof rec[k] === "string" ? (rec[k] as string) : "");
35
+ switch (col) {
36
+ case C_ARTIST:
37
+ return s("name") ? identKey(did, C_ARTIST, artistHash(s("name"))) : undefined;
38
+ case C_ALBUM:
39
+ return s("title") && s("artist") ? identKey(did, C_ALBUM, albumHash(s("title"), s("artist"))) : undefined;
40
+ case C_SONG:
41
+ return s("title") && s("artist") && s("album")
42
+ ? identKey(did, C_SONG, songHash(s("title"), s("artist"), s("album")))
43
+ : undefined;
44
+ case C_SCROBBLE: {
45
+ if (s("title") && s("artist") && s("album") && s("createdAt")) {
46
+ const secs = Math.floor(Date.parse(s("createdAt")) / 1000);
47
+ if (!Number.isNaN(secs)) return scrobbleKey(did, songHash(s("title"), s("artist"), s("album")), secs);
48
+ }
49
+ return undefined;
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * Local duplicate-prevention mirror of a user's repo, keyed by Rocksky's
57
+ * identity hashes, backed by an embedded LevelDB (classic-level). Built from the
58
+ * repo CAR by {@link Agent.syncRepo} and kept live by {@link Agent.hydrateFromJetstream}.
59
+ */
60
+ export class RockskyIndex {
61
+ private db: ClassicLevel<string, string>;
62
+
63
+ constructor(path: string) {
64
+ this.db = new ClassicLevel<string, string>(path, { keyEncoding: "utf8", valueEncoding: "utf8" });
65
+ }
66
+
67
+ /** Open the database (call before use). */
68
+ open(): Promise<void> {
69
+ return this.db.open();
70
+ }
71
+ /** Close the database. */
72
+ close(): Promise<void> {
73
+ return this.db.close();
74
+ }
75
+
76
+ private async get(key: string): Promise<string | undefined> {
77
+ try {
78
+ return await this.db.get(key);
79
+ } catch {
80
+ return undefined; // NotFound
81
+ }
82
+ }
83
+
84
+ songUri(did: string, title: string, artist: string, album: string): Promise<string | undefined> {
85
+ return this.get(identKey(did, C_SONG, songHash(title, artist, album)));
86
+ }
87
+ albumUri(did: string, album: string, albumArtist: string): Promise<string | undefined> {
88
+ return this.get(identKey(did, C_ALBUM, albumHash(album, albumArtist)));
89
+ }
90
+ artistUri(did: string, albumArtist: string): Promise<string | undefined> {
91
+ return this.get(identKey(did, C_ARTIST, artistHash(albumArtist)));
92
+ }
93
+ scrobbleUri(did: string, title: string, artist: string, album: string, secs: number): Promise<string | undefined> {
94
+ return this.get(scrobbleKey(did, songHash(title, artist, album), secs));
95
+ }
96
+
97
+ private async putPrimary(did: string, col: string, primary: string, uri: string): Promise<void> {
98
+ const rkey = uri.slice(uri.lastIndexOf("/") + 1);
99
+ await this.db.batch([
100
+ { type: "put", key: primary, value: uri },
101
+ { type: "put", key: `${SEP}rk${SEP}${did}${SEP}${col}${SEP}${rkey}`, value: primary },
102
+ ]);
103
+ }
104
+ recordSong(did: string, title: string, artist: string, album: string, uri: string): Promise<void> {
105
+ return this.putPrimary(did, C_SONG, identKey(did, C_SONG, songHash(title, artist, album)), uri);
106
+ }
107
+ recordAlbum(did: string, album: string, albumArtist: string, uri: string): Promise<void> {
108
+ return this.putPrimary(did, C_ALBUM, identKey(did, C_ALBUM, albumHash(album, albumArtist)), uri);
109
+ }
110
+ recordArtist(did: string, albumArtist: string, uri: string): Promise<void> {
111
+ return this.putPrimary(did, C_ARTIST, identKey(did, C_ARTIST, artistHash(albumArtist)), uri);
112
+ }
113
+ recordScrobble(did: string, title: string, artist: string, album: string, secs: number, uri: string): Promise<void> {
114
+ return this.putPrimary(did, C_SCROBBLE, scrobbleKey(did, songHash(title, artist, album), secs), uri);
115
+ }
116
+
117
+ cursor(did: string): Promise<number> {
118
+ return this.get(`${SEP}meta${SEP}cursor${SEP}${did}`).then((v) => (v ? Number(v) : 0));
119
+ }
120
+ async setCursor(did: string, timeUS: number): Promise<void> {
121
+ await this.db.put(`${SEP}meta${SEP}cursor${SEP}${did}`, String(timeUS));
122
+ }
123
+
124
+ /** Ingest a full repo CAR for `did`, indexing song/album/artist/scrobble. */
125
+ async indexCar(did: string, car: Uint8Array): Promise<IndexStats> {
126
+ const reader = fromUint8Array(car);
127
+ const blocks = new Map<string, Uint8Array>();
128
+ for (const entry of reader) blocks.set(cidToString(entry.cid), entry.bytes);
129
+
130
+ const root = reader.roots[0];
131
+ if (!root) throw new Error("CAR has no root");
132
+ const commit = cborDecode(blocks.get(root.$link)!) as { data: { $link: string }; rev?: string };
133
+
134
+ const stats: IndexStats = { artists: 0, albums: 0, songs: 0, scrobbles: 0 };
135
+ const ops: { type: "put"; key: string; value: string }[] = [];
136
+
137
+ for (const [path, valueLink] of walkMst(blocks, commit.data.$link)) {
138
+ const slash = path.indexOf("/");
139
+ if (slash < 0) continue;
140
+ const col = path.slice(0, slash);
141
+ const rkey = path.slice(slash + 1);
142
+ if (col !== C_ARTIST && col !== C_ALBUM && col !== C_SONG && col !== C_SCROBBLE) continue;
143
+ const recBytes = blocks.get(valueLink);
144
+ if (!recBytes) continue;
145
+ const rec = cborDecode(recBytes) as Record<string, unknown>;
146
+ const primary = primaryFor(did, col, rec);
147
+ if (!primary) continue;
148
+ const uri = `at://${did}/${col}/${rkey}`;
149
+ ops.push({ type: "put", key: primary, value: uri });
150
+ ops.push({ type: "put", key: `${SEP}rk${SEP}${did}${SEP}${col}${SEP}${rkey}`, value: primary });
151
+ if (col === C_ARTIST) stats.artists++;
152
+ else if (col === C_ALBUM) stats.albums++;
153
+ else if (col === C_SONG) stats.songs++;
154
+ else stats.scrobbles++;
155
+ }
156
+ if (commit.rev) ops.push({ type: "put", key: `${SEP}meta${SEP}rev${SEP}${did}`, value: commit.rev });
157
+ await this.db.batch(ops);
158
+ return stats;
159
+ }
160
+
161
+ /** Apply a single Jetstream commit event to the index. */
162
+ async applyCommit(
163
+ did: string,
164
+ col: string,
165
+ operation: string,
166
+ rkey: string,
167
+ record: Record<string, unknown> | undefined,
168
+ ): Promise<void> {
169
+ if (operation === "create" || operation === "update") {
170
+ if (!record) return;
171
+ const primary = primaryFor(did, col, record);
172
+ if (!primary) return;
173
+ await this.putPrimary(did, col, primary, `at://${did}/${col}/${rkey}`);
174
+ } else if (operation === "delete") {
175
+ const rk = `${SEP}rk${SEP}${did}${SEP}${col}${SEP}${rkey}`;
176
+ const primary = await this.get(rk);
177
+ if (primary) {
178
+ await this.db.batch([
179
+ { type: "del", key: primary },
180
+ { type: "del", key: rk },
181
+ ]);
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // In-order MST traversal, reconstructing each leaf's full key. Yields
188
+ // [path, recordCidString]. Skips CIDs absent from `blocks`.
189
+ function* walkMst(blocks: Map<string, Uint8Array>, cid: string): Generator<[string, string]> {
190
+ const raw = blocks.get(cid);
191
+ if (!raw) return;
192
+ const node = cborDecode(raw) as {
193
+ l?: { $link: string } | null;
194
+ e: { p: number; k: unknown; v: { $link: string }; t?: { $link: string } | null }[];
195
+ };
196
+ if (node.l && isCidLink(node.l)) yield* walkMst(blocks, node.l.$link);
197
+ let last = new Uint8Array(0);
198
+ for (const e of node.e) {
199
+ const suffix = isBytes(e.k) ? fromBytes(e.k) : (e.k as Uint8Array);
200
+ const key = new Uint8Array(e.p + suffix.length);
201
+ key.set(last.subarray(0, e.p), 0);
202
+ key.set(suffix, e.p);
203
+ yield [new TextDecoder().decode(key), e.v.$link];
204
+ last = key;
205
+ if (e.t && isCidLink(e.t)) yield* walkMst(blocks, e.t.$link);
206
+ }
207
+ }
package/src/errors.ts CHANGED
@@ -1,47 +1,10 @@
1
+ /** Error thrown when a Rocksky XRPC call returns a non-2xx `{ error, message }`. */
1
2
  export class RockskyError extends Error {
2
- readonly cause?: unknown;
3
- constructor(message: string, options?: { cause?: unknown }) {
4
- super(message);
3
+ readonly kind?: string;
4
+ constructor(payload: unknown) {
5
+ const p = payload as { error?: string; message?: string } | undefined;
6
+ super(p?.message || p?.error || "rocksky request failed");
5
7
  this.name = "RockskyError";
6
- this.cause = options?.cause;
7
- }
8
- }
9
-
10
- export class RockskyHttpError extends RockskyError {
11
- readonly status: number;
12
- readonly statusText: string;
13
- readonly url: string;
14
- readonly body: unknown;
15
-
16
- constructor(args: {
17
- status: number;
18
- statusText: string;
19
- url: string;
20
- body: unknown;
21
- message?: string;
22
- }) {
23
- const message =
24
- args.message ??
25
- `Rocksky API ${args.status} ${args.statusText} at ${args.url}`;
26
- super(message);
27
- this.name = "RockskyHttpError";
28
- this.status = args.status;
29
- this.statusText = args.statusText;
30
- this.url = args.url;
31
- this.body = args.body;
32
- }
33
- }
34
-
35
- export class RockskyTimeoutError extends RockskyError {
36
- constructor(ms: number) {
37
- super(`Rocksky request timed out after ${ms}ms`);
38
- this.name = "RockskyTimeoutError";
39
- }
40
- }
41
-
42
- export class RockskyAuthError extends RockskyError {
43
- constructor(message = "Authentication required") {
44
- super(message);
45
- this.name = "RockskyAuthError";
8
+ this.kind = p?.error;
46
9
  }
47
10
  }
@@ -874,6 +874,11 @@ export interface GetArtistTracksParams {
874
874
  offset?: number;
875
875
  }
876
876
 
877
+ export interface GetAudioSettingsParams {
878
+ /** DID or handle of the user whose settings to fetch. Required for unauthenticated requests. */
879
+ did?: AtIdentifier;
880
+ }
881
+
877
882
  export interface GetCurrentlyPlayingParams {
878
883
  playerId?: string;
879
884
  /** Handle or DID of the actor to retrieve the currently playing track for. If not provided, defaults to the current user. */
@@ -1412,6 +1417,17 @@ export interface ProfileRecord {
1412
1417
  createdAt?: DateTime;
1413
1418
  }
1414
1419
 
1420
+ export interface PutAudioSettingsInput {
1421
+ /** Crossfade settings to apply. */
1422
+ crossfade?: RockboxCrossfadeSettings;
1423
+ /** Equalizer settings to apply. */
1424
+ equalizer?: RockboxEqualizerSettings;
1425
+ /** Replay gain settings to apply. */
1426
+ replayGain?: RockboxReplayGainSettings;
1427
+ /** Tone control settings to apply. */
1428
+ tone?: RockboxToneSettings;
1429
+ }
1430
+
1415
1431
  export interface PutMirrorSourceInput {
1416
1432
  /** One of: lastfm, listenbrainz, tealfm */
1417
1433
  provider: string;
@@ -1506,6 +1522,74 @@ export interface ReportShoutInput {
1506
1522
  reason?: string;
1507
1523
  }
1508
1524
 
1525
+ export interface RockboxCrossfadeSettings {
1526
+ /** Crossfade mode: disabled | enabled | shuffle | albumChange | trackChange */
1527
+ mode?: string;
1528
+ /** Fade-in delay in ms */
1529
+ fadeInDelay?: number;
1530
+ /** Fade-in duration in ms */
1531
+ fadeInDuration?: number;
1532
+ /** Fade-out delay in ms */
1533
+ fadeOutDelay?: number;
1534
+ /** Fade-out duration in ms */
1535
+ fadeOutDuration?: number;
1536
+ /** Fade-out mix mode: crossfade | mix */
1537
+ fadeOutMixMode?: string;
1538
+ }
1539
+
1540
+ export interface RockboxEqualizerBand {
1541
+ /** Center frequency in Hz */
1542
+ frequency: number;
1543
+ /** Band gain in tenths of dB (e.g. 30 = +3.0 dB) */
1544
+ gain: number;
1545
+ /** Q factor × 10 (e.g. 7 = Q 0.7) */
1546
+ q: number;
1547
+ }
1548
+
1549
+ export interface RockboxEqualizerSettings {
1550
+ /** Whether the equalizer is enabled */
1551
+ enabled?: boolean;
1552
+ /** Pre-amplification cut in tenths of dB applied before EQ bands (e.g. -60 = -6.0 dB) */
1553
+ precut?: number;
1554
+ /** Up to 10 EQ bands */
1555
+ bands?: RockboxEqualizerBand[];
1556
+ }
1557
+
1558
+ export interface RockboxReplayGainSettings {
1559
+ /** Replay gain mode: disabled | track | album | trackIfShuffling */
1560
+ mode?: string;
1561
+ /** Pre-amplification in tenths of dB (e.g. 15 = +1.5 dB) */
1562
+ preamp?: number;
1563
+ /** Whether to prevent clipping by reducing volume */
1564
+ preventClipping?: boolean;
1565
+ }
1566
+
1567
+ export interface RockboxSettingsView {
1568
+ /** Crossfade settings */
1569
+ crossfade?: RockboxCrossfadeSettings;
1570
+ /** Equalizer settings */
1571
+ equalizer?: RockboxEqualizerSettings;
1572
+ /** Replay gain settings */
1573
+ replayGain?: RockboxReplayGainSettings;
1574
+ /** Tone control settings (bass, treble, balance, channels) */
1575
+ tone?: RockboxToneSettings;
1576
+ /** When this settings record was first created. */
1577
+ createdAt: DateTime;
1578
+ /** When this settings record was last updated. */
1579
+ updatedAt?: DateTime;
1580
+ }
1581
+
1582
+ export interface RockboxToneSettings {
1583
+ /** Bass level in dB */
1584
+ bass?: number;
1585
+ /** Treble level in dB */
1586
+ treble?: number;
1587
+ /** Left/right balance. Negative = left, positive = right */
1588
+ balance?: number;
1589
+ /** Channel configuration: stereo | mono | monoLeft | monoRight | karaoke | wide */
1590
+ channels?: string;
1591
+ }
1592
+
1509
1593
  export interface ScrobbleFirstScrobbleView {
1510
1594
  /** The handle of the user who first scrobbled this song. */
1511
1595
  handle?: string;
@@ -1573,26 +1657,32 @@ export interface ScrobbleRecord {
1573
1657
  export interface ScrobbleViewBasic {
1574
1658
  /** The unique identifier of the scrobble. */
1575
1659
  id?: string;
1576
- /** The handle of the user who created the scrobble. */
1577
- user?: string;
1578
- /** The display name of the user who created the scrobble. */
1579
- userDisplayName?: string;
1580
- /** The avatar URL of the user who created the scrobble. */
1581
- userAvatar?: Uri;
1660
+ /** The unique identifier of the track this scrobble is of. */
1661
+ trackId?: string;
1582
1662
  /** The title of the scrobble. */
1583
1663
  title?: string;
1584
1664
  /** The artist of the song. */
1585
1665
  artist?: string;
1586
1666
  /** The URI of the artist. */
1587
1667
  artistUri?: AtUri;
1668
+ /** The album artist of the song. */
1669
+ albumArtist?: string;
1588
1670
  /** The album of the song. */
1589
1671
  album?: string;
1590
1672
  /** The URI of the album. */
1591
1673
  albumUri?: AtUri;
1592
1674
  /** The album art URL of the song. */
1593
- cover?: Uri;
1675
+ albumArt?: Uri;
1676
+ /** The URI of the track (song) this scrobble is of. */
1677
+ trackUri?: AtUri;
1678
+ /** The handle of the user who created the scrobble. */
1679
+ handle?: string;
1680
+ /** The DID of the user who created the scrobble. */
1681
+ did?: AtIdentifier;
1682
+ /** The avatar URL of the user who created the scrobble. */
1683
+ avatar?: Uri;
1594
1684
  /** The timestamp when the scrobble was created. */
1595
- date?: DateTime;
1685
+ createdAt?: DateTime;
1596
1686
  /** The URI of the scrobble. */
1597
1687
  uri?: Uri;
1598
1688
  /** The SHA256 hash of the scrobble data. */
@@ -1646,6 +1736,21 @@ export interface SeekParams {
1646
1736
  position: number;
1647
1737
  }
1648
1738
 
1739
+ export interface SettingsRecord {
1740
+ /** Crossfade settings */
1741
+ crossfade?: RockboxCrossfadeSettings;
1742
+ /** Equalizer settings */
1743
+ equalizer?: RockboxEqualizerSettings;
1744
+ /** Replay gain settings */
1745
+ replayGain?: RockboxReplayGainSettings;
1746
+ /** Tone control settings (bass, treble, balance, channels) */
1747
+ tone?: RockboxToneSettings;
1748
+ /** When this settings record was first created. */
1749
+ createdAt: DateTime;
1750
+ /** When this settings record was last updated. */
1751
+ updatedAt?: DateTime;
1752
+ }
1753
+
1649
1754
  export interface ShoutAuthor {
1650
1755
  /** The unique identifier of the author. */
1651
1756
  id?: string;
@@ -2118,6 +2223,8 @@ export interface Endpoints {
2118
2223
  "app.rocksky.playlist.removePlaylist": void;
2119
2224
  "app.rocksky.playlist.removeTrack": void;
2120
2225
  "app.rocksky.playlist.startPlaylist": void;
2226
+ "app.rocksky.rockbox.getAudioSettings": RockboxSettingsView;
2227
+ "app.rocksky.rockbox.putAudioSettings": RockboxSettingsView;
2121
2228
  "app.rocksky.scrobble.createScrobble": ScrobbleViewBasic;
2122
2229
  "app.rocksky.scrobble.getScrobble": ScrobbleViewDetailed;
2123
2230
  "app.rocksky.scrobble.getScrobbles": GetScrobblesOutput;
package/src/hash.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ // Lowercase-hex SHA-256 of s.toLowerCase() — the lowercasing is applied to the
4
+ // whole string (not per field), matching Rocksky's server and every other SDK.
5
+ function sha256Lower(s: string): string {
6
+ return createHash("sha256").update(s.toLowerCase()).digest("hex");
7
+ }
8
+
9
+ /** Identity hash of a song: sha256(lower("{title} - {artist} - {album}")). */
10
+ export function songHash(title: string, artist: string, album: string): string {
11
+ return sha256Lower(`${title} - ${artist} - ${album}`);
12
+ }
13
+
14
+ /** Identity hash of an album: sha256(lower("{album} - {albumArtist}")). */
15
+ export function albumHash(album: string, albumArtist: string): string {
16
+ return sha256Lower(`${album} - ${albumArtist}`);
17
+ }
18
+
19
+ /** Identity hash of an artist: sha256(lower(albumArtist)) — a single field. */
20
+ export function artistHash(albumArtist: string): string {
21
+ return sha256Lower(albumArtist);
22
+ }
package/src/index.ts CHANGED
@@ -1,87 +1,18 @@
1
- export {
2
- RockskyClient,
3
- RockskyClientBuilder,
4
- createClient,
5
- } from "./client.js";
6
- export {
7
- RockskyError,
8
- RockskyHttpError,
9
- RockskyTimeoutError,
10
- RockskyAuthError,
11
- } from "./errors.js";
12
- export {
13
- pipe,
14
- map,
15
- tap,
16
- withRetry,
17
- withTimeout,
18
- withFallback,
19
- catchError,
20
- type Op,
21
- } from "./pipe.js";
22
- export {
23
- DEFAULT_BASE_URL,
24
- type AuthProvider,
25
- type ClientOptions,
26
- type FetchLike,
27
- type Json,
28
- type Pagination,
29
- type RequestOptions,
30
- } from "./types.js";
31
- export {
32
- paginate,
33
- type PageOpts,
34
- type PageResult,
35
- type PaginateArgs,
36
- } from "./paginate.js";
37
- export {
38
- RealtimeClient,
39
- RealtimeClientBuilder,
40
- createRealtimeClient,
41
- type RealtimeOptions,
42
- type RealtimeEvent,
43
- type RealtimeEventMap,
44
- type ReconnectOptions,
45
- type WebSocketCtor,
46
- type WebSocketLike,
47
- } from "./realtime.js";
48
-
1
+ /**
2
+ * @rocksky/sdk — the official TypeScript SDK for Rocksky, built on atcute.
3
+ *
4
+ * {@link RockskyClient} does unauthenticated AppView reads; {@link Agent} logs
5
+ * in with an app password and writes app.rocksky.* records to the user's PDS
6
+ * (scrobble, like, follow, shout, now-playing). With a {@link RockskyIndex}
7
+ * attached it prevents duplicates, backfilled from the repo CAR
8
+ * ({@link Agent.syncRepo}) and kept live off the Jetstream firehose
9
+ * ({@link Agent.hydrateFromJetstream}).
10
+ */
11
+ export { RockskyClient, DEFAULT_APPVIEW, Interval } from "./client.js";
12
+ export type { DateInterval } from "./client.js";
13
+ export { Agent, type ScrobbleInput, type SongInput, type AlbumInput, type ArtistInput } from "./agent.js";
14
+ export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
15
+ export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
16
+ export { songHash, albumHash, artistHash } from "./hash.js";
17
+ export { RockskyError } from "./errors.js";
49
18
  export type * from "./generated/types.js";
50
-
51
- export type {
52
- GetProfileParams,
53
- ActorPagedParams,
54
- ActorRangeParams,
55
- } from "./namespaces/actor.js";
56
- export type {
57
- CreateScrobbleInput,
58
- GetScrobblesParams,
59
- } from "./namespaces/scrobble.js";
60
- export type {
61
- GetSongParams,
62
- GetSongsParams,
63
- MatchSongParams,
64
- CreateSongInput,
65
- } from "./namespaces/song.js";
66
- export type { GetAlbumsParams } from "./namespaces/album.js";
67
- export type {
68
- GetArtistsParams,
69
- ArtistListenersParams,
70
- GetArtistTracksParams,
71
- } from "./namespaces/artist.js";
72
- export type {
73
- ListApikeysParams,
74
- CreateApikeyInput,
75
- UpdateApikeyInput,
76
- } from "./namespaces/apikey.js";
77
- export type {
78
- ScrobblesChartParams,
79
- TopChartParams,
80
- } from "./namespaces/charts.js";
81
- export type {
82
- FollowListParams,
83
- KnownFollowersParams,
84
- } from "./namespaces/graph.js";
85
- export type { RecommendParams } from "./namespaces/feed.js";
86
- export type { LikeInput } from "./namespaces/like.js";
87
- export type { PutMirrorSourceInput } from "./namespaces/mirror.js";
@@ -0,0 +1,122 @@
1
+ import type { RockskyIndex } from "./dedup.js";
2
+
3
+ /** The four public Bluesky Jetstream servers. */
4
+ export const DEFAULT_JETSTREAM_SERVERS = [
5
+ "wss://jetstream1.us-east.bsky.network",
6
+ "wss://jetstream2.us-east.bsky.network",
7
+ "wss://jetstream1.us-west.bsky.network",
8
+ "wss://jetstream2.us-west.bsky.network",
9
+ ];
10
+
11
+ const RECONNECT_SLACK_US = 5_000_000;
12
+
13
+ export interface JetstreamOptions {
14
+ /** Servers to connect to at once (defaults to {@link DEFAULT_JETSTREAM_SERVERS}). */
15
+ servers?: string[];
16
+ /** Cancels the hydration and closes all connections when aborted. */
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ interface JetEvent {
21
+ did: string;
22
+ time_us: number;
23
+ kind: string;
24
+ commit?: {
25
+ operation: string;
26
+ collection: string;
27
+ rkey: string;
28
+ record?: Record<string, unknown>;
29
+ };
30
+ }
31
+
32
+ // A shared, mutable watermark — the highest time_us processed across sources.
33
+ interface Watermark {
34
+ v: number;
35
+ }
36
+
37
+ /**
38
+ * Hydrate `idx` from the Bluesky Jetstream firehose for `did`, connecting to
39
+ * every server at once, filtered to app.rocksky.* + this DID. A shared watermark
40
+ * de-duplicates the overlap between servers and is the reconnect cursor. Resolves
41
+ * when opts.signal aborts; each source reconnects with backoff.
42
+ */
43
+ export async function runJetstream(idx: RockskyIndex, did: string, opts: JetstreamOptions = {}): Promise<void> {
44
+ const servers = opts.servers ?? DEFAULT_JETSTREAM_SERVERS;
45
+ const wm: Watermark = { v: await idx.cursor(did) };
46
+ await Promise.all(servers.map((s) => sourceLoop(s, idx, did, wm, opts.signal)));
47
+ }
48
+
49
+ async function sourceLoop(server: string, idx: RockskyIndex, did: string, wm: Watermark, signal?: AbortSignal) {
50
+ while (!signal?.aborted) {
51
+ const cursor = Math.max(0, wm.v - RECONNECT_SLACK_US);
52
+ try {
53
+ await connect(subscribeURL(server, did, cursor), idx, did, wm, signal);
54
+ } catch {
55
+ // fall through to backoff
56
+ }
57
+ await sleep(2000, signal);
58
+ }
59
+ }
60
+
61
+ function connect(url: string, idx: RockskyIndex, did: string, wm: Watermark, signal?: AbortSignal): Promise<void> {
62
+ return new Promise<void>((resolve) => {
63
+ const ws = new WebSocket(url);
64
+ const onAbort = () => {
65
+ try {
66
+ ws.close();
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ };
71
+ signal?.addEventListener("abort", onAbort, { once: true });
72
+
73
+ const done = () => {
74
+ signal?.removeEventListener("abort", onAbort);
75
+ resolve();
76
+ };
77
+
78
+ ws.onmessage = (ev: MessageEvent) => {
79
+ let event: JetEvent;
80
+ try {
81
+ event = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
82
+ } catch {
83
+ return;
84
+ }
85
+ if (event.kind !== "commit" || event.did !== did || !event.commit) return;
86
+ // Claim this event for exactly one source (single-threaded: set before await).
87
+ if (event.time_us <= wm.v) return;
88
+ wm.v = event.time_us;
89
+ const c = event.commit;
90
+ void idx
91
+ .applyCommit(event.did, c.collection, c.operation, c.rkey, c.record)
92
+ .then(() => idx.setCursor(did, event.time_us))
93
+ .catch(() => {});
94
+ };
95
+ ws.onclose = done;
96
+ ws.onerror = () => {
97
+ try {
98
+ ws.close();
99
+ } catch {
100
+ /* ignore */
101
+ }
102
+ done();
103
+ };
104
+ });
105
+ }
106
+
107
+ function subscribeURL(server: string, did: string, cursorUS: number): string {
108
+ let u = `${server.replace(/\/+$/, "")}/subscribe?wantedCollections=app.rocksky.*&wantedDids=${encodeURIComponent(did)}`;
109
+ if (cursorUS > 0) u += `&cursor=${cursorUS}`;
110
+ return u;
111
+ }
112
+
113
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
114
+ return new Promise((resolve) => {
115
+ if (signal?.aborted) return resolve();
116
+ const t = setTimeout(resolve, ms);
117
+ signal?.addEventListener("abort", () => {
118
+ clearTimeout(t);
119
+ resolve();
120
+ }, { once: true });
121
+ });
122
+ }