@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
package/src/cmd/whoami.ts CHANGED
@@ -1,18 +1,47 @@
1
1
  import chalk from "chalk";
2
2
  import { RockskyClient } from "client";
3
3
  import fs from "fs/promises";
4
+ import { createAgent } from "lib/agent";
5
+ import { env } from "lib/env";
6
+ import { getDidAndHandle } from "lib/getDidAndHandle";
4
7
  import os from "os";
5
8
  import path from "path";
9
+ import { createUser } from "./sync";
10
+ import { ctx } from "context";
11
+ import schema from "schema";
12
+ import { eq } from "drizzle-orm";
6
13
 
7
14
  export async function whoami() {
15
+ if (env.ROCKSKY_IDENTIFIER && env.ROCKSKY_PASSWORD) {
16
+ const [did, handle] = await getDidAndHandle();
17
+ const agent = await createAgent(did, handle);
18
+ let user = await ctx.db
19
+ .select()
20
+ .from(schema.users)
21
+ .where(eq(schema.users.did, did))
22
+ .execute()
23
+ .then((rows) => rows[0]);
24
+
25
+ if (!user) {
26
+ user = await createUser(agent, did, handle);
27
+ }
28
+
29
+ console.log(`You are logged in as ${user.handle} (${user.displayName}).`);
30
+ console.log(
31
+ `View your profile at: ${chalk.magenta(
32
+ `https://rocksky.app/profile/${user.handle}`,
33
+ )}`,
34
+ );
35
+ return;
36
+ }
8
37
  const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
9
38
  try {
10
39
  await fs.access(tokenPath);
11
40
  } catch (err) {
12
41
  console.error(
13
42
  `You are not logged in. Please run ${chalk.greenBright(
14
- "`rocksky login <username>.bsky.social`"
15
- )} first.`
43
+ "`rocksky login <username>.bsky.social`",
44
+ )} first.`,
16
45
  );
17
46
  return;
18
47
  }
