@rocksky/cli 0.1.1 → 0.3.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 (184) hide show
  1. package/README.md +270 -1
  2. package/TOOLS.md +194 -0
  3. package/bun.lock +28 -0
  4. package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
  5. package/dist/drizzle/meta/0000_snapshot.json +1559 -0
  6. package/dist/drizzle/meta/_journal.json +13 -0
  7. package/dist/index.js +8718 -165
  8. package/drizzle/0000_parallel_paper_doll.sql +220 -0
  9. package/drizzle/meta/0000_snapshot.json +1559 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +18 -0
  12. package/package.json +34 -4
  13. package/src/client.ts +32 -14
  14. package/src/cmd/mcp.ts +8 -0
  15. package/src/cmd/scrobble-api.ts +457 -0
  16. package/src/cmd/scrobble.ts +14 -61
  17. package/src/cmd/search.ts +27 -25
  18. package/src/cmd/sync.ts +812 -0
  19. package/src/cmd/whoami.ts +36 -7
  20. package/src/context.ts +24 -0
  21. package/src/drizzle.ts +53 -0
  22. package/src/index.ts +72 -23
  23. package/src/jetstream.ts +285 -0
  24. package/src/lexicon/index.ts +1321 -0
  25. package/src/lexicon/lexicons.ts +5453 -0
  26. package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
  27. package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
  28. package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
  29. package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
  30. package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
  31. package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
  32. package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
  33. package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
  34. package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
  35. package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
  36. package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
  37. package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
  38. package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
  39. package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
  40. package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
  41. package/src/lexicon/types/app/rocksky/album.ts +51 -0
  42. package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
  43. package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
  44. package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
  45. package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
  46. package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
  47. package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
  48. package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
  49. package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
  50. package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
  51. package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
  52. package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
  53. package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
  54. package/src/lexicon/types/app/rocksky/artist.ts +41 -0
  55. package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
  56. package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
  57. package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
  58. package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
  59. package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
  60. package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
  61. package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
  62. package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
  63. package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
  64. package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
  65. package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
  66. package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
  67. package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
  68. package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
  69. package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
  70. package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
  71. package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
  72. package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
  73. package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
  74. package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
  75. package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
  76. package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
  77. package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
  78. package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
  79. package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
  80. package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
  81. package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
  82. package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
  83. package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
  84. package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
  85. package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
  86. package/src/lexicon/types/app/rocksky/like.ts +27 -0
  87. package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
  88. package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
  89. package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
  90. package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
  91. package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
  92. package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
  93. package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
  94. package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
  95. package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
  96. package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
  97. package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
  98. package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
  99. package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
  100. package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
  101. package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
  102. package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
  103. package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
  104. package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
  105. package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
  106. package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
  107. package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
  108. package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
  109. package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
  110. package/src/lexicon/types/app/rocksky/radio.ts +37 -0
  111. package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
  112. package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
  113. package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
  114. package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
  115. package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
  116. package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
  117. package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
  118. package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
  119. package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
  120. package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
  121. package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
  122. package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
  123. package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
  124. package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
  125. package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
  126. package/src/lexicon/types/app/rocksky/shout.ts +30 -0
  127. package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
  128. package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
  129. package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
  130. package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
  131. package/src/lexicon/types/app/rocksky/song.ts +74 -0
  132. package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
  133. package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
  134. package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
  135. package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
  136. package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
  137. package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
  138. package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
  139. package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
  140. package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
  141. package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
  142. package/src/lexicon/util.ts +13 -0
  143. package/src/lib/agent.ts +56 -0
  144. package/src/lib/cleanUpJetstreamLock.ts +66 -0
  145. package/src/lib/cleanUpSyncLock.ts +56 -0
  146. package/src/lib/didUnstorageCache.ts +72 -0
  147. package/src/lib/env.ts +25 -0
  148. package/src/lib/extractPdsFromDid.ts +33 -0
  149. package/src/lib/getDidAndHandle.ts +39 -0
  150. package/src/lib/idResolver.ts +52 -0
  151. package/src/lib/lastfm.ts +26 -0
  152. package/src/lib/matchTrack.ts +47 -0
  153. package/src/logger.ts +18 -0
  154. package/src/mcp/index.ts +269 -0
  155. package/src/mcp/tools/albums.ts +13 -0
  156. package/src/mcp/tools/artists.ts +17 -0
  157. package/src/mcp/tools/create.ts +27 -0
  158. package/src/mcp/tools/myscrobbles.ts +42 -0
  159. package/src/mcp/tools/nowplaying.ts +53 -0
  160. package/src/mcp/tools/scrobbles.ts +39 -0
  161. package/src/mcp/tools/search.ts +88 -0
  162. package/src/mcp/tools/stats.ts +40 -0
  163. package/src/mcp/tools/tracks.ts +15 -0
  164. package/src/mcp/tools/whoami.ts +27 -0
  165. package/src/schema/album-tracks.ts +30 -0
  166. package/src/schema/albums.ts +29 -0
  167. package/src/schema/artist-albums.ts +29 -0
  168. package/src/schema/artist-genres.ts +17 -0
  169. package/src/schema/artist-tracks.ts +29 -0
  170. package/src/schema/artists.ts +30 -0
  171. package/src/schema/auth-session.ts +18 -0
  172. package/src/schema/genres.ts +18 -0
  173. package/src/schema/index.ts +33 -0
  174. package/src/schema/loved-tracks.ts +27 -0
  175. package/src/schema/scrobbles.ts +30 -0
  176. package/src/schema/tracks.ts +39 -0
  177. package/src/schema/user-albums.ts +31 -0
  178. package/src/schema/user-artists.ts +32 -0
  179. package/src/schema/user-tracks.ts +31 -0
  180. package/src/schema/users.ts +21 -0
  181. package/src/scrobble.ts +410 -0
  182. package/src/sqliteKv.ts +173 -0
  183. package/src/types.ts +308 -0
  184. package/tsconfig.json +26 -29
