@rocksky/cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +270 -1
  2. package/TOOLS.md +194 -0
  3. package/bun.lock +28 -0
  4. package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
  5. package/dist/drizzle/meta/0000_snapshot.json +1559 -0
  6. package/dist/drizzle/meta/_journal.json +13 -0
  7. package/dist/index.js +8718 -165
  8. package/drizzle/0000_parallel_paper_doll.sql +220 -0
  9. package/drizzle/meta/0000_snapshot.json +1559 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +18 -0
  12. package/package.json +34 -4
  13. package/src/client.ts +32 -14
  14. package/src/cmd/mcp.ts +8 -0
  15. package/src/cmd/scrobble-api.ts +457 -0
  16. package/src/cmd/scrobble.ts +14 -61
  17. package/src/cmd/search.ts +27 -25
  18. package/src/cmd/sync.ts +812 -0
  19. package/src/cmd/whoami.ts +36 -7
  20. package/src/context.ts +24 -0
  21. package/src/drizzle.ts +53 -0
  22. package/src/index.ts +72 -23
  23. package/src/jetstream.ts +285 -0
  24. package/src/lexicon/index.ts +1321 -0
  25. package/src/lexicon/lexicons.ts +5453 -0
  26. package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
  27. package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
  28. package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
  29. package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
  30. package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
  31. package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
  32. package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
  33. package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
  34. package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
  35. package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
  36. package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
  37. package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
  38. package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
  39. package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
  40. package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
  41. package/src/lexicon/types/app/rocksky/album.ts +51 -0
  42. package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
  43. package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
  44. package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
  45. package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
  46. package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
  47. package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
  48. package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
  49. package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
  50. package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
  51. package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
  52. package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
  53. package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
  54. package/src/lexicon/types/app/rocksky/artist.ts +41 -0
  55. package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
  56. package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
  57. package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
  58. package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
  59. package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
  60. package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
  61. package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
  62. package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
  63. package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
  64. package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
  65. package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
  66. package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
  67. package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
  68. package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
  69. package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
  70. package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
  71. package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
  72. package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
  73. package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
  74. package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
  75. package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
  76. package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
  77. package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
  78. package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
  79. package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
  80. package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
  81. package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
  82. package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
  83. package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
  84. package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
  85. package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
  86. package/src/lexicon/types/app/rocksky/like.ts +27 -0
  87. package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
  88. package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
  89. package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
  90. package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
  91. package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
  92. package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
  93. package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
  94. package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
  95. package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
  96. package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
  97. package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
  98. package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
  99. package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
  100. package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
  101. package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
  102. package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
  103. package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
  104. package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
  105. package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
  106. package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
  107. package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
  108. package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
  109. package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
  110. package/src/lexicon/types/app/rocksky/radio.ts +37 -0
  111. package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
  112. package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
  113. package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
  114. package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
  115. package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
  116. package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
  117. package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
  118. package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
  119. package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
  120. package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
  121. package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
  122. package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
  123. package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
  124. package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
  125. package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
  126. package/src/lexicon/types/app/rocksky/shout.ts +30 -0
  127. package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
  128. package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
  129. package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
  130. package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
  131. package/src/lexicon/types/app/rocksky/song.ts +74 -0
  132. package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
  133. package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
  134. package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
  135. package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
  136. package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
  137. package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
  138. package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
  139. package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
  140. package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
  141. package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
  142. package/src/lexicon/util.ts +13 -0
  143. package/src/lib/agent.ts +56 -0
  144. package/src/lib/cleanUpJetstreamLock.ts +66 -0
  145. package/src/lib/cleanUpSyncLock.ts +56 -0
  146. package/src/lib/didUnstorageCache.ts +72 -0
  147. package/src/lib/env.ts +25 -0
  148. package/src/lib/extractPdsFromDid.ts +33 -0
  149. package/src/lib/getDidAndHandle.ts +39 -0
  150. package/src/lib/idResolver.ts +52 -0
  151. package/src/lib/lastfm.ts +26 -0
  152. package/src/lib/matchTrack.ts +47 -0
  153. package/src/logger.ts +18 -0
  154. package/src/mcp/index.ts +269 -0
  155. package/src/mcp/tools/albums.ts +13 -0
  156. package/src/mcp/tools/artists.ts +17 -0
  157. package/src/mcp/tools/create.ts +27 -0
  158. package/src/mcp/tools/myscrobbles.ts +42 -0
  159. package/src/mcp/tools/nowplaying.ts +53 -0
  160. package/src/mcp/tools/scrobbles.ts +39 -0
  161. package/src/mcp/tools/search.ts +88 -0
  162. package/src/mcp/tools/stats.ts +40 -0
  163. package/src/mcp/tools/tracks.ts +15 -0
  164. package/src/mcp/tools/whoami.ts +27 -0
  165. package/src/schema/album-tracks.ts +30 -0
  166. package/src/schema/albums.ts +29 -0
  167. package/src/schema/artist-albums.ts +29 -0
  168. package/src/schema/artist-genres.ts +17 -0
  169. package/src/schema/artist-tracks.ts +29 -0
  170. package/src/schema/artists.ts +30 -0
  171. package/src/schema/auth-session.ts +18 -0
  172. package/src/schema/genres.ts +18 -0
  173. package/src/schema/index.ts +33 -0
  174. package/src/schema/loved-tracks.ts +27 -0
  175. package/src/schema/scrobbles.ts +30 -0
  176. package/src/schema/tracks.ts +39 -0
  177. package/src/schema/user-albums.ts +31 -0
  178. package/src/schema/user-artists.ts +32 -0
  179. package/src/schema/user-tracks.ts +31 -0
  180. package/src/schema/users.ts +21 -0
  181. package/src/scrobble.ts +410 -0
  182. package/src/sqliteKv.ts +173 -0
  183. package/src/types.ts +308 -0
  184. package/tsconfig.json +26 -29
