@rocksky/cli 0.2.0 → 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 (170) hide show
  1. package/README.md +21 -9
  2. package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
  3. package/dist/drizzle/meta/0000_snapshot.json +1559 -0
  4. package/dist/drizzle/meta/_journal.json +13 -0
  5. package/dist/index.js +8280 -254
  6. package/drizzle/0000_parallel_paper_doll.sql +220 -0
  7. package/drizzle/meta/0000_snapshot.json +1559 -0
  8. package/drizzle/meta/_journal.json +13 -0
  9. package/drizzle.config.ts +18 -0
  10. package/package.json +31 -3
  11. package/src/client.ts +32 -14
  12. package/src/cmd/scrobble-api.ts +457 -0
  13. package/src/cmd/scrobble.ts +14 -61
  14. package/src/cmd/search.ts +27 -25
  15. package/src/cmd/sync.ts +812 -0
  16. package/src/cmd/whoami.ts +36 -7
  17. package/src/context.ts +24 -0
  18. package/src/drizzle.ts +53 -0
  19. package/src/index.ts +66 -26
  20. package/src/jetstream.ts +285 -0
  21. package/src/lexicon/index.ts +1321 -0
  22. package/src/lexicon/lexicons.ts +5453 -0
  23. package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
  24. package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
  25. package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
  26. package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
  27. package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
  28. package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
  29. package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
  30. package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
  31. package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
  32. package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
  33. package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
  34. package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
  35. package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
  36. package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
  37. package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
  38. package/src/lexicon/types/app/rocksky/album.ts +51 -0
  39. package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
  40. package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
  41. package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
  42. package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
  43. package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
  44. package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
  45. package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
  46. package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
  47. package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
  48. package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
  49. package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
  50. package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
  51. package/src/lexicon/types/app/rocksky/artist.ts +41 -0
  52. package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
  53. package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
  54. package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
  55. package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
  56. package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
  57. package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
  58. package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
  59. package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
  60. package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
  61. package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
  62. package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
  63. package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
  64. package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
  65. package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
  66. package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
  67. package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
  68. package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
  69. package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
  70. package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
  71. package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
  72. package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
  73. package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
  74. package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
  75. package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
  76. package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
  77. package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
  78. package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
  79. package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
  80. package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
  81. package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
  82. package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
  83. package/src/lexicon/types/app/rocksky/like.ts +27 -0
  84. package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
  85. package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
  86. package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
  87. package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
  88. package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
  89. package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
  90. package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
  91. package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
  92. package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
  93. package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
  94. package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
  95. package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
  96. package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
  97. package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
  98. package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
  99. package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
  100. package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
  101. package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
  102. package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
  103. package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
  104. package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
  105. package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
  106. package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
  107. package/src/lexicon/types/app/rocksky/radio.ts +37 -0
  108. package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
  109. package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
  110. package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
  111. package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
  112. package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
  113. package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
  114. package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
  115. package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
  116. package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
  117. package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
  118. package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
  119. package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
  120. package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
  121. package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
  122. package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
  123. package/src/lexicon/types/app/rocksky/shout.ts +30 -0
  124. package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
  125. package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
  126. package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
  127. package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
  128. package/src/lexicon/types/app/rocksky/song.ts +74 -0
  129. package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
  130. package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
  131. package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
  132. package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
  133. package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
  134. package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
  135. package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
  136. package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
  137. package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
  138. package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
  139. package/src/lexicon/util.ts +13 -0
  140. package/src/lib/agent.ts +56 -0
  141. package/src/lib/cleanUpJetstreamLock.ts +66 -0
  142. package/src/lib/cleanUpSyncLock.ts +56 -0
  143. package/src/lib/didUnstorageCache.ts +72 -0
  144. package/src/lib/env.ts +25 -0
  145. package/src/lib/extractPdsFromDid.ts +33 -0
  146. package/src/lib/getDidAndHandle.ts +39 -0
  147. package/src/lib/idResolver.ts +52 -0
  148. package/src/lib/lastfm.ts +26 -0
  149. package/src/lib/matchTrack.ts +47 -0
  150. package/src/logger.ts +18 -0
  151. package/src/schema/album-tracks.ts +30 -0
  152. package/src/schema/albums.ts +29 -0
  153. package/src/schema/artist-albums.ts +29 -0
  154. package/src/schema/artist-genres.ts +17 -0
  155. package/src/schema/artist-tracks.ts +29 -0
  156. package/src/schema/artists.ts +30 -0
  157. package/src/schema/auth-session.ts +18 -0
  158. package/src/schema/genres.ts +18 -0
  159. package/src/schema/index.ts +33 -0
  160. package/src/schema/loved-tracks.ts +27 -0
  161. package/src/schema/scrobbles.ts +30 -0
  162. package/src/schema/tracks.ts +39 -0
  163. package/src/schema/user-albums.ts +31 -0
  164. package/src/schema/user-artists.ts +32 -0
  165. package/src/schema/user-tracks.ts +31 -0
  166. package/src/schema/users.ts +21 -0
  167. package/src/scrobble.ts +410 -0
  168. package/src/sqliteKv.ts +173 -0
  169. package/src/types.ts +308 -0
  170. package/tsconfig.json +26 -29
