@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.
- package/README.md +17 -11
- package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/dist/drizzle/0001_awesome_gabe_jones.sql +1 -0
- package/dist/drizzle/meta/0000_snapshot.json +1559 -0
- package/dist/drizzle/meta/0001_snapshot.json +1569 -0
- package/dist/drizzle/meta/_journal.json +20 -0
- package/dist/index.js +8509 -254
- package/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/drizzle/0001_awesome_gabe_jones.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +1559 -0
- package/drizzle/meta/0001_snapshot.json +1569 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +18 -0
- package/package.json +35 -3
- package/src/client.ts +32 -14
- package/src/cmd/scrobble-api.ts +465 -0
- package/src/cmd/scrobble.ts +14 -61
- package/src/cmd/search.ts +27 -25
- package/src/cmd/sync.ts +1085 -0
- package/src/cmd/whoami.ts +36 -7
- package/src/context.ts +24 -0
- package/src/drizzle.ts +53 -0
- package/src/index.ts +66 -26
- package/src/jetstream.ts +285 -0
- package/src/lexicon/index.ts +1321 -0
- package/src/lexicon/lexicons.ts +5453 -0
- package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
- package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
- package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
- package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
- package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
- package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
- package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
- package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
- package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
- package/src/lexicon/types/app/rocksky/album.ts +51 -0
- package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
- package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
- package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
- package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
- package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
- package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
- package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
- package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist.ts +41 -0
- package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
- package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
- package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
- package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
- package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
- package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
- package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
- package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
- package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
- package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
- package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
- package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
- package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
- package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
- package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
- package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
- package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
- package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
- package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
- package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
- package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
- package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
- package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
- package/src/lexicon/types/app/rocksky/like.ts +27 -0
- package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
- package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
- package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
- package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
- package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
- package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
- package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
- package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
- package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
- package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
- package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
- package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
- package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
- package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
- package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
- package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
- package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
- package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
- package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
- package/src/lexicon/types/app/rocksky/radio.ts +37 -0
- package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
- package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
- package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
- package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
- package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
- package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
- package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
- package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
- package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
- package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
- package/src/lexicon/types/app/rocksky/shout.ts +30 -0
- package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
- package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
- package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
- package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
- package/src/lexicon/types/app/rocksky/song.ts +74 -0
- package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
- package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
- package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
- package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
- package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
- package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
- package/src/lexicon/util.ts +13 -0
- package/src/lib/agent.ts +56 -0
- package/src/lib/cleanUpJetstreamLock.ts +66 -0
- package/src/lib/cleanUpSyncLock.ts +56 -0
- package/src/lib/didUnstorageCache.ts +72 -0
- package/src/lib/env.ts +25 -0
- package/src/lib/extractPdsFromDid.ts +33 -0
- package/src/lib/getDidAndHandle.ts +39 -0
- package/src/lib/idResolver.ts +52 -0
- package/src/lib/lastfm.ts +26 -0
- package/src/lib/matchTrack.ts +47 -0
- package/src/logger.ts +18 -0
- package/src/schema/album-tracks.ts +30 -0
- package/src/schema/albums.ts +29 -0
- package/src/schema/artist-albums.ts +29 -0
- package/src/schema/artist-genres.ts +17 -0
- package/src/schema/artist-tracks.ts +29 -0
- package/src/schema/artists.ts +30 -0
- package/src/schema/auth-session.ts +18 -0
- package/src/schema/genres.ts +18 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/loved-tracks.ts +27 -0
- package/src/schema/scrobbles.ts +30 -0
- package/src/schema/tracks.ts +47 -0
- package/src/schema/user-albums.ts +31 -0
- package/src/schema/user-artists.ts +32 -0
- package/src/schema/user-tracks.ts +31 -0
- package/src/schema/users.ts +21 -0
- package/src/scrobble.ts +410 -0
- package/src/sqliteKv.ts +173 -0
- package/src/types.ts +308 -0
- package/tsconfig.json +26 -29
package/src/cmd/sync.ts
ADDED
|
@@ -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
|
+
};
|