@rocksky/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +17 -11
  2. package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
  3. package/dist/drizzle/0001_awesome_gabe_jones.sql +1 -0
  4. package/dist/drizzle/meta/0000_snapshot.json +1559 -0
  5. package/dist/drizzle/meta/0001_snapshot.json +1569 -0
  6. package/dist/drizzle/meta/_journal.json +20 -0
  7. package/dist/index.js +8509 -254
  8. package/drizzle/0000_parallel_paper_doll.sql +220 -0
  9. package/drizzle/0001_awesome_gabe_jones.sql +1 -0
  10. package/drizzle/meta/0000_snapshot.json +1559 -0
  11. package/drizzle/meta/0001_snapshot.json +1569 -0
  12. package/drizzle/meta/_journal.json +20 -0
  13. package/drizzle.config.ts +18 -0
  14. package/package.json +35 -3
  15. package/src/client.ts +32 -14
  16. package/src/cmd/scrobble-api.ts +465 -0
  17. package/src/cmd/scrobble.ts +14 -61
  18. package/src/cmd/search.ts +27 -25
  19. package/src/cmd/sync.ts +1085 -0
  20. package/src/cmd/whoami.ts +36 -7
  21. package/src/context.ts +24 -0
  22. package/src/drizzle.ts +53 -0
  23. package/src/index.ts +66 -26
  24. package/src/jetstream.ts +285 -0
  25. package/src/lexicon/index.ts +1321 -0
  26. package/src/lexicon/lexicons.ts +5453 -0
  27. package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
  28. package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
  29. package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
  30. package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
  31. package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
  32. package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
  33. package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
  34. package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
  35. package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
  36. package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
  37. package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
  38. package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
  39. package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
  40. package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
  41. package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
  42. package/src/lexicon/types/app/rocksky/album.ts +51 -0
  43. package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
  44. package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
  45. package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
  46. package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
  47. package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
  48. package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
  49. package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
  50. package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
  51. package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
  52. package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
  53. package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
  54. package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
  55. package/src/lexicon/types/app/rocksky/artist.ts +41 -0
  56. package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
  57. package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
  58. package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
  59. package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
  60. package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
  61. package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
  62. package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
  63. package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
  64. package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
  65. package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
  66. package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
  67. package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
  68. package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
  69. package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
  70. package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
  71. package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
  72. package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
  73. package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
  74. package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
  75. package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
  76. package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
  77. package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
  78. package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
  79. package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
  80. package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
  81. package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
  82. package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
  83. package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
  84. package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
  85. package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
  86. package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
  87. package/src/lexicon/types/app/rocksky/like.ts +27 -0
  88. package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
  89. package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
  90. package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
  91. package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
  92. package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
  93. package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
  94. package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
  95. package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
  96. package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
  97. package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
  98. package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
  99. package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
  100. package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
  101. package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
  102. package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
  103. package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
  104. package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
  105. package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
  106. package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
  107. package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
  108. package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
  109. package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
  110. package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
  111. package/src/lexicon/types/app/rocksky/radio.ts +37 -0
  112. package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
  113. package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
  114. package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
  115. package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
  116. package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
  117. package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
  118. package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
  119. package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
  120. package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
  121. package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
  122. package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
  123. package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
  124. package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
  125. package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
  126. package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
  127. package/src/lexicon/types/app/rocksky/shout.ts +30 -0
  128. package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
  129. package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
  130. package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
  131. package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
  132. package/src/lexicon/types/app/rocksky/song.ts +74 -0
  133. package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
  134. package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
  135. package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
  136. package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
  137. package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
  138. package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
  139. package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
  140. package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
  141. package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
  142. package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
  143. package/src/lexicon/util.ts +13 -0
  144. package/src/lib/agent.ts +56 -0
  145. package/src/lib/cleanUpJetstreamLock.ts +66 -0
  146. package/src/lib/cleanUpSyncLock.ts +56 -0
  147. package/src/lib/didUnstorageCache.ts +72 -0
  148. package/src/lib/env.ts +25 -0
  149. package/src/lib/extractPdsFromDid.ts +33 -0
  150. package/src/lib/getDidAndHandle.ts +39 -0
  151. package/src/lib/idResolver.ts +52 -0
  152. package/src/lib/lastfm.ts +26 -0
  153. package/src/lib/matchTrack.ts +47 -0
  154. package/src/logger.ts +18 -0
  155. package/src/schema/album-tracks.ts +30 -0
  156. package/src/schema/albums.ts +29 -0
  157. package/src/schema/artist-albums.ts +29 -0
  158. package/src/schema/artist-genres.ts +17 -0
  159. package/src/schema/artist-tracks.ts +29 -0
  160. package/src/schema/artists.ts +30 -0
  161. package/src/schema/auth-session.ts +18 -0
  162. package/src/schema/genres.ts +18 -0
  163. package/src/schema/index.ts +33 -0
  164. package/src/schema/loved-tracks.ts +27 -0
  165. package/src/schema/scrobbles.ts +30 -0
  166. package/src/schema/tracks.ts +47 -0
  167. package/src/schema/user-albums.ts +31 -0
  168. package/src/schema/user-artists.ts +32 -0
  169. package/src/schema/user-tracks.ts +31 -0
  170. package/src/schema/users.ts +21 -0
  171. package/src/scrobble.ts +410 -0
  172. package/src/sqliteKv.ts +173 -0
  173. package/src/types.ts +308 -0
  174. package/tsconfig.json +26 -29
