@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.
- package/README.md +270 -1
- package/TOOLS.md +194 -0
- package/bun.lock +28 -0
- package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/dist/drizzle/meta/0000_snapshot.json +1559 -0
- package/dist/drizzle/meta/_journal.json +13 -0
- package/dist/index.js +8718 -165
- package/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/drizzle/meta/0000_snapshot.json +1559 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +18 -0
- package/package.json +34 -4
- package/src/client.ts +32 -14
- package/src/cmd/mcp.ts +8 -0
- package/src/cmd/scrobble-api.ts +457 -0
- package/src/cmd/scrobble.ts +14 -61
- package/src/cmd/search.ts +27 -25
- package/src/cmd/sync.ts +812 -0
- package/src/cmd/whoami.ts +36 -7
- package/src/context.ts +24 -0
- package/src/drizzle.ts +53 -0
- package/src/index.ts +72 -23
- 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/mcp/index.ts +269 -0
- package/src/mcp/tools/albums.ts +13 -0
- package/src/mcp/tools/artists.ts +17 -0
- package/src/mcp/tools/create.ts +27 -0
- package/src/mcp/tools/myscrobbles.ts +42 -0
- package/src/mcp/tools/nowplaying.ts +53 -0
- package/src/mcp/tools/scrobbles.ts +39 -0
- package/src/mcp/tools/search.ts +88 -0
- package/src/mcp/tools/stats.ts +40 -0
- package/src/mcp/tools/tracks.ts +15 -0
- package/src/mcp/tools/whoami.ts +27 -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 +39 -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/scrobble.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { MatchTrackResult } from "lib/matchTrack";
|
|
2
|
+
import { logger } from "logger";
|
|
3
|
+
import dayjs from "dayjs";
|
|
4
|
+
import { createAgent } from "lib/agent";
|
|
5
|
+
import { getDidAndHandle } from "lib/getDidAndHandle";
|
|
6
|
+
import { ctx } from "context";
|
|
7
|
+
import schema from "schema";
|
|
8
|
+
import { and, eq, gte, lte, or, sql } from "drizzle-orm";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import chalk from "chalk";
|
|
13
|
+
import * as Album from "lexicon/types/app/rocksky/album";
|
|
14
|
+
import * as Artist from "lexicon/types/app/rocksky/artist";
|
|
15
|
+
import * as Scrobble from "lexicon/types/app/rocksky/scrobble";
|
|
16
|
+
import * as Song from "lexicon/types/app/rocksky/song";
|
|
17
|
+
import { TID } from "@atproto/common";
|
|
18
|
+
import { Agent } from "@atproto/api";
|
|
19
|
+
import { createUser, subscribeToJetstream, sync } from "cmd/sync";
|
|
20
|
+
import _ from "lodash";
|
|
21
|
+
|
|
22
|
+
export async function publishScrobble(
|
|
23
|
+
track: MatchTrackResult,
|
|
24
|
+
timestamp?: number,
|
|
25
|
+
dryRun?: boolean,
|
|
26
|
+
) {
|
|
27
|
+
const [did, handle] = await getDidAndHandle();
|
|
28
|
+
const agent: Agent = await createAgent(did, handle);
|
|
29
|
+
const recentScrobble = await getRecentScrobble(did, track, timestamp);
|
|
30
|
+
const user = await createUser(agent, did, handle);
|
|
31
|
+
await subscribeToJetstream(user);
|
|
32
|
+
|
|
33
|
+
const lockFilePath = path.join(os.tmpdir(), `rocksky-${did}.lock`);
|
|
34
|
+
|
|
35
|
+
if (fs.existsSync(lockFilePath)) {
|
|
36
|
+
logger.error(
|
|
37
|
+
`${chalk.greenBright(handle)} Scrobble publishing failed: lock file exists, maybe rocksky-cli is still syncing?\nPlease wait for rocksky to finish syncing before publishing scrobbles or delete the lock file manually ${chalk.greenBright(lockFilePath)}`,
|
|
38
|
+
);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (recentScrobble) {
|
|
43
|
+
logger.info`${handle} Skipping scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")} (already scrobbled)`;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const totalScrobbles = await countScrobbles(did);
|
|
48
|
+
if (totalScrobbles === 0) {
|
|
49
|
+
logger.warn`${handle} No scrobbles found for this user. Are you sure you have successfully synced your scrobbles locally?\nIf not, please run ${"rocksky sync"} to sync your scrobbles before publishing scrobbles.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
logger.info`${handle} Publishing scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")}`;
|
|
53
|
+
|
|
54
|
+
if (await shouldSync(agent)) {
|
|
55
|
+
logger.info`${handle} Syncing scrobbles before publishing`;
|
|
56
|
+
await sync();
|
|
57
|
+
} else {
|
|
58
|
+
logger.info`${handle} Local scrobbles are up-to-date, skipping sync`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (dryRun) {
|
|
62
|
+
logger.info`${handle} Dry run: Skipping publishing scrobble for ${track.title} by ${track.artist} at ${timestamp ? dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm:ss") : dayjs().format("YYYY-MM-DD HH:mm:ss")}`;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existingTrack = await ctx.db
|
|
67
|
+
.select()
|
|
68
|
+
.from(schema.tracks)
|
|
69
|
+
.where(
|
|
70
|
+
or(
|
|
71
|
+
and(
|
|
72
|
+
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
|
|
73
|
+
sql`LOWER(${schema.tracks.artist}) = LOWER(${track.artist})`,
|
|
74
|
+
),
|
|
75
|
+
and(
|
|
76
|
+
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
|
|
77
|
+
sql`LOWER(${schema.tracks.albumArtist}) = LOWER(${track.artist})`,
|
|
78
|
+
),
|
|
79
|
+
and(
|
|
80
|
+
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
|
|
81
|
+
sql`LOWER(${schema.tracks.albumArtist}) = LOWER(${track.albumArtist})`,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
.limit(1)
|
|
86
|
+
.execute()
|
|
87
|
+
.then((rows) => rows[0]);
|
|
88
|
+
|
|
89
|
+
if (!existingTrack) {
|
|
90
|
+
await putSongRecord(agent, track);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const existingArtist = await ctx.db
|
|
94
|
+
.select()
|
|
95
|
+
.from(schema.artists)
|
|
96
|
+
.where(
|
|
97
|
+
or(
|
|
98
|
+
sql`LOWER(${schema.artists.name}) = LOWER(${track.artist})`,
|
|
99
|
+
sql`LOWER(${schema.artists.name}) = LOWER(${track.albumArtist})`,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
.limit(1)
|
|
103
|
+
.execute()
|
|
104
|
+
.then((rows) => rows[0]);
|
|
105
|
+
|
|
106
|
+
if (!existingArtist) {
|
|
107
|
+
await putArtistRecord(agent, track);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const existingAlbum = await ctx.db
|
|
111
|
+
.select()
|
|
112
|
+
.from(schema.albums)
|
|
113
|
+
.where(
|
|
114
|
+
and(
|
|
115
|
+
sql`LOWER(${schema.albums.title}) = LOWER(${track.album})`,
|
|
116
|
+
sql`LOWER(${schema.albums.artist}) = LOWER(${track.albumArtist})`,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
.limit(1)
|
|
120
|
+
.execute()
|
|
121
|
+
.then((rows) => rows[0]);
|
|
122
|
+
|
|
123
|
+
if (!existingAlbum) {
|
|
124
|
+
await putAlbumRecord(agent, track);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const scrobbleUri = await putScrobbleRecord(agent, track, timestamp);
|
|
128
|
+
|
|
129
|
+
// wait for the scrobble to be published
|
|
130
|
+
if (scrobbleUri) {
|
|
131
|
+
const MAX_ATTEMPTS = 40;
|
|
132
|
+
let attempts = 0;
|
|
133
|
+
do {
|
|
134
|
+
const count = await ctx.db
|
|
135
|
+
.select({
|
|
136
|
+
count: sql`COUNT(*)`,
|
|
137
|
+
})
|
|
138
|
+
.from(schema.scrobbles)
|
|
139
|
+
.where(eq(schema.scrobbles.uri, scrobbleUri))
|
|
140
|
+
.execute()
|
|
141
|
+
.then((rows) => _.get(rows, "[0].count", 0) as number);
|
|
142
|
+
|
|
143
|
+
if (count > 0 || attempts >= MAX_ATTEMPTS) {
|
|
144
|
+
if (attempts == MAX_ATTEMPTS) {
|
|
145
|
+
logger.error`Failed to detect published scrobble after ${MAX_ATTEMPTS} attempts`;
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
151
|
+
attempts += 1;
|
|
152
|
+
} while (true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function getRecentScrobble(
|
|
159
|
+
did: string,
|
|
160
|
+
track: MatchTrackResult,
|
|
161
|
+
timestamp?: number,
|
|
162
|
+
) {
|
|
163
|
+
const scrobbleTime = dayjs.unix(timestamp || dayjs().unix());
|
|
164
|
+
return ctx.db
|
|
165
|
+
.select({
|
|
166
|
+
scrobble: schema.scrobbles,
|
|
167
|
+
user: schema.users,
|
|
168
|
+
track: schema.tracks,
|
|
169
|
+
})
|
|
170
|
+
.from(schema.scrobbles)
|
|
171
|
+
.innerJoin(schema.users, eq(schema.scrobbles.userId, schema.users.id))
|
|
172
|
+
.innerJoin(schema.tracks, eq(schema.scrobbles.trackId, schema.tracks.id))
|
|
173
|
+
.where(
|
|
174
|
+
and(
|
|
175
|
+
eq(schema.users.did, did),
|
|
176
|
+
sql`LOWER(${schema.tracks.title}) = LOWER(${track.title})`,
|
|
177
|
+
sql`LOWER(${schema.tracks.artist}) = LOWER(${track.artist})`,
|
|
178
|
+
gte(
|
|
179
|
+
schema.scrobbles.timestamp,
|
|
180
|
+
scrobbleTime.subtract(60, "seconds").toDate(),
|
|
181
|
+
),
|
|
182
|
+
lte(
|
|
183
|
+
schema.scrobbles.timestamp,
|
|
184
|
+
scrobbleTime.add(60, "seconds").toDate(),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
.limit(1)
|
|
189
|
+
.then((rows) => rows[0]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function countScrobbles(did: string): Promise<number> {
|
|
193
|
+
return ctx.db
|
|
194
|
+
.select({ count: sql<number>`count(*)` })
|
|
195
|
+
.from(schema.scrobbles)
|
|
196
|
+
.innerJoin(schema.users, eq(schema.scrobbles.userId, schema.users.id))
|
|
197
|
+
.where(eq(schema.users.did, did))
|
|
198
|
+
.then((rows) => rows[0].count);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function putSongRecord(agent: Agent, track: MatchTrackResult) {
|
|
202
|
+
const rkey = TID.nextStr();
|
|
203
|
+
|
|
204
|
+
const record: Song.Record = {
|
|
205
|
+
$type: "app.rocksky.song",
|
|
206
|
+
title: track.title,
|
|
207
|
+
artist: track.artist,
|
|
208
|
+
artists: track.mbArtists === null ? undefined : track.mbArtists,
|
|
209
|
+
album: track.album,
|
|
210
|
+
albumArtist: track.albumArtist,
|
|
211
|
+
duration: track.duration,
|
|
212
|
+
releaseDate: track.releaseDate
|
|
213
|
+
? new Date(track.releaseDate).toISOString()
|
|
214
|
+
: undefined,
|
|
215
|
+
year: track.year === null ? undefined : track.year,
|
|
216
|
+
albumArtUrl: track.albumArt,
|
|
217
|
+
composer: track.composer ? track.composer : undefined,
|
|
218
|
+
lyrics: track.lyrics ? track.lyrics : undefined,
|
|
219
|
+
trackNumber: track.trackNumber,
|
|
220
|
+
discNumber: track.discNumber === 0 ? 1 : track.discNumber,
|
|
221
|
+
copyrightMessage: track.copyrightMessage
|
|
222
|
+
? track.copyrightMessage
|
|
223
|
+
: undefined,
|
|
224
|
+
createdAt: new Date().toISOString(),
|
|
225
|
+
spotifyLink: track.spotifyLink ? track.spotifyLink : undefined,
|
|
226
|
+
tags: track.genres || [],
|
|
227
|
+
mbid: track.mbId,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (!Song.validateRecord(record).success) {
|
|
231
|
+
logger.info`${Song.validateRecord(record)}`;
|
|
232
|
+
logger.info`${record}`;
|
|
233
|
+
throw new Error("Invalid Song record");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const res = await agent.com.atproto.repo.putRecord({
|
|
238
|
+
repo: agent.assertDid,
|
|
239
|
+
collection: "app.rocksky.song",
|
|
240
|
+
rkey,
|
|
241
|
+
record,
|
|
242
|
+
validate: false,
|
|
243
|
+
});
|
|
244
|
+
const uri = res.data.uri;
|
|
245
|
+
logger.info`Song record created at ${uri}`;
|
|
246
|
+
return uri;
|
|
247
|
+
} catch (e) {
|
|
248
|
+
logger.error`Error creating song record: ${e}`;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function putArtistRecord(agent: Agent, track: MatchTrackResult) {
|
|
254
|
+
const rkey = TID.nextStr();
|
|
255
|
+
const record: Artist.Record = {
|
|
256
|
+
$type: "app.rocksky.artist",
|
|
257
|
+
name: track.albumArtist,
|
|
258
|
+
createdAt: new Date().toISOString(),
|
|
259
|
+
pictureUrl: track.artistPicture || undefined,
|
|
260
|
+
tags: track.genres || [],
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (!Artist.validateRecord(record).success) {
|
|
264
|
+
logger.info`${Artist.validateRecord(record)}`;
|
|
265
|
+
logger.info`${record}`;
|
|
266
|
+
throw new Error("Invalid Artist record");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const res = await agent.com.atproto.repo.putRecord({
|
|
271
|
+
repo: agent.assertDid,
|
|
272
|
+
collection: "app.rocksky.artist",
|
|
273
|
+
rkey,
|
|
274
|
+
record,
|
|
275
|
+
validate: false,
|
|
276
|
+
});
|
|
277
|
+
const uri = res.data.uri;
|
|
278
|
+
logger.info`Artist record created at ${uri}`;
|
|
279
|
+
return uri;
|
|
280
|
+
} catch (e) {
|
|
281
|
+
logger.error`Error creating artist record: ${e}`;
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function putAlbumRecord(agent: Agent, track: MatchTrackResult) {
|
|
287
|
+
const rkey = TID.nextStr();
|
|
288
|
+
|
|
289
|
+
const record = {
|
|
290
|
+
$type: "app.rocksky.album",
|
|
291
|
+
title: track.album,
|
|
292
|
+
artist: track.albumArtist,
|
|
293
|
+
year: track.year === null ? undefined : track.year,
|
|
294
|
+
releaseDate: track.releaseDate
|
|
295
|
+
? new Date(track.releaseDate).toISOString()
|
|
296
|
+
: undefined,
|
|
297
|
+
createdAt: new Date().toISOString(),
|
|
298
|
+
albumArtUrl: track.albumArt,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
if (!Album.validateRecord(record).success) {
|
|
302
|
+
logger.info`${Album.validateRecord(record)}`;
|
|
303
|
+
logger.info`${record}`;
|
|
304
|
+
throw new Error("Invalid Album record");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const res = await agent.com.atproto.repo.putRecord({
|
|
309
|
+
repo: agent.assertDid,
|
|
310
|
+
collection: "app.rocksky.album",
|
|
311
|
+
rkey,
|
|
312
|
+
record,
|
|
313
|
+
validate: false,
|
|
314
|
+
});
|
|
315
|
+
const uri = res.data.uri;
|
|
316
|
+
logger.info`Album record created at ${uri}`;
|
|
317
|
+
return uri;
|
|
318
|
+
} catch (e) {
|
|
319
|
+
logger.error`Error creating album record: ${e}`;
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function putScrobbleRecord(
|
|
325
|
+
agent: Agent,
|
|
326
|
+
track: MatchTrackResult,
|
|
327
|
+
timestamp?: number,
|
|
328
|
+
) {
|
|
329
|
+
const rkey = TID.nextStr();
|
|
330
|
+
|
|
331
|
+
const record: Scrobble.Record = {
|
|
332
|
+
$type: "app.rocksky.scrobble",
|
|
333
|
+
title: track.title,
|
|
334
|
+
albumArtist: track.albumArtist,
|
|
335
|
+
albumArtUrl: track.albumArt,
|
|
336
|
+
artist: track.artist,
|
|
337
|
+
artists: track.mbArtists === null ? undefined : track.mbArtists,
|
|
338
|
+
album: track.album,
|
|
339
|
+
duration: track.duration,
|
|
340
|
+
trackNumber: track.trackNumber,
|
|
341
|
+
discNumber: track.discNumber === 0 ? 1 : track.discNumber,
|
|
342
|
+
releaseDate: track.releaseDate
|
|
343
|
+
? new Date(track.releaseDate).toISOString()
|
|
344
|
+
: undefined,
|
|
345
|
+
year: track.year === null ? undefined : track.year,
|
|
346
|
+
composer: track.composer ? track.composer : undefined,
|
|
347
|
+
lyrics: track.lyrics ? track.lyrics : undefined,
|
|
348
|
+
copyrightMessage: track.copyrightMessage
|
|
349
|
+
? track.copyrightMessage
|
|
350
|
+
: undefined,
|
|
351
|
+
createdAt: timestamp
|
|
352
|
+
? dayjs.unix(timestamp).toISOString()
|
|
353
|
+
: new Date().toISOString(),
|
|
354
|
+
spotifyLink: track.spotifyLink ? track.spotifyLink : undefined,
|
|
355
|
+
tags: track.genres || [],
|
|
356
|
+
mbid: track.mbId,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
if (!Scrobble.validateRecord(record).success) {
|
|
360
|
+
logger.info`${Scrobble.validateRecord(record)}`;
|
|
361
|
+
logger.info`${record}`;
|
|
362
|
+
throw new Error("Invalid Scrobble record");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const res = await agent.com.atproto.repo.putRecord({
|
|
367
|
+
repo: agent.assertDid,
|
|
368
|
+
collection: "app.rocksky.scrobble",
|
|
369
|
+
rkey,
|
|
370
|
+
record,
|
|
371
|
+
validate: false,
|
|
372
|
+
});
|
|
373
|
+
const uri = res.data.uri;
|
|
374
|
+
logger.info`Scrobble record created at ${uri}`;
|
|
375
|
+
return uri;
|
|
376
|
+
} catch (e) {
|
|
377
|
+
logger.error`Error creating scrobble record: ${e}`;
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function shouldSync(agent: Agent): Promise<boolean> {
|
|
383
|
+
const res = await agent.com.atproto.repo.listRecords({
|
|
384
|
+
repo: agent.assertDid,
|
|
385
|
+
collection: "app.rocksky.scrobble",
|
|
386
|
+
limit: 1,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const records = res.data.records as Array<{
|
|
390
|
+
uri: string;
|
|
391
|
+
cid: string;
|
|
392
|
+
value: Scrobble.Record;
|
|
393
|
+
}>;
|
|
394
|
+
|
|
395
|
+
if (!records.length) {
|
|
396
|
+
logger.info`No scrobble records found`;
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const { count } = await ctx.db
|
|
401
|
+
.select({
|
|
402
|
+
count: sql<number>`count(*)`,
|
|
403
|
+
})
|
|
404
|
+
.from(schema.scrobbles)
|
|
405
|
+
.where(eq(schema.scrobbles.cid, records[0].cid))
|
|
406
|
+
.execute()
|
|
407
|
+
.then((result) => result[0]);
|
|
408
|
+
|
|
409
|
+
return count === 0;
|
|
410
|
+
}
|
package/src/sqliteKv.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { Kysely, SqliteDialect } from "kysely";
|
|
3
|
+
import { defineDriver } from "unstorage";
|
|
4
|
+
|
|
5
|
+
interface TableSchema {
|
|
6
|
+
[k: string]: {
|
|
7
|
+
id: string;
|
|
8
|
+
value: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
updated_at: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type KvDb = Kysely<TableSchema>;
|
|
15
|
+
|
|
16
|
+
const DRIVER_NAME = "sqlite";
|
|
17
|
+
|
|
18
|
+
export default defineDriver<
|
|
19
|
+
{
|
|
20
|
+
location?: string;
|
|
21
|
+
table: string;
|
|
22
|
+
getDb?: () => KvDb;
|
|
23
|
+
},
|
|
24
|
+
KvDb
|
|
25
|
+
>(
|
|
26
|
+
({
|
|
27
|
+
location,
|
|
28
|
+
table = "kv",
|
|
29
|
+
getDb = (): KvDb => {
|
|
30
|
+
let _db: KvDb | null = null;
|
|
31
|
+
|
|
32
|
+
return (() => {
|
|
33
|
+
if (_db) {
|
|
34
|
+
return _db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!location) {
|
|
38
|
+
throw new Error("SQLite location is required");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sqlite = new Database(location, { fileMustExist: false });
|
|
42
|
+
|
|
43
|
+
// Enable WAL mode
|
|
44
|
+
sqlite.pragma("journal_mode = WAL");
|
|
45
|
+
|
|
46
|
+
_db = new Kysely<TableSchema>({
|
|
47
|
+
dialect: new SqliteDialect({
|
|
48
|
+
database: sqlite,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Create table if not exists
|
|
53
|
+
_db.schema
|
|
54
|
+
.createTable(table)
|
|
55
|
+
.ifNotExists()
|
|
56
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
57
|
+
.addColumn("value", "text", (col) => col.notNull())
|
|
58
|
+
.addColumn("created_at", "text", (col) => col.notNull())
|
|
59
|
+
.addColumn("updated_at", "text", (col) => col.notNull())
|
|
60
|
+
.execute();
|
|
61
|
+
|
|
62
|
+
return _db;
|
|
63
|
+
})();
|
|
64
|
+
},
|
|
65
|
+
}) => {
|
|
66
|
+
return {
|
|
67
|
+
name: DRIVER_NAME,
|
|
68
|
+
options: { location, table },
|
|
69
|
+
getInstance: getDb,
|
|
70
|
+
|
|
71
|
+
async hasItem(key) {
|
|
72
|
+
const result = await getDb()
|
|
73
|
+
.selectFrom(table)
|
|
74
|
+
.select(["id"])
|
|
75
|
+
.where("id", "=", key)
|
|
76
|
+
.executeTakeFirst();
|
|
77
|
+
return !!result;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async getItem(key) {
|
|
81
|
+
const result = await getDb()
|
|
82
|
+
.selectFrom(table)
|
|
83
|
+
.select(["value"])
|
|
84
|
+
.where("id", "=", key)
|
|
85
|
+
.executeTakeFirst();
|
|
86
|
+
return result?.value ?? null;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async setItem(key: string, value: string) {
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
await getDb()
|
|
92
|
+
.insertInto(table)
|
|
93
|
+
.values({
|
|
94
|
+
id: key,
|
|
95
|
+
value,
|
|
96
|
+
created_at: now,
|
|
97
|
+
updated_at: now,
|
|
98
|
+
})
|
|
99
|
+
.onConflict((oc) =>
|
|
100
|
+
oc.column("id").doUpdateSet({
|
|
101
|
+
value,
|
|
102
|
+
updated_at: now,
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
.execute();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async setItems(items) {
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
|
|
111
|
+
await getDb()
|
|
112
|
+
.transaction()
|
|
113
|
+
.execute(async (trx) => {
|
|
114
|
+
await Promise.all(
|
|
115
|
+
items.map(({ key, value }) => {
|
|
116
|
+
return trx
|
|
117
|
+
.insertInto(table)
|
|
118
|
+
.values({
|
|
119
|
+
id: key,
|
|
120
|
+
value,
|
|
121
|
+
created_at: now,
|
|
122
|
+
updated_at: now,
|
|
123
|
+
})
|
|
124
|
+
.onConflict((oc) =>
|
|
125
|
+
oc.column("id").doUpdateSet({
|
|
126
|
+
value,
|
|
127
|
+
updated_at: now,
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
.execute();
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async removeItem(key: string) {
|
|
137
|
+
await getDb().deleteFrom(table).where("id", "=", key).execute();
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async getMeta(key: string) {
|
|
141
|
+
const result = await getDb()
|
|
142
|
+
.selectFrom(table)
|
|
143
|
+
.select(["created_at", "updated_at"])
|
|
144
|
+
.where("id", "=", key)
|
|
145
|
+
.executeTakeFirst();
|
|
146
|
+
if (!result) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
birthtime: new Date(result.created_at),
|
|
151
|
+
mtime: new Date(result.updated_at),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async getKeys(base = "") {
|
|
156
|
+
const results = await getDb()
|
|
157
|
+
.selectFrom(table)
|
|
158
|
+
.select(["id"])
|
|
159
|
+
.where("id", "like", `${base}%`)
|
|
160
|
+
.execute();
|
|
161
|
+
return results.map((r) => r.id);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async clear() {
|
|
165
|
+
await getDb().deleteFrom(table).execute();
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async dispose() {
|
|
169
|
+
await getDb().destroy();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
);
|