@@ -22,8 +51,8 @@ export async function whoami() {
22
51
  if (!token) {
23
52
  console.error(
24
53
  `You are not logged in. Please run ${chalk.greenBright(
25
- "`rocksky login <username>.bsky.social`"
26
- )} first.`
54
+ "`rocksky login <username>.bsky.social`",
55
+ )} first.`,
27
56
  );
28
57
  return;
29
58
  }
@@ -34,12 +63,12 @@ export async function whoami() {
34
63
  console.log(`You are logged in as ${user.handle} (${user.displayName}).`);
35
64
  console.log(
36
65
  `View your profile at: ${chalk.magenta(
37
- `https://rocksky.app/profile/${user.handle}`
38
- )}`
66
+ `https://rocksky.app/profile/${user.handle}`,
67
+ )}`,
39
68
  );
40
69
  } catch (err) {
41
70
  console.error(
42
- `Failed to fetch user data. Please check your token and try again.`
71
+ `Failed to fetch user data. Please check your token and try again.`,
43
72
  );
44
73
  }
45
74
  }
package/src/context.ts ADDED
@@ -0,0 +1,24 @@
1
+ import drizzle from "./drizzle";
2
+ import sqliteKv from "sqliteKv";
3
+ import { createBidirectionalResolver, createIdResolver } from "lib/idResolver";
4
+ import { createStorage } from "unstorage";
5
+ import envpaths from "env-paths";
6
+ import fs from "node:fs";
7
+
8
+ fs.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
9
+ const kvPath = `${envpaths("rocksky", { suffix: "" }).data}/rocksky-kv.sqlite`;
10
+
11
+ const kv = createStorage({
12
+ driver: sqliteKv({ location: kvPath, table: "kv" }),
13
+ });
14
+
15
+ const baseIdResolver = createIdResolver(kv);
16
+
17
+ export const ctx = {
18
+ db: drizzle.db,
19
+ resolver: createBidirectionalResolver(baseIdResolver),
20
+ baseIdResolver,
21
+ kv,
22
+ };
23
+
24
+ export type Context = typeof ctx;
package/src/drizzle.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { drizzle } from "drizzle-orm/better-sqlite3";
2
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
3
+ import Database from "better-sqlite3";
4
+ import envpaths from "env-paths";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ fs.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
13
+ const url = `${envpaths("rocksky", { suffix: "" }).data}/rocksky.sqlite`;
14
+
15
+ const sqlite = new Database(url);
16
+ const db = drizzle(sqlite);
17
+
18
+ let initialized = false;
19
+
20
+ /**
21
+ * Initialize the database and run migrations
22
+ * This should be called before any database operations
23
+ */
24
+ export async function initializeDatabase() {
25
+ if (initialized) {
26
+ return;
27
+ }
28
+
29
+ try {
30
+ // In production (built), migrations are in ../drizzle
31
+ // In development (src), migrations are in ../../drizzle
32
+ let migrationsFolder = path.join(__dirname, "../drizzle");
33
+
34
+ if (!fs.existsSync(migrationsFolder)) {
35
+ // Try development path
36
+ migrationsFolder = path.join(__dirname, "../../drizzle");
37
+ }
38
+
39
+ if (fs.existsSync(migrationsFolder)) {
40
+ migrate(db, { migrationsFolder });
41
+ initialized = true;
42
+ } else {
43
+ // No migrations folder found - this might be the first run
44
+ // or migrations haven't been generated yet
45
+ initialized = true;
46
+ }
47
+ } catch (error) {
48
+ console.error("Failed to run migrations:", error);
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ export default { db, initializeDatabase };
package/src/index.ts CHANGED
@@ -13,46 +13,74 @@ import { stats } from "cmd/stats";
13
13
  import { tracks } from "cmd/tracks";
14
14
  import { whoami } from "cmd/whoami";
15
15
  import { Command } from "commander";
16
- import version from "../package.json" assert { type: "json" };
16
+ import { version } from "../package.json" assert { type: "json" };
17
17
  import { login } from "./cmd/login";
18
+ import { sync } from "cmd/sync";
19
+ import { initializeDatabase } from "./drizzle";
20
+ import { scrobbleApi } from "cmd/scrobble-api";
21
+
22
+ await initializeDatabase();
18
23
 
19
24
  const program = new Command();
20
25
 
21
26
  program
22
27
  .name("rocksky")
23
28
  .description(
24
- `Command-line interface for Rocksky (${chalk.underline(
25
- "https://rocksky.app"
26
- )}) scrobble tracks, view stats, and manage your listening history.`
29
+ `
30
+ ___ __ __ _______ ____
31
+ / _ \\___ ____/ /__ ___ / /____ __ / ___/ / / _/
32
+ / , _/ _ \\/ __/ '_/(_-</ '_/ // / / /__/ /___/ /
33
+ /_/|_|\\___/\\__/_/\\_\\/___/_/\\_\\\\_, / \\___/____/___/
34
+ /___/
35
+ Command-line interface for Rocksky ${chalk.magentaBright(
36
+ "https://rocksky.app",
37
+ )} – scrobble tracks, view stats, and manage your listening history.`,
27
38
  )
28
- .version(version.version);
39
+ .version(version);
40
+
41
+ program.configureHelp({
42
+ styleTitle: (str) => chalk.bold.cyan(str),
43
+ styleCommandText: (str) => chalk.yellow(str),
44
+ styleDescriptionText: (str) => chalk.white(str),
45
+ styleOptionText: (str) => chalk.green(str),
46
+ styleArgumentText: (str) => chalk.magenta(str),
47
+ styleSubcommandText: (str) => chalk.blue(str),
48
+ });
49
+
50
+ program.addHelpText(
51
+ "after",
52
+ `
53
+ ${chalk.bold("\nLearn more about Rocksky:")} https://docs.rocksky.app
54
+ ${chalk.bold("Join our Discord community:")} ${chalk.blueBright("https://discord.gg/EVcBy2fVa3")}
55
+ `,
56
+ );
29
57
 
30
58
  program
31
59
  .command("login")
32
- .argument("<handle>", "your BlueSky handle (e.g., <username>.bsky.social)")
33
- .description("login with your BlueSky account and get a session token.")
60
+ .argument("<handle>", "your AT Proto handle (e.g., <username>.bsky.social)")
61
+ .description("login with your AT Proto account and get a session token")
34
62
  .action(login);
35
63
 
36
64
  program
37
65
  .command("whoami")
38
- .description("get the current logged-in user.")
66
+ .description("get the current logged-in user")
39
67
  .action(whoami);
40
68
 
41
69
  program
42
70
  .command("nowplaying")
43
71
  .argument(
44
72
  "[did]",
45
- "the DID or handle of the user to get the now playing track for."
73
+ "the DID or handle of the user to get the now playing track for",
46
74
  )
47
- .description("get the currently playing track.")
75
+ .description("get the currently playing track")
48
76
  .action(nowplaying);
49
77
 
50
78
  program
51
79
  .command("scrobbles")
52
80
  .option("-s, --skip <number>", "number of scrobbles to skip")
53
81
  .option("-l, --limit <number>", "number of scrobbles to limit")
54
- .argument("[did]", "the DID or handle of the user to get the scrobbles for.")
55
- .description("display recently played tracks.")
82
+ .argument("[did]", "the DID or handle of the user to get the scrobbles for")
83
+ .description("display recently played tracks")
56
84
  .action(scrobbles);
57
85
 
58
86
  program
@@ -63,37 +91,37 @@ program
63
91
  .option("-l, --limit <number>", "number of results to limit")
64
92
  .argument(
65
93
  "<query>",
66
- "the search query, e.g., artist, album, title or account"
94
+ "the search query, e.g., artist, album, title or account",
67
95
  )
68
- .description("search for tracks, albums, or accounts.")
96
+ .description("search for tracks, albums, or accounts")
69
97
  .action(search);
70
98
 
71
99
  program
72
100
  .command("stats")
73
101
  .option("-l, --limit <number>", "number of results to limit")
74
- .argument("[did]", "the DID or handle of the user to get stats for.")
75
- .description("get the user's listening stats.")
102
+ .argument("[did]", "the DID or handle of the user to get stats for")
103
+ .description("get the user's listening stats")
76
104
  .action(stats);
77
105
 
78
106
  program
79
107
  .command("artists")
80
108
  .option("-l, --limit <number>", "number of results to limit")
81
- .argument("[did]", "the DID or handle of the user to get artists for.")
82
- .description("get the user's top artists.")
109
+ .argument("[did]", "the DID or handle of the user to get artists for")
110
+ .description("get the user's top artists")
83
111
  .action(artists);
84
112
 
85
113
  program
86
114
  .command("albums")
87
115
  .option("-l, --limit <number>", "number of results to limit")
88
- .argument("[did]", "the DID or handle of the user to get albums for.")
89
- .description("get the user's top albums.")
116
+ .argument("[did]", "the DID or handle of the user to get albums for")
117
+ .description("get the user's top albums")
90
118
  .action(albums);
91
119
 
92
120
  program
93
121
  .command("tracks")
94
122
  .option("-l, --limit <number>", "number of results to limit")
95
- .argument("[did]", "the DID or handle of the user to get tracks for.")
96
- .description("get the user's top tracks.")
123
+ .argument("[did]", "the DID or handle of the user to get tracks for")
124
+ .description("get the user's top tracks")
97
125
  .action(tracks);
98
126
 
99
127
  program
@@ -101,21 +129,33 @@ program
101
129
  .argument("<track>", "the title of the track")
102
130
  .argument("<artist>", "the artist of the track")
103
131
  .option("-t, --timestamp <timestamp>", "the timestamp of the scrobble")
104
- .description("scrobble a track to your profile.")
132
+ .option("-d, --dry-run", "simulate the scrobble without actually sending it")
133
+ .description("scrobble a track to your profile")
105
134
  .action(scrobble);
106
135
 
107
136
  program
108
137
  .command("create")
109
- .description("create a new API key.")
138
+ .description("create a new API key")
110
139
  .command("apikey")
111
140
  .argument("<name>", "the name of the API key")
112
141
  .option("-d, --description <description>", "the description of the API key")
113
- .description("create a new API key.")
142
+ .description("create a new API key")
114
143
  .action(createApiKey);
115
144
 
116
145
  program
117
146
  .command("mcp")
118
- .description("Starts an MCP server to use with Claude or other LLMs.")
147
+ .description("starts an MCP server to use with Claude or other LLMs")
119
148
  .action(mcp);
120
149
 
150
+ program
151
+ .command("sync")
152
+ .description("sync your local Rocksky data from AT Protocol")
153
+ .action(sync);
154
+
155
+ program
156
+ .command("scrobble-api")
157
+ .description("start a local listenbrainz/lastfm compatibility server")
158
+ .option("-p, --port <port>", "the port to listen on", "8778")
159
+ .action(scrobbleApi);
160
+
121
161
  program.parse(process.argv);
@@ -0,0 +1,285 @@
1
+ export interface JetStreamEvent {
2
+ did: string;
3
+ time_us: number;
4
+ kind: "commit" | "identity" | "account";
5
+ commit?: {
6
+ rev: string;
7
+ operation: "create" | "update" | "delete";
8
+ collection: string;
9
+ rkey: string;
10
+ record?: Record<string, unknown>;
11
+ cid?: string;
12
+ };
13
+ identity?: {
14
+ did: string;
15
+ handle?: string;
16
+ seq?: number;
17
+ time?: string;
18
+ };
19
+ account?: {
20
+ active: boolean;
21
+ did: string;
22
+ seq: number;
23
+ time: string;
24
+ };
25
+ }
26
+
27
+ export interface JetStreamClientOptions {
28
+ endpoint?: string;
29
+ wantedCollections?: string[];
30
+ wantedDids?: string[];
31
+ maxReconnectAttempts?: number;
32
+ reconnectDelay?: number;
33
+ maxReconnectDelay?: number;
34
+ backoffMultiplier?: number;
35
+ debug?: boolean;
36
+ }
37
+
38
+ export type JetStreamEventType =
39
+ | "open"
40
+ | "message"
41
+ | "error"
42
+ | "close"
43
+ | "reconnect";
44
+
45
+ export class JetStreamClient {
46
+ private ws: WebSocket | null = null;
47
+ private options: Required<JetStreamClientOptions>;
48
+ private reconnectAttempts = 0;
49
+ private reconnectTimer: number | null = null;
50
+ private isManualClose = false;
51
+ private eventHandlers: Map<
52
+ JetStreamEventType,
53
+ Set<(data?: unknown) => void>
54
+ > = new Map();
55
+ private cursor: number | null = null;
56
+
57
+ constructor(options: JetStreamClientOptions = {}) {
58
+ this.options = {
59
+ endpoint:
60
+ options.endpoint || "wss://jetstream1.us-east.bsky.network/subscribe",
61
+ wantedCollections: options.wantedCollections || [],
62
+ wantedDids: options.wantedDids || [],
63
+ maxReconnectAttempts: options.maxReconnectAttempts ?? Infinity,
64
+ reconnectDelay: options.reconnectDelay ?? 1000,
65
+ maxReconnectDelay: options.maxReconnectDelay ?? 30000,
66
+ backoffMultiplier: options.backoffMultiplier ?? 1.5,
67
+ debug: options.debug ?? false,
68
+ };
69
+
70
+ // Initialize event handler sets
71
+ ["open", "message", "error", "close", "reconnect"].forEach((event) => {
72
+ this.eventHandlers.set(event as JetStreamEventType, new Set());
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Register an event handler
78
+ */
79
+ on(event: JetStreamEventType, handler: (data?: unknown) => void): this {
80
+ this.eventHandlers.get(event)?.add(handler);
81
+ return this;
82
+ }
83
+
84
+ /**
85
+ * Remove an event handler
86
+ */
87
+ off(event: JetStreamEventType, handler: (data?: unknown) => void): this {
88
+ this.eventHandlers.get(event)?.delete(handler);
89
+ return this;
90
+ }
91
+
92
+ /**
93
+ * Emit an event to all registered handlers
94
+ */
95
+ private emit(event: JetStreamEventType, data?: unknown): void {
96
+ this.eventHandlers.get(event)?.forEach((handler) => {
97
+ try {
98
+ handler(data);
99
+ } catch (error) {
100
+ this.log("error", `Handler error for ${event}:`, error);
101
+ }
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Build the WebSocket URL with query parameters
107
+ */
108
+ private buildUrl(): string {
109
+ const url = new URL(this.options.endpoint);
110
+
111
+ if (this.options.wantedCollections.length > 0) {
112
+ this.options.wantedCollections.forEach((collection) => {
113
+ url.searchParams.append("wantedCollections", collection);
114
+ });
115
+ }
116
+
117
+ if (this.options.wantedDids.length > 0) {
118
+ this.options.wantedDids.forEach((did) => {
119
+ url.searchParams.append("wantedDids", did);
120
+ });
121
+ }
122
+
123
+ if (this.cursor !== null) {
124
+ url.searchParams.set("cursor", this.cursor.toString());
125
+ }
126
+
127
+ return url.toString();
128
+ }
129
+
130
+ /**
131
+ * Connect to the JetStream WebSocket
132
+ */
133
+ connect(): void {
134
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
135
+ this.log("warn", "Already connected");
136
+ return;
137
+ }
138
+
139
+ this.isManualClose = false;
140
+ const url = this.buildUrl();
141
+ this.log("info", `Connecting to ${url}`);
142
+
143
+ try {
144
+ this.ws = new WebSocket(url);
145
+
146
+ this.ws.onopen = () => {
147
+ this.log("info", "Connected successfully");
148
+ this.reconnectAttempts = 0;
149
+ this.emit("open");
150
+ };
151
+
152
+ this.ws.onmessage = (event) => {
153
+ try {
154
+ const data = JSON.parse(event.data) as JetStreamEvent;
155
+
156
+ // Update cursor for resumption
157
+ if (data.time_us) {
158
+ this.cursor = data.time_us;
159
+ }
160
+
161
+ this.emit("message", data);
162
+ } catch (error) {
163
+ this.log("error", "Failed to parse message:", error);
164
+ this.emit("error", { type: "parse_error", error });
165
+ }
166
+ };
167
+
168
+ this.ws.onerror = (event) => {
169
+ this.log("error", "WebSocket error:", event);
170
+ this.emit("error", event);
171
+ };
172
+
173
+ this.ws.onclose = (event) => {
174
+ this.log("info", `Connection closed: ${event.code} ${event.reason}`);
175
+ this.emit("close", event);
176
+
177
+ if (!this.isManualClose) {
178
+ this.scheduleReconnect();
179
+ }
180
+ };
181
+ } catch (error) {
182
+ this.log("error", "Failed to create WebSocket:", error);
183
+ this.emit("error", { type: "connection_error", error });
184
+ this.scheduleReconnect();
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Schedule a reconnection attempt with exponential backoff
190
+ */
191
+ private scheduleReconnect(): void {
192
+ if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
193
+ this.log("error", "Max reconnection attempts reached");
194
+ return;
195
+ }
196
+
197
+ const delay = Math.min(
198
+ this.options.reconnectDelay *
199
+ Math.pow(this.options.backoffMultiplier, this.reconnectAttempts),
200
+ this.options.maxReconnectDelay,
201
+ );
202
+
203
+ this.reconnectAttempts++;
204
+ this.log(
205
+ "info",
206
+ `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
207
+ );
208
+
209
+ this.reconnectTimer = setTimeout(() => {
210
+ this.emit("reconnect", { attempt: this.reconnectAttempts });
211
+ this.connect();
212
+ }, delay) as unknown as number;
213
+ }
214
+
215
+ /**
216
+ * Manually disconnect from the WebSocket
217
+ */
218
+ disconnect(): void {
219
+ this.isManualClose = true;
220
+
221
+ if (this.reconnectTimer !== null) {
222
+ clearTimeout(this.reconnectTimer);
223
+ this.reconnectTimer = null;
224
+ }
225
+
226
+ if (this.ws) {
227
+ this.ws.close();
228
+ this.ws = null;
229
+ }
230
+
231
+ this.log("info", "Disconnected");
232
+ }
233
+
234
+ /**
235
+ * Update subscription filters (requires reconnection)
236
+ */
237
+ updateFilters(options: {
238
+ wantedCollections?: string[];
239
+ wantedDids?: string[];
240
+ }): void {
241
+ if (options.wantedCollections) {
242
+ this.options.wantedCollections = options.wantedCollections;
243
+ }
244
+ if (options.wantedDids) {
245
+ this.options.wantedDids = options.wantedDids;
246
+ }
247
+
248
+ // Reconnect with new filters
249
+ if (this.ws) {
250
+ this.disconnect();
251
+ this.connect();
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Get current connection state
257
+ */
258
+ get readyState(): number {
259
+ return this.ws?.readyState ?? WebSocket.CLOSED;
260
+ }
261
+
262
+ /**
263
+ * Check if currently connected
264
+ */
265
+ get isConnected(): boolean {
266
+ return this.ws?.readyState === WebSocket.OPEN;
267
+ }
268
+
269
+ /**
270
+ * Get current cursor position
271
+ */
272
+ get currentCursor(): number | null {
273
+ return this.cursor;
274
+ }
275
+
276
+ /**
277
+ * Logging utility
278
+ */
279
+ private log(level: "info" | "warn" | "error", ...args: unknown[]): void {
280
+ if (this.options.debug || level === "error") {
281
+ const prefix = `[JetStream ${level.toUpperCase()}]`;
282
+ console[level](prefix, ...args);
283
+ }
284
+ }
285
+ }