@@ -0,0 +1,56 @@
1
+ import { Agent, AtpAgent } from "@atproto/api";
2
+ import { ctx } from "context";
3
+ import { eq } from "drizzle-orm";
4
+ import authSessions from "schema/auth-session";
5
+ import extractPdsFromDid from "./extractPdsFromDid";
6
+ import { env } from "./env";
7
+ import { logger } from "logger";
8
+
9
+ export async function createAgent(did: string, handle: string): Promise<Agent> {
10
+ const pds = await extractPdsFromDid(did);
11
+ const agent = new AtpAgent({
12
+ service: new URL(pds),
13
+ });
14
+
15
+ try {
16
+ const [data] = await ctx.db
17
+ .select()
18
+ .from(authSessions)
19
+ .where(eq(authSessions.key, did))
20
+ .execute();
21
+
22
+ if (!data) {
23
+ throw new Error("No session found");
24
+ }
25
+
26
+ await agent.resumeSession(JSON.parse(data.session));
27
+ return agent;
28
+ } catch (e) {
29
+ logger.error`Resuming session ${did}`;
30
+ await ctx.db
31
+ .delete(authSessions)
32
+ .where(eq(authSessions.key, did))
33
+ .execute();
34
+
35
+ await agent.login({
36
+ identifier: handle,
37
+ password: env.ROCKSKY_PASSWORD,
38
+ });
39
+
40
+ await ctx.db
41
+ .insert(authSessions)
42
+ .values({
43
+ key: did,
44
+ session: JSON.stringify(agent.session),
45
+ })
46
+ .onConflictDoUpdate({
47
+ target: authSessions.key,
48
+ set: { session: JSON.stringify(agent.session) },
49
+ })
50
+ .execute();
51
+
52
+ logger.info`Logged in as ${handle}`;
53
+
54
+ return agent;
55
+ }
56
+ }
@@ -0,0 +1,66 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { logger } from "logger";
5
+
6
+ export function cleanUpJetstreamLockOnExit(did: string) {
7
+ process.on("exit", async () => {
8
+ try {
9
+ await fs.promises.unlink(
10
+ path.join(os.tmpdir(), `rocksky-jetstream-${did}.lock`),
11
+ );
12
+ process.exit(0);
13
+ } catch (error) {
14
+ logger.error`Error cleaning up Jetstream lock: ${error}`;
15
+ process.exit(1);
16
+ }
17
+ });
18
+
19
+ process.on("SIGINT", async () => {
20
+ try {
21
+ await fs.promises.unlink(
22
+ path.join(os.tmpdir(), `rocksky-jetstream-${did}.lock`),
23
+ );
24
+ process.exit(0);
25
+ } catch (error) {
26
+ logger.error`Error cleaning up Jetstream lock: ${error}`;
27
+ process.exit(1);
28
+ }
29
+ });
30
+
31
+ process.on("SIGTERM", async () => {
32
+ try {
33
+ await fs.promises.unlink(
34
+ path.join(os.tmpdir(), `rocksky-jetstream-${did}.lock`),
35
+ );
36
+ process.exit(0);
37
+ } catch (error) {
38
+ logger.error`Error cleaning up Jetstream lock: ${error}`;
39
+ process.exit(1);
40
+ }
41
+ });
42
+
43
+ process.on("uncaughtException", async () => {
44
+ try {
45
+ await fs.promises.unlink(
46
+ path.join(os.tmpdir(), `rocksky-jetstream-${did}.lock`),
47
+ );
48
+ process.exit(1);
49
+ } catch (error) {
50
+ logger.error`Error cleaning up Jetstream lock: ${error}`;
51
+ process.exit(1);
52
+ }
53
+ });
54
+
55
+ process.on("unhandledRejection", async () => {
56
+ try {
57
+ await fs.promises.unlink(
58
+ path.join(os.tmpdir(), `rocksky-jetstream-${did}.lock`),
59
+ );
60
+ process.exit(1);
61
+ } catch (error) {
62
+ logger.error`Error cleaning up Jetstream lock: ${error}`;
63
+ process.exit(1);
64
+ }
65
+ });
66
+ }
@@ -0,0 +1,56 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { logger } from "logger";
5
+
6
+ export function cleanUpSyncLockOnExit(did: string) {
7
+ process.on("exit", async () => {
8
+ try {
9
+ await fs.promises.unlink(path.join(os.tmpdir(), `rocksky-${did}.lock`));
10
+ process.exit(0);
11
+ } catch (error) {
12
+ logger.error`Error cleaning up Sync lock: ${error}`;
13
+ process.exit(1);
14
+ }
15
+ });
16
+
17
+ process.on("SIGINT", async () => {
18
+ try {
19
+ await fs.promises.unlink(path.join(os.tmpdir(), `rocksky-${did}.lock`));
20
+ process.exit(0);
21
+ } catch (error) {
22
+ logger.error`Error cleaning up Sync lock: ${error}`;
23
+ process.exit(1);
24
+ }
25
+ });
26
+
27
+ process.on("SIGTERM", async () => {
28
+ try {
29
+ await fs.promises.unlink(path.join(os.tmpdir(), `rocksky-${did}.lock`));
30
+ process.exit(0);
31
+ } catch (error) {
32
+ logger.error`Error cleaning up Sync lock: ${error}`;
33
+ process.exit(1);
34
+ }
35
+ });
36
+
37
+ process.on("uncaughtException", async () => {
38
+ try {
39
+ await fs.promises.unlink(path.join(os.tmpdir(), `rocksky-${did}.lock`));
40
+ process.exit(1);
41
+ } catch (error) {
42
+ logger.error`Error cleaning up Sync lock: ${error}`;
43
+ process.exit(1);
44
+ }
45
+ });
46
+
47
+ process.on("unhandledRejection", async () => {
48
+ try {
49
+ await fs.promises.unlink(path.join(os.tmpdir(), `rocksky-${did}.lock`));
50
+ process.exit(1);
51
+ } catch (error) {
52
+ logger.error`Error cleaning up Sync lock: ${error}`;
53
+ process.exit(1);
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,72 @@
1
+ import type { CacheResult, DidCache, DidDocument } from "@atproto/identity";
2
+ import type { Storage } from "unstorage";
3
+
4
+ const HOUR = 60e3 * 60;
5
+ const DAY = HOUR * 24;
6
+
7
+ type CacheVal = {
8
+ doc: DidDocument;
9
+ updatedAt: number;
10
+ };
11
+
12
+ /**
13
+ * An unstorage based DidCache with staleness and max TTL
14
+ */
15
+ export class StorageCache implements DidCache {
16
+ public staleTTL: number;
17
+ public maxTTL: number;
18
+ public cache: Storage<CacheVal>;
19
+ private prefix: string;
20
+ constructor({
21
+ store,
22
+ prefix,
23
+ staleTTL,
24
+ maxTTL,
25
+ }: {
26
+ store: Storage;
27
+ prefix: string;
28
+ staleTTL?: number;
29
+ maxTTL?: number;
30
+ }) {
31
+ this.cache = store as Storage<CacheVal>;
32
+ this.prefix = prefix;
33
+ this.staleTTL = staleTTL ?? HOUR;
34
+ this.maxTTL = maxTTL ?? DAY;
35
+ }
36
+
37
+ async cacheDid(did: string, doc: DidDocument): Promise<void> {
38
+ await this.cache.set(this.prefix + did, { doc, updatedAt: Date.now() });
39
+ }
40
+
41
+ async refreshCache(
42
+ did: string,
43
+ getDoc: () => Promise<DidDocument | null>,
44
+ ): Promise<void> {
45
+ const doc = await getDoc();
46
+ if (doc) {
47
+ await this.cacheDid(did, doc);
48
+ }
49
+ }
50
+
51
+ async checkCache(did: string): Promise<CacheResult | null> {
52
+ const val = await this.cache.get<CacheVal>(this.prefix + did);
53
+ if (!val) return null;
54
+ const now = Date.now();
55
+ const expired = now > val.updatedAt + this.maxTTL;
56
+ const stale = now > val.updatedAt + this.staleTTL;
57
+ return {
58
+ ...val,
59
+ did,
60
+ stale,
61
+ expired,
62
+ };
63
+ }
64
+
65
+ async clearEntry(did: string): Promise<void> {
66
+ await this.cache.remove(this.prefix + did);
67
+ }
68
+
69
+ async clear(): Promise<void> {
70
+ await this.cache.clear(this.prefix);
71
+ }
72
+ }
package/src/lib/env.ts ADDED
@@ -0,0 +1,25 @@
1
+ import dotenv from "dotenv";
2
+ import { cleanEnv, str } from "envalid";
3
+ import crypto from "node:crypto";
4
+ import { v4 as uuid } from "uuid";
5
+
6
+ dotenv.config();
7
+
8
+ export const env = cleanEnv(process.env, {
9
+ ROCKSKY_IDENTIFIER: str({ default: "" }),
10
+ ROCKSKY_HANDLE: str({ default: "" }),
11
+ ROCKSKY_PASSWORD: str({ default: "" }),
12
+ JETSTREAM_SERVER: str({
13
+ default: "wss://jetstream1.us-west.bsky.network/subscribe",
14
+ }),
15
+ ROCKSKY_API_KEY: str({ default: crypto.randomBytes(16).toString("hex") }),
16
+ ROCKSKY_SHARED_SECRET: str({
17
+ default: crypto.randomBytes(16).toString("hex"),
18
+ }),
19
+ ROCKSKY_SESSION_KEY: str({
20
+ default: crypto.randomBytes(16).toString("hex"),
21
+ }),
22
+ ROCKSKY_WEBSCROBBLER_KEY: str({
23
+ default: uuid(),
24
+ }),
25
+ });
@@ -0,0 +1,33 @@
1
+ export default async function extractPdsFromDid(
2
+ did: string,
3
+ ): Promise<string | null> {
4
+ let didDocUrl: string;
5
+
6
+ if (did.startsWith("did:plc:")) {
7
+ didDocUrl = `https://plc.directory/${did}`;
8
+ } else if (did.startsWith("did:web:")) {
9
+ const domain = did.substring("did:web:".length);
10
+ didDocUrl = `https://${domain}/.well-known/did.json`;
11
+ } else {
12
+ throw new Error("Unsupported DID method");
13
+ }
14
+
15
+ const response = await fetch(didDocUrl);
16
+ if (!response.ok) throw new Error("Failed to fetch DID doc");
17
+
18
+ const doc: {
19
+ service?: Array<{
20
+ type: string;
21
+ id: string;
22
+ serviceEndpoint: string;
23
+ }>;
24
+ } = await response.json();
25
+
26
+ // Find the atproto PDS service
27
+ const pdsService = doc.service?.find(
28
+ (s: any) =>
29
+ s.type === "AtprotoPersonalDataServer" && s.id.endsWith("#atproto_pds"),
30
+ );
31
+
32
+ return pdsService?.serviceEndpoint ?? null;
33
+ }
@@ -0,0 +1,39 @@
1
+ import { isValidHandle } from "@atproto/syntax";
2
+ import { env } from "./env";
3
+ import { logger } from "logger";
4
+ import { ctx } from "context";
5
+ import chalk from "chalk";
6
+
7
+ export async function getDidAndHandle(): Promise<[string, string]> {
8
+ let handle = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER;
9
+ let did = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER;
10
+
11
+ if (!handle) {
12
+ console.error(
13
+ `❌ No AT Proto handle or DID provided, please provide one in the environment variables ${chalk.bold("ROCKSKY_HANDLE")} or ${chalk.bold("ROCKSKY_IDENTIFIER")}`,
14
+ );
15
+ process.exit(1);
16
+ }
17
+
18
+ if (!env.ROCKSKY_PASSWORD) {
19
+ console.error(
20
+ `❌ No app password provided, please provide one in the environment variable ${chalk.bold("ROCKSKY_PASSWORD")}\nYou can create one at ${chalk.blueBright("https://bsky.app/settings/app-passwords")}`,
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ if (handle.startsWith("did:plc:") || handle.startsWith("did:web:")) {
26
+ handle = await ctx.resolver.resolveDidToHandle(handle);
27
+ }
28
+
29
+ if (!isValidHandle(handle)) {
30
+ logger.error`❌ Invalid handle: ${handle}`;
31
+ process.exit(1);
32
+ }
33
+
34
+ if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) {
35
+ did = await ctx.baseIdResolver.handle.resolve(did);
36
+ }
37
+
38
+ return [did, handle];
39
+ }
@@ -0,0 +1,52 @@
1
+ import { IdResolver } from "@atproto/identity";
2
+ import type { Storage } from "unstorage";
3
+ import { StorageCache } from "./didUnstorageCache";
4
+
5
+ const HOUR = 60e3 * 60;
6
+ const DAY = HOUR * 24;
7
+ const WEEK = HOUR * 7;
8
+
9
+ export function createIdResolver(kv: Storage) {
10
+ return new IdResolver({
11
+ didCache: new StorageCache({
12
+ store: kv,
13
+ prefix: "didCache:",
14
+ staleTTL: DAY,
15
+ maxTTL: WEEK,
16
+ }),
17
+ });
18
+ }
19
+
20
+ export interface BidirectionalResolver {
21
+ resolveDidToHandle(did: string): Promise<string>;
22
+ resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>;
23
+ }
24
+
25
+ export function createBidirectionalResolver(resolver: IdResolver) {
26
+ return {
27
+ async resolveDidToHandle(did: string): Promise<string> {
28
+ const didDoc = await resolver.did.resolveAtprotoData(did);
29
+
30
+ // asynchronously double check that the handle resolves back
31
+ resolver.handle.resolve(didDoc.handle).then((resolvedHandle) => {
32
+ if (resolvedHandle !== did) {
33
+ resolver.did.ensureResolve(did, true);
34
+ }
35
+ });
36
+ return didDoc?.handle ?? did;
37
+ },
38
+
39
+ async resolveDidsToHandles(
40
+ dids: string[],
41
+ ): Promise<Record<string, string>> {
42
+ const didHandleMap: Record<string, string> = {};
43
+ const resolves = await Promise.all(
44
+ dids.map((did) => this.resolveDidToHandle(did).catch((_) => did)),
45
+ );
46
+ for (let i = 0; i < dids.length; i++) {
47
+ didHandleMap[dids[i]] = resolves[i];
48
+ }
49
+ return didHandleMap;
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,26 @@
1
+ import { env } from "lib/env";
2
+ import crypto from "node:crypto";
3
+
4
+ export function generateLastfmSignature(
5
+ params: Record<string, string>,
6
+ ): string {
7
+ const sortedKeys = Object.keys(params).sort();
8
+ let signatureString = "";
9
+ for (const key of sortedKeys) {
10
+ if (key !== "format" && key !== "callback") {
11
+ signatureString += key + params[key];
12
+ }
13
+ }
14
+ signatureString += env.ROCKSKY_SHARED_SECRET;
15
+ return crypto.createHash("md5").update(signatureString, "utf8").digest("hex");
16
+ }
17
+
18
+ export function validateLastfmSignature(
19
+ params: Record<string, string>,
20
+ ): boolean {
21
+ const providedSignature = params.api_sig;
22
+ if (!providedSignature) return false;
23
+
24
+ const expectedSignature = generateLastfmSignature(params);
25
+ return providedSignature === expectedSignature;
26
+ }
@@ -0,0 +1,47 @@
1
+ import { RockskyClient } from "client";
2
+ import { ctx } from "context";
3
+ import { logger } from "logger";
4
+ import { SelectTrack } from "schema/tracks";
5
+
6
+ export type MusicBrainzArtist = {
7
+ mbid: string;
8
+ name: string;
9
+ };
10
+
11
+ export type MatchTrackResult = SelectTrack & {
12
+ genres: string[] | null;
13
+ artistPicture: string | null;
14
+ releaseDate: string | null;
15
+ year: number | null;
16
+ mbArtists: MusicBrainzArtist[] | null;
17
+ };
18
+
19
+ export async function matchTrack(
20
+ track: string,
21
+ artist: string,
22
+ ): Promise<MatchTrackResult | null> {
23
+ let match;
24
+ const cached = await ctx.kv.getItem(`${track} - ${artist}`);
25
+ const client = new RockskyClient();
26
+
27
+ if (cached) {
28
+ match = cached;
29
+ client.matchSong(track, artist).then((newMatch) => {
30
+ if (newMatch) {
31
+ ctx.kv.setItem(`${track} - ${artist}`.toLowerCase(), newMatch);
32
+ }
33
+ });
34
+ } else {
35
+ match = await client.matchSong(track, artist);
36
+ await ctx.kv.setItem(`${track} - ${artist}`.toLowerCase(), match);
37
+ }
38
+
39
+ if (!match.title || !match.artist) {
40
+ logger.error`Failed to match track ${track} by ${artist}`;
41
+ return null;
42
+ }
43
+
44
+ logger.info`💿 Matched track ${match.title} by ${match.artist}`;
45
+
46
+ return match;
47
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
2
+
3
+ await configure({
4
+ sinks: {
5
+ console: getConsoleSink(),
6
+ meta: getConsoleSink(),
7
+ },
8
+ loggers: [
9
+ { category: "@rocksky/cli", lowestLevel: "debug", sinks: ["console"] },
10
+ {
11
+ category: ["logtape", "meta"],
12
+ lowestLevel: "warning",
13
+ sinks: ["meta"],
14
+ },
15
+ ],
16
+ });
17
+
18
+ export const logger = getLogger("@rocksky/cli");