@@ -0,0 +1,1085 @@
1
+ import { JetStreamClient, JetStreamEvent } from "jetstream";
2
+ import { logger } from "logger";
3
+ import { ctx } from "context";
4
+ import { Agent } from "@atproto/api";
5
+ import { env } from "lib/env";
6
+ import { createAgent } from "lib/agent";
7
+ import chalk from "chalk";
8
+ import * as Artist from "lexicon/types/app/rocksky/artist";
9
+ import * as Album from "lexicon/types/app/rocksky/album";
10
+ import * as Song from "lexicon/types/app/rocksky/song";
11
+ import * as Scrobble from "lexicon/types/app/rocksky/scrobble";
12
+ import { SelectUser } from "schema/users";
13
+ import schema from "schema";
14
+ import { createId } from "@paralleldrive/cuid2";
15
+ import _ from "lodash";
16
+ import { and, eq, or } from "drizzle-orm";
17
+ import { indexBy } from "ramda";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { getDidAndHandle } from "lib/getDidAndHandle";
22
+ import { cleanUpJetstreamLockOnExit } from "lib/cleanUpJetstreamLock";
23
+ import { cleanUpSyncLockOnExit } from "lib/cleanUpSyncLock";
24
+ import { CarReader } from "@ipld/car";
25
+ import * as cbor from "@ipld/dag-cbor";
26
+
27
+ type Artists = { value: Artist.Record; uri: string; cid: string }[];
28
+ type Albums = { value: Album.Record; uri: string; cid: string }[];
29
+ type Songs = { value: Song.Record; uri: string; cid: string }[];
30
+ type Scrobbles = { value: Scrobble.Record; uri: string; cid: string }[];
31
+
32
+ export async function sync() {
33
+ const [did, handle] = await getDidAndHandle();
34
+ const agent: Agent = await createAgent(did, handle);
35
+
36
+ const user = await createUser(agent, did, handle);
37
+ await subscribeToJetstream(user);
38
+
39
+ logger.info` DID: ${did}`;
40
+ logger.info` Handle: ${handle}`;
41
+
42
+ const [artists, albums, songs, scrobbles] = await Promise.all([
43
+ getRockskyUserArtists(agent),
44
+ getRockskyUserAlbums(agent),
45
+ getRockskyUserSongs(agent),
46
+ getRockskyUserScrobbles(agent),
47
+ ]);
48
+
49
+ logger.info` Artists: ${artists.length}`;
50
+ logger.info` Albums: ${albums.length}`;
51
+ logger.info` Songs: ${songs.length}`;
52
+ logger.info` Scrobbles: ${scrobbles.length}`;
53
+
54
+ const lockFilePath = path.join(os.tmpdir(), `rocksky-${did}.lock`);
55
+
56
+ if (await fs.promises.stat(lockFilePath).catch(() => false)) {
57
+ logger.error`Lock file already exists, if you want to force sync, delete the lock file ${lockFilePath}`;
58
+ process.exit(1);
59
+ }
60
+
61
+ await fs.promises.writeFile(lockFilePath, "");
62
+ cleanUpSyncLockOnExit(user.did);
63
+
64
+ await createArtists(artists, user);
65
+ await createAlbums(albums, user);
66
+ await createSongs(songs, user);
67
+ await createScrobbles(scrobbles, user);
68
+
69
+ await fs.promises.unlink(lockFilePath);
70
+ }
71
+
72
+ const getEndpoint = () => {
73
+ const endpoint = env.JETSTREAM_SERVER;
74
+
75
+ if (endpoint?.endsWith("/subscribe")) {
76
+ return endpoint;
77
+ }
78
+
79
+ return `${endpoint}/subscribe`;
80
+ };
81
+
82
+ export const createUser = async (
83
+ agent: Agent,
84
+ did: string,
85
+ handle: string,
86
+ ): Promise<SelectUser> => {
87
+ const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
88
+ repo: agent.assertDid,
89
+ collection: "app.bsky.actor.profile",
90
+ rkey: "self",
91
+ });
92
+
93
+ const displayName = _.get(profileRecord, "value.displayName") as
94
+ | string
95
+ | undefined;
96
+ const avatar = `https://cdn.bsky.app/img/avatar/plain/${did}/${_.get(profileRecord, "value.avatar.ref", "").toString()}@jpeg`;
97
+
98
+ const [user] = await ctx.db
99
+ .insert(schema.users)
100
+ .values({
101
+ id: createId(),
102
+ did,
103
+ handle,
104
+ displayName,
105
+ avatar,
106
+ })
107
+ .onConflictDoUpdate({
108
+ target: schema.users.did,
109
+ set: {
110
+ handle,
111
+ displayName,
112
+ avatar,
113
+ updatedAt: new Date(),
114
+ },
115
+ })
116
+ .returning()
117
+ .execute();
118
+
119
+ return user;
120
+ };
121
+
122
+ const createArtists = async (artists: Artists, user: SelectUser) => {
123
+ if (artists.length === 0) return;
124
+
125
+ const tags = artists.map((artist) => artist.value.tags || []);
126
+
127
+ // Batch genre inserts to avoid stack overflow
128
+ const uniqueTags = tags
129
+ .flat()
130
+ .filter((tag) => tag)
131
+ .map((tag) => ({
132
+ id: createId(),
133
+ name: tag,
134
+ }));
135
+
136
+ const BATCH_SIZE = 1000;
137
+ for (let i = 0; i < uniqueTags.length; i += BATCH_SIZE) {
138
+ const batch = uniqueTags.slice(i, i + BATCH_SIZE);
139
+ await ctx.db
140
+ .insert(schema.genres)
141
+ .values(batch)
142
+ .onConflictDoNothing({
143
+ target: schema.genres.name,
144
+ })
145
+ .execute();
146
+ }
147
+
148
+ const genres = await ctx.db.select().from(schema.genres).execute();
149
+
150
+ const genreMap = indexBy((genre) => genre.name, genres);
151
+
152
+ // Process artists in batches
153
+ let totalArtistsImported = 0;
154
+
155
+ for (let i = 0; i < artists.length; i += BATCH_SIZE) {
156
+ const batch = artists.slice(i, i + BATCH_SIZE);
157
+
158
+ ctx.db.transaction((tx) => {
159
+ const newArtists = tx
160
+ .insert(schema.artists)
161
+ .values(
162
+ batch.map((artist) => ({
163
+ id: createId(),
164
+ name: artist.value.name,
165
+ cid: artist.cid,
166
+ uri: artist.uri,
167
+ biography: artist.value.bio,
168
+ born: artist.value.born ? new Date(artist.value.born) : null,
169
+ bornIn: artist.value.bornIn,
170
+ died: artist.value.died ? new Date(artist.value.died) : null,
171
+ picture: artist.value.pictureUrl,
172
+ genres: artist.value.tags?.join(", "),
173
+ })),
174
+ )
175
+ .onConflictDoNothing({
176
+ target: schema.artists.cid,
177
+ })
178
+ .returning()
179
+ .all();
180
+
181
+ if (newArtists.length === 0) return;
182
+
183
+ const artistGenres = newArtists
184
+ .map(
185
+ (artist) =>
186
+ artist.genres
187
+ ?.split(", ")
188
+ .filter((tag) => !!tag && !!genreMap[tag])
189
+ .map((tag) => ({
190
+ id: createId(),
191
+ artistId: artist.id,
192
+ genreId: genreMap[tag].id,
193
+ })) || [],
194
+ )
195
+ .flat();
196
+
197
+ if (artistGenres.length > 0) {
198
+ tx.insert(schema.artistGenres)
199
+ .values(artistGenres)
200
+ .onConflictDoNothing({
201
+ target: [schema.artistGenres.artistId, schema.artistGenres.genreId],
202
+ })
203
+ .returning()
204
+ .run();
205
+ }
206
+
207
+ tx.insert(schema.userArtists)
208
+ .values(
209
+ newArtists.map((artist) => ({
210
+ id: createId(),
211
+ userId: user.id,
212
+ artistId: artist.id,
213
+ uri: artist.uri,
214
+ })),
215
+ )
216
+ .run();
217
+
218
+ totalArtistsImported += newArtists.length;
219
+ });
220
+ }
221
+
222
+ logger.info`👤 ${totalArtistsImported} Artists imported`;
223
+ };
224
+
225
+ const createAlbums = async (albums: Albums, user: SelectUser) => {
226
+ if (albums.length === 0) return;
227
+
228
+ const artists = await Promise.all(
229
+ albums.map(async (album) =>
230
+ ctx.db
231
+ .select()
232
+ .from(schema.artists)
233
+ .where(eq(schema.artists.name, album.value.artist))
234
+ .execute()
235
+ .then(([artist]) => artist),
236
+ ),
237
+ );
238
+
239
+ const validAlbumData = albums
240
+ .map((album, index) => ({ album, artist: artists[index] }))
241
+ .filter(({ artist }) => artist);
242
+
243
+ // Process albums in batches
244
+ const BATCH_SIZE = 1000;
245
+ let totalAlbumsImported = 0;
246
+
247
+ for (let i = 0; i < validAlbumData.length; i += BATCH_SIZE) {
248
+ const batch = validAlbumData.slice(i, i + BATCH_SIZE);
249
+
250
+ ctx.db.transaction((tx) => {
251
+ const newAlbums = tx
252
+ .insert(schema.albums)
253
+ .values(
254
+ batch.map(({ album, artist }) => ({
255
+ id: createId(),
256
+ cid: album.cid,
257
+ uri: album.uri,
258
+ title: album.value.title,
259
+ artist: album.value.artist,
260
+ releaseDate: album.value.releaseDate,
261
+ year: album.value.year,
262
+ albumArt: album.value.albumArtUrl,
263
+ artistUri: artist.uri,
264
+ appleMusicLink: album.value.appleMusicLink,
265
+ spotifyLink: album.value.spotifyLink,
266
+ tidalLink: album.value.tidalLink,
267
+ youtubeLink: album.value.youtubeLink,
268
+ })),
269
+ )
270
+ .onConflictDoNothing({
271
+ target: schema.albums.cid,
272
+ })
273
+ .returning()
274
+ .all();
275
+
276
+ if (newAlbums.length === 0) return;
277
+
278
+ tx.insert(schema.userAlbums)
279
+ .values(
280
+ newAlbums.map((album) => ({
281
+ id: createId(),
282
+ userId: user.id,
283
+ albumId: album.id,
284
+ uri: album.uri,
285
+ })),
286
+ )
287
+ .run();
288
+
289
+ totalAlbumsImported += newAlbums.length;
290
+ });
291
+ }
292
+
293
+ logger.info`💿 ${totalAlbumsImported} Albums imported`;
294
+ };
295
+
296
+ const createSongs = async (songs: Songs, user: SelectUser) => {
297
+ if (songs.length === 0) return;
298
+
299
+ const albums = await Promise.all(
300
+ songs.map((song) =>
301
+ ctx.db
302
+ .select()
303
+ .from(schema.albums)
304
+ .where(
305
+ and(
306
+ eq(schema.albums.artist, song.value.albumArtist),
307
+ eq(schema.albums.title, song.value.album),
308
+ ),
309
+ )
310
+ .execute()
311
+ .then((result) => result[0]),
312
+ ),
313
+ );
314
+
315
+ const artists = await Promise.all(
316
+ songs.map((song) =>
317
+ ctx.db
318
+ .select()
319
+ .from(schema.artists)
320
+ .where(eq(schema.artists.name, song.value.albumArtist))
321
+ .execute()
322
+ .then((result) => result[0]),
323
+ ),
324
+ );
325
+
326
+ const validSongData = songs
327
+ .map((song, index) => ({
328
+ song,
329
+ artist: artists[index],
330
+ album: albums[index],
331
+ }))
332
+ .filter(({ artist, album }) => artist && album);
333
+
334
+ // Process in batches to avoid stack overflow with large datasets
335
+ const BATCH_SIZE = 1000;
336
+ let totalTracksImported = 0;
337
+
338
+ for (let i = 0; i < validSongData.length; i += BATCH_SIZE) {
339
+ const batch = validSongData.slice(i, i + BATCH_SIZE);
340
+ const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
341
+ const totalBatches = Math.ceil(validSongData.length / BATCH_SIZE);
342
+
343
+ logger.info`▶️ Processing tracks batch ${batchNumber}/${totalBatches} (${Math.min(i + BATCH_SIZE, validSongData.length)}/${validSongData.length})`;
344
+
345
+ ctx.db.transaction((tx) => {
346
+ const tracks = tx
347
+ .insert(schema.tracks)
348
+ .values(
349
+ batch.map(({ song, artist, album }) => ({
350
+ id: createId(),
351
+ cid: song.cid,
352
+ uri: song.uri,
353
+ title: song.value.title,
354
+ artist: song.value.artist,
355
+ albumArtist: song.value.albumArtist,
356
+ albumArt: song.value.albumArtUrl,
357
+ album: song.value.album,
358
+ trackNumber: song.value.trackNumber,
359
+ duration: song.value.duration,
360
+ mbId: song.value.mbid,
361
+ youtubeLink: song.value.youtubeLink,
362
+ spotifyLink: song.value.spotifyLink,
363
+ appleMusicLink: song.value.appleMusicLink,
364
+ tidalLink: song.value.tidalLink,
365
+ discNumber: song.value.discNumber,
366
+ lyrics: song.value.lyrics,
367
+ composer: song.value.composer,
368
+ genre: song.value.genre,
369
+ label: song.value.label,
370
+ copyrightMessage: song.value.copyrightMessage,
371
+ albumUri: album.uri,
372
+ artistUri: artist.uri,
373
+ })),
374
+ )
375
+ .onConflictDoNothing()
376
+ .returning()
377
+ .all();
378
+
379
+ if (tracks.length === 0) return;
380
+
381
+ tx.insert(schema.albumTracks)
382
+ .values(
383
+ tracks.map((track, index) => ({
384
+ id: createId(),
385
+ albumId: batch[index].album.id,
386
+ trackId: track.id,
387
+ })),
388
+ )
389
+ .onConflictDoNothing({
390
+ target: [schema.albumTracks.albumId, schema.albumTracks.trackId],
391
+ })
392
+ .run();
393
+
394
+ tx.insert(schema.userTracks)
395
+ .values(
396
+ tracks.map((track) => ({
397
+ id: createId(),
398
+ userId: user.id,
399
+ trackId: track.id,
400
+ uri: track.uri,
401
+ })),
402
+ )
403
+ .onConflictDoNothing({
404
+ target: [schema.userTracks.userId, schema.userTracks.trackId],
405
+ })
406
+ .run();
407
+
408
+ totalTracksImported += tracks.length;
409
+ });
410
+ }
411
+
412
+ logger.info`▶️ ${totalTracksImported} Tracks imported`;
413
+ };
414
+
415
+ const createScrobbles = async (scrobbles: Scrobbles, user: SelectUser) => {
416
+ if (!scrobbles.length) return;
417
+
418
+ logger.info`Loading Scrobble Tracks ...`;
419
+
420
+ const tracks = await Promise.all(
421
+ scrobbles.map((scrobble) =>
422
+ ctx.db
423
+ .select()
424
+ .from(schema.tracks)
425
+ .where(
426
+ and(
427
+ eq(schema.tracks.title, scrobble.value.title),
428
+ eq(schema.tracks.artist, scrobble.value.artist),
429
+ eq(schema.tracks.album, scrobble.value.album),
430
+ eq(schema.tracks.albumArtist, scrobble.value.albumArtist),
431
+ ),
432
+ )
433
+ .execute()
434
+ .then(([track]) => track),
435
+ ),
436
+ );
437
+
438
+ logger.info`Loading Scrobble Albums ...`;
439
+
440
+ const albums = await Promise.all(
441
+ scrobbles.map((scrobble) =>
442
+ ctx.db
443
+ .select()
444
+ .from(schema.albums)
445
+ .where(
446
+ and(
447
+ eq(schema.albums.title, scrobble.value.album),
448
+ eq(schema.albums.artist, scrobble.value.albumArtist),
449
+ ),
450
+ )
451
+ .execute()
452
+ .then(([album]) => album),
453
+ ),
454
+ );
455
+
456
+ logger.info`Loading Scrobble Artists ...`;
457
+
458
+ const artists = await Promise.all(
459
+ scrobbles.map((scrobble) =>
460
+ ctx.db
461
+ .select()
462
+ .from(schema.artists)
463
+ .where(
464
+ or(
465
+ and(eq(schema.artists.name, scrobble.value.artist)),
466
+ and(eq(schema.artists.name, scrobble.value.albumArtist)),
467
+ ),
468
+ )
469
+ .execute()
470
+ .then(([artist]) => artist),
471
+ ),
472
+ );
473
+
474
+ const validScrobbleData = scrobbles
475
+ .map((scrobble, index) => ({
476
+ scrobble,
477
+ track: tracks[index],
478
+ album: albums[index],
479
+ artist: artists[index],
480
+ }))
481
+ .filter(({ track, album, artist }) => track && album && artist);
482
+
483
+ // Process in batches to avoid stack overflow with large datasets
484
+ const BATCH_SIZE = 1000;
485
+ let totalScrobblesImported = 0;
486
+
487
+ for (let i = 0; i < validScrobbleData.length; i += BATCH_SIZE) {
488
+ const batch = validScrobbleData.slice(i, i + BATCH_SIZE);
489
+ const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
490
+ const totalBatches = Math.ceil(validScrobbleData.length / BATCH_SIZE);
491
+
492
+ logger.info`🕒 Processing scrobbles batch ${batchNumber}/${totalBatches} (${Math.min(i + BATCH_SIZE, validScrobbleData.length)}/${validScrobbleData.length})`;
493
+
494
+ const result = await ctx.db
495
+ .insert(schema.scrobbles)
496
+ .values(
497
+ batch.map(({ scrobble, track, album, artist }) => ({
498
+ id: createId(),
499
+ userId: user.id,
500
+ trackId: track.id,
501
+ albumId: album.id,
502
+ artistId: artist.id,
503
+ uri: scrobble.uri,
504
+ cid: scrobble.cid,
505
+ timestamp: new Date(scrobble.value.createdAt),
506
+ })),
507
+ )
508
+ .onConflictDoNothing({
509
+ target: schema.scrobbles.cid,
510
+ })
511
+ .returning()
512
+ .execute();
513
+
514
+ totalScrobblesImported += result.length;
515
+ }
516
+
517
+ logger.info`🕒 ${totalScrobblesImported} scrobbles imported`;
518
+ };
519
+
520
+ export const subscribeToJetstream = (user: SelectUser): Promise<void> => {
521
+ const lockFile = path.join(os.tmpdir(), `rocksky-jetstream-${user.did}.lock`);
522
+ if (fs.existsSync(lockFile)) {
523
+ logger.warn`JetStream subscription already in progress for user ${user.did}`;
524
+ logger.warn`Skipping subscription`;
525
+ logger.warn`Lock file exists at ${lockFile}`;
526
+ return Promise.resolve();
527
+ }
528
+
529
+ fs.writeFileSync(lockFile, "");
530
+
531
+ const client = new JetStreamClient({
532
+ wantedCollections: [
533
+ "app.rocksky.scrobble",
534
+ "app.rocksky.artist",
535
+ "app.rocksky.album",
536
+ "app.rocksky.song",
537
+ ],
538
+ endpoint: getEndpoint(),
539
+ wantedDids: [user.did],
540
+
541
+ // Reconnection settings
542
+ maxReconnectAttempts: 10,
543
+ reconnectDelay: 1000,
544
+ maxReconnectDelay: 30000,
545
+ backoffMultiplier: 1.5,
546
+
547
+ // Enable debug logging
548
+ debug: true,
549
+ });
550
+
551
+ return new Promise((resolve, reject) => {
552
+ client.on("open", () => {
553
+ logger.info`✅ Connected to JetStream!`;
554
+ cleanUpJetstreamLockOnExit(user.did);
555
+ resolve();
556
+ });
557
+
558
+ client.on("message", async (data) => {
559
+ const event = data as JetStreamEvent;
560
+
561
+ if (event.kind === "commit" && event.commit) {
562
+ const { operation, collection, record, rkey, cid } = event.commit;
563
+ const uri = `at://${event.did}/${collection}/${rkey}`;
564
+
565
+ logger.info`\n📡 New event:`;
566
+ logger.info` Operation: ${operation}`;
567
+ logger.info` Collection: ${collection}`;
568
+ logger.info` DID: ${event.did}`;
569
+ logger.info` Uri: ${uri}`;
570
+
571
+ if (operation === "create" && record) {
572
+ console.log(JSON.stringify(record, null, 2));
573
+ await onNewCollection(record, cid, uri, user);
574
+ }
575
+
576
+ logger.info` Cursor: ${event.time_us}`;
577
+ }
578
+ });
579
+
580
+ client.on("error", (error) => {
581
+ logger.error`❌ Error: ${error}`;
582
+ cleanUpJetstreamLockOnExit(user.did);
583
+ reject(error);
584
+ });
585
+
586
+ client.on("reconnect", (data) => {
587
+ const { attempt } = data as { attempt: number };
588
+ logger.info`🔄 Reconnecting... (attempt ${attempt})`;
589
+ });
590
+
591
+ client.connect();
592
+ });
593
+ };
594
+
595
+ const onNewCollection = async (
596
+ record: any,
597
+ cid: string,
598
+ uri: string,
599
+ user: SelectUser,
600
+ ) => {
601
+ switch (record.$type) {
602
+ case "app.rocksky.song":
603
+ await onNewSong(record, cid, uri, user);
604
+ break;
605
+ case "app.rocksky.album":
606
+ await onNewAlbum(record, cid, uri, user);
607
+ break;
608
+ case "app.rocksky.artist":
609
+ await onNewArtist(record, cid, uri, user);
610
+ break;
611
+ case "app.rocksky.scrobble":
612
+ await onNewScrobble(record, cid, uri, user);
613
+ break;
614
+ default:
615
+ logger.warn`Unknown collection type: ${record.$type}`;
616
+ }
617
+ };
618
+
619
+ const onNewSong = async (
620
+ record: Song.Record,
621
+ cid: string,
622
+ uri: string,
623
+ user: SelectUser,
624
+ ) => {
625
+ const { title, artist, album } = record;
626
+ logger.info` New song: ${title} by ${artist} from ${album}`;
627
+ await createSongs(
628
+ [
629
+ {
630
+ cid,
631
+ uri,
632
+ value: record,
633
+ },
634
+ ],
635
+ user,
636
+ );
637
+ };
638
+
639
+ const onNewAlbum = async (
640
+ record: Album.Record,
641
+ cid: string,
642
+ uri: string,
643
+ user: SelectUser,
644
+ ) => {
645
+ const { title, artist } = record;
646
+ logger.info` New album: ${title} by ${artist}`;
647
+ await createAlbums(
648
+ [
649
+ {
650
+ cid,
651
+ uri,
652
+ value: record,
653
+ },
654
+ ],
655
+ user,
656
+ );
657
+ };
658
+
659
+ const onNewArtist = async (
660
+ record: Artist.Record,
661
+ cid: string,
662
+ uri: string,
663
+ user: SelectUser,
664
+ ) => {
665
+ const { name } = record;
666
+ logger.info` New artist: ${name}`;
667
+ await createArtists(
668
+ [
669
+ {
670
+ cid,
671
+ uri,
672
+ value: record,
673
+ },
674
+ ],
675
+ user,
676
+ );
677
+ };
678
+
679
+ const onNewScrobble = async (
680
+ record: Scrobble.Record,
681
+ cid: string,
682
+ uri: string,
683
+ user: SelectUser,
684
+ ) => {
685
+ const { title, createdAt, artist, album, albumArtist } = record;
686
+ logger.info` New scrobble: ${title} at ${createdAt}`;
687
+
688
+ // Check if the artist exists, create if not
689
+ let [artistRecord] = await ctx.db
690
+ .select()
691
+ .from(schema.artists)
692
+ .where(eq(schema.artists.name, record.albumArtist))
693
+ .execute();
694
+
695
+ if (!artistRecord) {
696
+ logger.info` ⚙️ Artist not found, creating: "${albumArtist}"`;
697
+
698
+ // Create a synthetic artist record from scrobble data
699
+ const artistUri = `at://${user.did}/app.rocksky.artist/${createId()}`;
700
+ const artistCid = createId();
701
+
702
+ await createArtists(
703
+ [
704
+ {
705
+ cid: artistCid,
706
+ uri: artistUri,
707
+ value: {
708
+ $type: "app.rocksky.artist",
709
+ name: record.albumArtist,
710
+ createdAt: new Date().toISOString(),
711
+ tags: record.tags || [],
712
+ } as Artist.Record,
713
+ },
714
+ ],
715
+ user,
716
+ );
717
+
718
+ [artistRecord] = await ctx.db
719
+ .select()
720
+ .from(schema.artists)
721
+ .where(eq(schema.artists.name, record.albumArtist))
722
+ .execute();
723
+
724
+ if (!artistRecord) {
725
+ logger.error` ❌ Failed to create artist. Skipping scrobble.`;
726
+ return;
727
+ }
728
+ }
729
+
730
+ // Check if the album exists, create if not
731
+ let [albumRecord] = await ctx.db
732
+ .select()
733
+ .from(schema.albums)
734
+ .where(
735
+ and(
736
+ eq(schema.albums.title, record.album),
737
+ eq(schema.albums.artist, record.albumArtist),
738
+ ),
739
+ )
740
+ .execute();
741
+
742
+ if (!albumRecord) {
743
+ logger.info` ⚙️ Album not found, creating: "${album}" by ${albumArtist}`;
744
+
745
+ // Create a synthetic album record from scrobble data
746
+ const albumUri = `at://${user.did}/app.rocksky.album/${createId()}`;
747
+ const albumCid = createId();
748
+
749
+ await createAlbums(
750
+ [
751
+ {
752
+ cid: albumCid,
753
+ uri: albumUri,
754
+ value: {
755
+ $type: "app.rocksky.album",
756
+ title: record.album,
757
+ artist: record.albumArtist,
758
+ createdAt: new Date().toISOString(),
759
+ releaseDate: record.releaseDate,
760
+ year: record.year,
761
+ albumArt: record.albumArt,
762
+ artistUri: artistRecord.uri,
763
+ spotifyLink: record.spotifyLink,
764
+ appleMusicLink: record.appleMusicLink,
765
+ tidalLink: record.tidalLink,
766
+ youtubeLink: record.youtubeLink,
767
+ } as Album.Record,
768
+ },
769
+ ],
770
+ user,
771
+ );
772
+
773
+ // Fetch the newly created album
774
+ [albumRecord] = await ctx.db
775
+ .select()
776
+ .from(schema.albums)
777
+ .where(
778
+ and(
779
+ eq(schema.albums.title, record.album),
780
+ eq(schema.albums.artist, record.albumArtist),
781
+ ),
782
+ )
783
+ .execute();
784
+
785
+ if (!albumRecord) {
786
+ logger.error` ❌ Failed to create album. Skipping scrobble.`;
787
+ return;
788
+ }
789
+ }
790
+
791
+ // Check if the track exists, create if not
792
+ let [track] = await ctx.db
793
+ .select()
794
+ .from(schema.tracks)
795
+ .where(
796
+ and(
797
+ eq(schema.tracks.title, record.title),
798
+ eq(schema.tracks.artist, record.artist),
799
+ eq(schema.tracks.album, record.album),
800
+ eq(schema.tracks.albumArtist, record.albumArtist),
801
+ ),
802
+ )
803
+ .execute();
804
+
805
+ if (!track) {
806
+ logger.info` ⚙️ Track not found, creating: "${title}" by ${artist} from ${album}`;
807
+
808
+ // Create a synthetic track record from scrobble data
809
+ const trackUri = `at://${user.did}/app.rocksky.song/${createId()}`;
810
+ const trackCid = createId();
811
+
812
+ await createSongs(
813
+ [
814
+ {
815
+ cid: trackCid,
816
+ uri: trackUri,
817
+ value: {
818
+ $type: "app.rocksky.song",
819
+ title: record.title,
820
+ artist: record.artist,
821
+ albumArtist: record.albumArtist,
822
+ album: record.album,
823
+ duration: record.duration,
824
+ trackNumber: record.trackNumber,
825
+ discNumber: record.discNumber,
826
+ releaseDate: record.releaseDate,
827
+ year: record.year,
828
+ genre: record.genre,
829
+ tags: record.tags,
830
+ composer: record.composer,
831
+ lyrics: record.lyrics,
832
+ copyrightMessage: record.copyrightMessage,
833
+ albumArt: record.albumArt,
834
+ youtubeLink: record.youtubeLink,
835
+ spotifyLink: record.spotifyLink,
836
+ tidalLink: record.tidalLink,
837
+ appleMusicLink: record.appleMusicLink,
838
+ createdAt: new Date().toISOString(),
839
+ mbId: record.mbid,
840
+ label: record.label,
841
+ albumUri: albumRecord.uri,
842
+ artistUri: artistRecord.uri,
843
+ } as Song.Record,
844
+ },
845
+ ],
846
+ user,
847
+ );
848
+
849
+ // Fetch the newly created track
850
+ [track] = await ctx.db
851
+ .select()
852
+ .from(schema.tracks)
853
+ .where(
854
+ and(
855
+ eq(schema.tracks.title, record.title),
856
+ eq(schema.tracks.artist, record.artist),
857
+ eq(schema.tracks.album, record.album),
858
+ eq(schema.tracks.albumArtist, record.albumArtist),
859
+ ),
860
+ )
861
+ .execute();
862
+
863
+ if (!track) {
864
+ logger.error` ❌ Failed to create track. Skipping scrobble.`;
865
+ return;
866
+ }
867
+ }
868
+
869
+ logger.info` ✓ All required entities ready. Creating scrobble...`;
870
+
871
+ await createScrobbles(
872
+ [
873
+ {
874
+ cid,
875
+ uri,
876
+ value: record,
877
+ },
878
+ ],
879
+ user,
880
+ );
881
+ };
882
+
883
+ const getRockskyUserSongs = async (agent: Agent): Promise<Songs> => {
884
+ const results: {
885
+ value: Song.Record;
886
+ uri: string;
887
+ cid: string;
888
+ }[] = [];
889
+
890
+ try {
891
+ logger.info(`Fetching repository CAR file for songs...`);
892
+
893
+ const repoRes = await agent.com.atproto.sync.getRepo({
894
+ did: agent.assertDid,
895
+ });
896
+
897
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
898
+ const collection = "app.rocksky.song";
899
+
900
+ for await (const { cid, bytes } of carReader.blocks()) {
901
+ try {
902
+ const decoded = cbor.decode(bytes);
903
+
904
+ // Check if this is a record with $type matching our collection
905
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
906
+ if (decoded.$type === collection) {
907
+ const value = decoded as unknown as Song.Record;
908
+ // Extract rkey from uri if present in the block, otherwise use cid
909
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
910
+
911
+ results.push({
912
+ value,
913
+ uri,
914
+ cid: cid.toString(),
915
+ });
916
+ }
917
+ }
918
+ } catch (e) {
919
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
920
+ continue;
921
+ }
922
+ }
923
+
924
+ logger.info(
925
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`,
926
+ );
927
+ } catch (error) {
928
+ logger.error(`Error fetching songs from CAR: ${error}`);
929
+ throw error;
930
+ }
931
+
932
+ return results;
933
+ };
934
+
935
+ const getRockskyUserAlbums = async (agent: Agent): Promise<Albums> => {
936
+ const results: {
937
+ value: Album.Record;
938
+ uri: string;
939
+ cid: string;
940
+ }[] = [];
941
+
942
+ try {
943
+ logger.info(`Fetching repository CAR file for albums...`);
944
+
945
+ // Use getRepo to fetch the entire repository as a CAR file
946
+ const repoRes = await agent.com.atproto.sync.getRepo({
947
+ did: agent.assertDid,
948
+ });
949
+
950
+ // Parse the CAR file
951
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
952
+ const collection = "app.rocksky.album";
953
+
954
+ for await (const { cid, bytes } of carReader.blocks()) {
955
+ try {
956
+ const decoded = cbor.decode(bytes);
957
+
958
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
959
+ if (decoded.$type === collection) {
960
+ const value = decoded as unknown as Album.Record;
961
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
962
+
963
+ results.push({
964
+ value,
965
+ uri,
966
+ cid: cid.toString(),
967
+ });
968
+ }
969
+ }
970
+ } catch (e) {
971
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
972
+ continue;
973
+ }
974
+ }
975
+
976
+ logger.info(
977
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`,
978
+ );
979
+ } catch (error) {
980
+ logger.error(`Error fetching albums from CAR: ${error}`);
981
+ throw error;
982
+ }
983
+
984
+ return results;
985
+ };
986
+
987
+ const getRockskyUserArtists = async (agent: Agent): Promise<Artists> => {
988
+ const results: {
989
+ value: Artist.Record;
990
+ uri: string;
991
+ cid: string;
992
+ }[] = [];
993
+
994
+ try {
995
+ logger.info(`Fetching repository CAR file for artists...`);
996
+
997
+ const repoRes = await agent.com.atproto.sync.getRepo({
998
+ did: agent.assertDid,
999
+ });
1000
+
1001
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
1002
+ const collection = "app.rocksky.artist";
1003
+
1004
+ for await (const { cid, bytes } of carReader.blocks()) {
1005
+ try {
1006
+ const decoded = cbor.decode(bytes);
1007
+
1008
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
1009
+ if (decoded.$type === collection) {
1010
+ const value = decoded as unknown as Artist.Record;
1011
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
1012
+
1013
+ results.push({
1014
+ value,
1015
+ uri,
1016
+ cid: cid.toString(),
1017
+ });
1018
+ }
1019
+ }
1020
+ } catch (e) {
1021
+ // Skip blocks that can't be decoded
1022
+ continue;
1023
+ }
1024
+ }
1025
+
1026
+ logger.info(
1027
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} artists`,
1028
+ );
1029
+ } catch (error) {
1030
+ logger.error(`Error fetching artists from CAR: ${error}`);
1031
+ throw error;
1032
+ }
1033
+
1034
+ return results;
1035
+ };
1036
+
1037
+ const getRockskyUserScrobbles = async (agent: Agent): Promise<Scrobbles> => {
1038
+ const results: {
1039
+ value: Scrobble.Record;
1040
+ uri: string;
1041
+ cid: string;
1042
+ }[] = [];
1043
+
1044
+ try {
1045
+ logger.info(`Fetching repository CAR file for scrobbles...`);
1046
+
1047
+ const repoRes = await agent.com.atproto.sync.getRepo({
1048
+ did: agent.assertDid,
1049
+ });
1050
+
1051
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
1052
+ const collection = "app.rocksky.scrobble";
1053
+
1054
+ for await (const { cid, bytes } of carReader.blocks()) {
1055
+ try {
1056
+ const decoded = cbor.decode(bytes);
1057
+
1058
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
1059
+ if (decoded.$type === collection) {
1060
+ const value = decoded as unknown as Scrobble.Record;
1061
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
1062
+
1063
+ results.push({
1064
+ value,
1065
+ uri,
1066
+ cid: cid.toString(),
1067
+ });
1068
+ }
1069
+ }
1070
+ } catch (e) {
1071
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
1072
+ continue;
1073
+ }
1074
+ }
1075
+
1076
+ logger.info(
1077
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`,
1078
+ );
1079
+ } catch (error) {
1080
+ logger.error(`Error fetching scrobbles from CAR: ${error}`);
1081
+ throw error;
1082
+ }
1083
+
1084
+ return results;
1085
+ };