@@ -0,0 +1,18 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+
4
+ const genres = sqliteTable("genres", {
5
+ id: text("id").primaryKey().notNull(),
6
+ name: text("name").unique().notNull(),
7
+ createdAt: integer("created_at", { mode: "timestamp" })
8
+ .notNull()
9
+ .default(sql`(unixepoch())`),
10
+ updatedAt: integer("updated_at", { mode: "timestamp" })
11
+ .notNull()
12
+ .default(sql`(unixepoch())`),
13
+ });
14
+
15
+ export type SelectGenre = InferSelectModel<typeof genres>;
16
+ export type InsertGenre = InferInsertModel<typeof genres>;
17
+
18
+ export default genres;
@@ -0,0 +1,33 @@
1
+ import albumTracks from "./album-tracks";
2
+ import albums from "./albums";
3
+ import artistAlbums from "./artist-albums";
4
+ import artistGenres from "./artist-genres";
5
+ import artistTracks from "./artist-tracks";
6
+ import artists from "./artists";
7
+ import authSessions from "./auth-session";
8
+ import genres from "./genres";
9
+ import lovedTracks from "./loved-tracks";
10
+ import scrobbles from "./scrobbles";
11
+ import tracks from "./tracks";
12
+ import userAlbums from "./user-albums";
13
+ import userArtists from "./user-artists";
14
+ import userTracks from "./user-tracks";
15
+ import users from "./users";
16
+
17
+ export default {
18
+ users,
19
+ tracks,
20
+ artists,
21
+ albums,
22
+ albumTracks,
23
+ authSessions,
24
+ artistAlbums,
25
+ artistTracks,
26
+ lovedTracks,
27
+ scrobbles,
28
+ userAlbums,
29
+ userArtists,
30
+ userTracks,
31
+ genres,
32
+ artistGenres,
33
+ };
@@ -0,0 +1,27 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { sqliteTable, integer, text, unique } from "drizzle-orm/sqlite-core";
3
+ import tracks from "./tracks";
4
+ import users from "./users";
5
+
6
+ const lovedTracks = sqliteTable(
7
+ "loved_tracks",
8
+ {
9
+ id: text("id").primaryKey().notNull(),
10
+ userId: text("user_id")
11
+ .notNull()
12
+ .references(() => users.id),
13
+ trackId: text("track_id")
14
+ .notNull()
15
+ .references(() => tracks.id),
16
+ uri: text("uri").unique(),
17
+ createdAt: integer("created_at")
18
+ .notNull()
19
+ .default(sql`(unixepoch())`),
20
+ },
21
+ (t) => [unique("loved_tracks_unique_index").on(t.userId, t.trackId)],
22
+ );
23
+
24
+ export type SelectLovedTrack = InferSelectModel<typeof lovedTracks>;
25
+ export type InsertLovedTrack = InferInsertModel<typeof lovedTracks>;
26
+
27
+ export default lovedTracks;
@@ -0,0 +1,30 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+ import albums from "./albums";
4
+ import artists from "./artists";
5
+ import tracks from "./tracks";
6
+ import users from "./users";
7
+
8
+ const scrobbles = sqliteTable("scrobbles", {
9
+ id: text("xata_id").primaryKey().notNull(),
10
+ userId: text("user_id").references(() => users.id),
11
+ trackId: text("track_id").references(() => tracks.id),
12
+ albumId: text("album_id").references(() => albums.id),
13
+ artistId: text("artist_id").references(() => artists.id),
14
+ uri: text("uri").unique(),
15
+ cid: text("cid").unique(),
16
+ createdAt: integer("created_at", { mode: "timestamp" })
17
+ .notNull()
18
+ .default(sql`(unixepoch())`),
19
+ updatedAt: integer("updated_at", { mode: "timestamp" })
20
+ .notNull()
21
+ .default(sql`(unixepoch())`),
22
+ timestamp: integer("timestamp", { mode: "timestamp" })
23
+ .notNull()
24
+ .default(sql`(unixepoch())`),
25
+ });
26
+
27
+ export type SelectScrobble = InferSelectModel<typeof scrobbles>;
28
+ export type InsertScrobble = InferInsertModel<typeof scrobbles>;
29
+
30
+ export default scrobbles;
@@ -0,0 +1,39 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+
4
+ const tracks = sqliteTable("tracks", {
5
+ id: text("id").primaryKey().notNull(),
6
+ title: text("title").notNull(),
7
+ artist: text("artist").notNull(),
8
+ albumArtist: text("album_artist").notNull(),
9
+ albumArt: text("album_art"),
10
+ album: text("album").notNull(),
11
+ trackNumber: integer("track_number"),
12
+ duration: integer("duration").notNull(),
13
+ mbId: text("mb_id").unique(),
14
+ youtubeLink: text("youtube_link").unique(),
15
+ spotifyLink: text("spotify_link").unique(),
16
+ appleMusicLink: text("apple_music_link").unique(),
17
+ tidalLink: text("tidal_link").unique(),
18
+ discNumber: integer("disc_number"),
19
+ lyrics: text("lyrics"),
20
+ composer: text("composer"),
21
+ genre: text("genre"),
22
+ label: text("label"),
23
+ copyrightMessage: text("copyright_message"),
24
+ uri: text("uri").unique(),
25
+ cid: text("cid").unique().notNull(),
26
+ albumUri: text("album_uri"),
27
+ artistUri: text("artist_uri"),
28
+ createdAt: integer("created_at", { mode: "timestamp" })
29
+ .notNull()
30
+ .default(sql`(unixepoch())`),
31
+ updatedAt: integer("updated_at", { mode: "timestamp" })
32
+ .notNull()
33
+ .default(sql`(unixepoch())`),
34
+ });
35
+
36
+ export type SelectTrack = InferSelectModel<typeof tracks>;
37
+ export type InsertTrack = InferInsertModel<typeof tracks>;
38
+
39
+ export default tracks;
@@ -0,0 +1,31 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
3
+ import albums from "./albums";
4
+ import users from "./users";
5
+
6
+ const userAlbums = sqliteTable(
7
+ "user_albums",
8
+ {
9
+ id: text("id").primaryKey().notNull(),
10
+ userId: text("user_id")
11
+ .notNull()
12
+ .references(() => users.id),
13
+ albumId: text("album_id")
14
+ .notNull()
15
+ .references(() => albums.id),
16
+ createdAt: integer("created_at", { mode: "timestamp" })
17
+ .notNull()
18
+ .default(sql`(unixepoch())`),
19
+ updatedAt: integer("updated_at", { mode: "timestamp" })
20
+ .notNull()
21
+ .default(sql`(unixepoch())`),
22
+ scrobbles: integer("scrobbles"),
23
+ uri: text("uri").unique().notNull(),
24
+ },
25
+ (t) => [unique("user_albums_unique_index").on(t.userId, t.albumId)],
26
+ );
27
+
28
+ export type SelectUserAlbum = InferSelectModel<typeof userAlbums>;
29
+ export type InsertUserAlbum = InferInsertModel<typeof userAlbums>;
30
+
31
+ export default userAlbums;
@@ -0,0 +1,32 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
3
+ import artists from "./artists";
4
+ import users from "./users";
5
+
6
+ const userArtists = sqliteTable(
7
+ "user_artists",
8
+ {
9
+ id: text("id").primaryKey().notNull(),
10
+ userId: text("user_id")
11
+ .notNull()
12
+ .references(() => users.id),
13
+ artistId: text("artist_id")
14
+ .notNull()
15
+ .references(() => artists.id),
16
+ createdAt: integer("created_at", { mode: "timestamp" })
17
+ .notNull()
18
+ .default(sql`(unixepoch())`),
19
+ updatedAt: integer("updated_at", { mode: "timestamp" })
20
+ .notNull()
21
+ .default(sql`(unixepoch())`),
22
+ scrobbles: integer("scrobbles"),
23
+ uri: text("uri").unique().notNull(),
24
+ },
25
+
26
+ (t) => [unique("user_artists_unique_index").on(t.userId, t.artistId)],
27
+ );
28
+
29
+ export type SelectUserArtist = InferSelectModel<typeof userArtists>;
30
+ export type InsertUserArtist = InferInsertModel<typeof userArtists>;
31
+
32
+ export default userArtists;
@@ -0,0 +1,31 @@
1
+ import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
3
+ import tracks from "./tracks";
4
+ import users from "./users";
5
+
6
+ const userTracks = sqliteTable(
7
+ "user_tracks",
8
+ {
9
+ id: text("id").primaryKey().notNull(),
10
+ userId: text("user_id")
11
+ .notNull()
12
+ .references(() => users.id),
13
+ trackId: text("track_id")
14
+ .notNull()
15
+ .references(() => tracks.id),
16
+ createdAt: integer("created_at", { mode: "timestamp" })
17
+ .notNull()
18
+ .default(sql`(unixepoch())`),
19
+ updatedAt: integer("updated_at", { mode: "timestamp" })
20
+ .notNull()
21
+ .default(sql`(unixepoch())`),
22
+ scrobbles: integer("scrobbles"),
23
+ uri: text("uri").unique().notNull(),
24
+ },
25
+ (t) => [unique("user_tracks_unique_index").on(t.userId, t.trackId)],
26
+ );
27
+
28
+ export type SelectUser = InferSelectModel<typeof userTracks>;
29
+ export type InsertUserTrack = InferInsertModel<typeof userTracks>;
30
+
31
+ export default userTracks;
@@ -0,0 +1,21 @@
1
+ import { type InferSelectModel, sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+
4
+ const users = sqliteTable("users", {
5
+ id: text("id").primaryKey().notNull(),
6
+ did: text("did").unique().notNull(),
7
+ displayName: text("display_name"),
8
+ handle: text("handle").unique().notNull(),
9
+ avatar: text("avatar").notNull(),
10
+ createdAt: integer("created_at", { mode: "timestamp" })
11
+ .notNull()
12
+ .default(sql`(unixepoch())`),
13
+ updatedAt: integer("updated_at", { mode: "timestamp" })
14
+ .notNull()
15
+ .default(sql`(unixepoch())`),
16
+ });
17
+
18
+ export type SelectUser = InferSelectModel<typeof users>;
19
+ export type InsertUser = InferSelectModel<typeof users>;
20
+
21
+ export default users;
@@ -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
+ }