@@ -0,0 +1,812 @@
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
+
25
+ const PAGE_SIZE = 100;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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
+ };
628
+
629
+ const onNewAlbum = async (
630
+ record: Album.Record,
631
+ cid: string,
632
+ uri: string,
633
+ user: SelectUser,
634
+ ) => {
635
+ const { title, artist } = record;
636
+ logger.info` New album: ${title} by ${artist}`;
637
+ await createAlbums(
638
+ [
639
+ {
640
+ cid,
641
+ uri,
642
+ value: record,
643
+ },
644
+ ],
645
+ user,
646
+ );
647
+ };
648
+
649
+ const onNewArtist = async (
650
+ record: Artist.Record,
651
+ cid: string,
652
+ uri: string,
653
+ user: SelectUser,
654
+ ) => {
655
+ const { name } = record;
656
+ logger.info` New artist: ${name}`;
657
+ await createArtists(
658
+ [
659
+ {
660
+ cid,
661
+ uri,
662
+ value: record,
663
+ },
664
+ ],
665
+ user,
666
+ );
667
+ };
668
+
669
+ const onNewScrobble = async (
670
+ record: Scrobble.Record,
671
+ cid: string,
672
+ uri: string,
673
+ user: SelectUser,
674
+ ) => {
675
+ const { title, createdAt } = record;
676
+ logger.info` New scrobble: ${title} at ${createdAt}`;
677
+ await createScrobbles(
678
+ [
679
+ {
680
+ cid,
681
+ uri,
682
+ value: record,
683
+ },
684
+ ],
685
+ user,
686
+ );
687
+ };
688
+
689
+ const getRockskyUserSongs = async (agent: Agent): Promise<Songs> => {
690
+ let results: {
691
+ value: Song.Record;
692
+ uri: string;
693
+ cid: string;
694
+ }[] = [];
695
+ let cursor: string | undefined;
696
+ do {
697
+ const res = await agent.com.atproto.repo.listRecords({
698
+ repo: agent.assertDid,
699
+ collection: "app.rocksky.song",
700
+ limit: PAGE_SIZE,
701
+ cursor,
702
+ });
703
+ const records = res.data.records as Array<{
704
+ uri: string;
705
+ cid: string;
706
+ value: Song.Record;
707
+ }>;
708
+ results = results.concat(records);
709
+ cursor = res.data.cursor;
710
+ logger.info(
711
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`,
712
+ );
713
+ } while (cursor);
714
+
715
+ return results;
716
+ };
717
+
718
+ const getRockskyUserAlbums = async (agent: Agent): Promise<Albums> => {
719
+ let results: {
720
+ value: Album.Record;
721
+ uri: string;
722
+ cid: string;
723
+ }[] = [];
724
+ let cursor: string | undefined;
725
+ do {
726
+ const res = await agent.com.atproto.repo.listRecords({
727
+ repo: agent.assertDid,
728
+ collection: "app.rocksky.album",
729
+ limit: PAGE_SIZE,
730
+ cursor,
731
+ });
732
+
733
+ const records = res.data.records as Array<{
734
+ uri: string;
735
+ cid: string;
736
+ value: Album.Record;
737
+ }>;
738
+
739
+ results = results.concat(records);
740
+
741
+ cursor = res.data.cursor;
742
+ logger.info(
743
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`,
744
+ );
745
+ } while (cursor);
746
+
747
+ return results;
748
+ };
749
+
750
+ const getRockskyUserArtists = async (agent: Agent): Promise<Artists> => {
751
+ let results: {
752
+ value: Artist.Record;
753
+ uri: string;
754
+ cid: string;
755
+ }[] = [];
756
+ let cursor: string | undefined;
757
+ do {
758
+ const res = await agent.com.atproto.repo.listRecords({
759
+ repo: agent.assertDid,
760
+ collection: "app.rocksky.artist",
761
+ limit: PAGE_SIZE,
762
+ cursor,
763
+ });
764
+
765
+ const records = res.data.records as Array<{
766
+ uri: string;
767
+ cid: string;
768
+ value: Artist.Record;
769
+ }>;
770
+
771
+ results = results.concat(records);
772
+
773
+ cursor = res.data.cursor;
774
+ logger.info(
775
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} artists`,
776
+ );
777
+ } while (cursor);
778
+
779
+ return results;
780
+ };
781
+
782
+ const getRockskyUserScrobbles = async (agent: Agent): Promise<Scrobbles> => {
783
+ let results: {
784
+ value: Scrobble.Record;
785
+ uri: string;
786
+ cid: string;
787
+ }[] = [];
788
+ let cursor: string | undefined;
789
+ do {
790
+ const res = await agent.com.atproto.repo.listRecords({
791
+ repo: agent.assertDid,
792
+ collection: "app.rocksky.scrobble",
793
+ limit: PAGE_SIZE,
794
+ cursor,
795
+ });
796
+
797
+ const records = res.data.records as Array<{
798
+ uri: string;
799
+ cid: string;
800
+ value: Scrobble.Record;
801
+ }>;
802
+
803
+ results = results.concat(records);
804
+
805
+ cursor = res.data.cursor;
806
+ logger.info(
807
+ `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`,
808
+ );
809
+ } while (cursor);
810
+
811
+ return results;
812
+ };