@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,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1768065262210,
9
+ "tag": "0000_parallel_paper_doll",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+ import envpaths from "env-paths";
3
+ import fs from "node:fs";
4
+ import chalk from "chalk";
5
+
6
+ fs.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
7
+ const url = `${envpaths("rocksky", { suffix: "" }).data}/rocksky.sqlite`;
8
+
9
+ console.log(`Database URL: ${chalk.greenBright(url)}`);
10
+
11
+ export default defineConfig({
12
+ dialect: "sqlite",
13
+ schema: "./src/schema",
14
+ out: "./drizzle",
15
+ dbCredentials: {
16
+ url,
17
+ },
18
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -8,9 +8,13 @@
8
8
  "rocksky": "./dist/index.js"
9
9
  },
10
10
  "scripts": {
11
+ "lexgen": "lex gen-server ./src/lexicon ./lexicons/**/* ./lexicons/*",
11
12
  "test": "echo \"Error: no test specified\" && exit 1",
12
13
  "dev": "tsx ./src/index.ts",
13
- "build": "pkgroll && chmod +x ./dist/index.js"
14
+ "build": "pkgroll && chmod +x ./dist/index.js && cp -r drizzle ./dist",
15
+ "db:generate": "drizzle-kit generate",
16
+ "db:migrate": "drizzle-kit migrate",
17
+ "db:studio": "drizzle-kit studio"
14
18
  },
15
19
  "keywords": [
16
20
  "audioscrobbler",
@@ -22,15 +26,41 @@
22
26
  "author": "Tsiry Sandratraina <tsiry.sndr@rocksky.app>",
23
27
  "license": "Apache-2.0",
24
28
  "dependencies": {
29
+ "@atproto/api": "^0.13.31",
30
+ "@atproto/common": "^0.4.6",
31
+ "@atproto/identity": "^0.4.5",
32
+ "@atproto/jwk-jose": "0.1.5",
33
+ "@atproto/lex-cli": "^0.5.6",
34
+ "@atproto/lexicon": "^0.4.5",
35
+ "@atproto/sync": "^0.1.11",
36
+ "@atproto/syntax": "^0.3.1",
37
+ "@hono/node-server": "^1.13.8",
38
+ "@logtape/logtape": "^1.3.6",
39
+ "@modelcontextprotocol/sdk": "^1.10.2",
40
+ "@paralleldrive/cuid2": "^3.0.6",
41
+ "@types/better-sqlite3": "^7.6.13",
25
42
  "axios": "^1.8.4",
43
+ "better-sqlite3": "^12.4.1",
26
44
  "chalk": "^5.4.1",
27
45
  "commander": "^13.1.0",
28
46
  "cors": "^2.8.5",
29
47
  "dayjs": "^1.11.13",
48
+ "dotenv": "^16.4.7",
49
+ "drizzle-kit": "^0.31.1",
50
+ "drizzle-orm": "^0.45.1",
51
+ "effect": "^3.19.14",
52
+ "env-paths": "^3.0.0",
53
+ "envalid": "^8.0.0",
30
54
  "express": "^5.1.0",
55
+ "hono": "^4.4.7",
56
+ "kysely": "^0.27.5",
57
+ "lodash": "^4.17.21",
31
58
  "md5": "^2.3.0",
32
59
  "open": "^10.1.0",
33
- "table": "^6.9.0"
60
+ "table": "^6.9.0",
61
+ "unstorage": "^1.14.4",
62
+ "uuid": "^13.0.0",
63
+ "zod": "^3.24.3"
34
64
  },
35
65
  "devDependencies": {
36
66
  "@types/express": "^5.0.1",
@@ -44,4 +74,4 @@
44
74
  "import": "./dist/index.js"
45
75
  }
46
76
  }
47
- }
77
+ }
package/src/client.ts CHANGED
@@ -35,12 +35,12 @@ export class RockskyClient {
35
35
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
36
36
  "Content-Type": "application/json",
37
37
  },
38
- }
38
+ },
39
39
  );
40
40
 
41
41
  if (!response.ok) {
42
42
  throw new Error(
43
- `Failed to fetch now playing data: ${response.statusText}`
43
+ `Failed to fetch now playing data: ${response.statusText}`,
44
44
  );
45
45
  }
46
46
 
@@ -56,12 +56,12 @@ export class RockskyClient {
56
56
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
57
57
  "Content-Type": "application/json",
58
58
  },
59
- }
59
+ },
60
60
  );
61
61
 
62
62
  if (!response.ok) {
63
63
  throw new Error(
64
- `Failed to fetch now playing data: ${response.statusText}`
64
+ `Failed to fetch now playing data: ${response.statusText}`,
65
65
  );
66
66
  }
67
67
 
@@ -78,11 +78,11 @@ export class RockskyClient {
78
78
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
79
79
  "Content-Type": "application/json",
80
80
  },
81
- }
81
+ },
82
82
  );
83
83
  if (!response.ok) {
84
84
  throw new Error(
85
- `Failed to fetch scrobbles data: ${response.statusText}`
85
+ `Failed to fetch scrobbles data: ${response.statusText}`,
86
86
  );
87
87
  }
88
88
  return response.json();
@@ -96,7 +96,7 @@ export class RockskyClient {
96
96
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
97
97
  "Content-Type": "application/json",
98
98
  },
99
- }
99
+ },
100
100
  );
101
101
  if (!response.ok) {
102
102
  throw new Error(`Failed to fetch scrobbles data: ${response.statusText}`);
@@ -107,14 +107,14 @@ export class RockskyClient {
107
107
 
108
108
  async search(query: string, { size }) {
109
109
  const response = await fetch(
110
- `${ROCKSKY_API_URL}/search?q=${query}&size=${size}`,
110
+ `${ROCKSKY_API_URL}/xrpc/app.rocksky.feed.search?query=${query}&size=${size}`,
111
111
  {
112
112
  method: "GET",
113
113
  headers: {
114
114
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
115
115
  "Content-Type": "application/json",
116
116
  },
117
- }
117
+ },
118
118
  );
119
119
 
120
120
  if (!response.ok) {
@@ -176,7 +176,7 @@ export class RockskyClient {
176
176
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
177
177
  "Content-Type": "application/json",
178
178
  },
179
- }
179
+ },
180
180
  );
181
181
  if (!response.ok) {
182
182
  throw new Error(`Failed to fetch artists data: ${response.statusText}`);
@@ -207,7 +207,7 @@ export class RockskyClient {
207
207
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
208
208
  "Content-Type": "application/json",
209
209
  },
210
- }
210
+ },
211
211
  );
212
212
  if (!response.ok) {
213
213
  throw new Error(`Failed to fetch albums data: ${response.statusText}`);
@@ -238,7 +238,7 @@ export class RockskyClient {
238
238
  Authorization: this.token ? `Bearer ${this.token}` : undefined,
239
239
  "Content-Type": "application/json",
240
240
  },
241
- }
241
+ },
242
242
  );
243
243
  if (!response.ok) {
244
244
  throw new Error(`Failed to fetch tracks data: ${response.statusText}`);
@@ -252,7 +252,7 @@ export class RockskyClient {
252
252
  await fs.promises.access(tokenPath);
253
253
  } catch (err) {
254
254
  console.error(
255
- `You are not logged in. Please run the login command first.`
255
+ `You are not logged in. Please run the login command first.`,
256
256
  );
257
257
  return;
258
258
  }
@@ -279,7 +279,7 @@ export class RockskyClient {
279
279
  throw new Error(
280
280
  `Failed to scrobble track: ${
281
281
  response.statusText
282
- } ${await response.text()}`
282
+ } ${await response.text()}`,
283
283
  );
284
284
  }
285
285
 
@@ -321,4 +321,22 @@ export class RockskyClient {
321
321
 
322
322
  return response.json();
323
323
  }
324
+
325
+ async matchSong(title: string, artist: string) {
326
+ const q = new URLSearchParams({
327
+ title,
328
+ artist,
329
+ });
330
+ const response = await fetch(
331
+ `${ROCKSKY_API_URL}/xrpc/app.rocksky.song.matchSong?${q.toString()}`,
332
+ );
333
+
334
+ if (!response.ok) {
335
+ throw new Error(
336
+ `Failed to match song: ${response.statusText} ${await response.text()}`,
337
+ );
338
+ }
339
+
340
+ return response.json();
341
+ }
324
342
  }
package/src/cmd/mcp.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { rockskyMcpServer } from "mcp";
2
+
3
+ export function mcp() {
4
+ rockskyMcpServer.run().catch((error) => {
5
+ console.error("Failed to run Rocksky MCP server", { error });
6
+ process.exit(1);
7
+ });
8
+ }
@@ -0,0 +1,457 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "hono/logger";
3
+ import { cors } from "hono/cors";
4
+ import { serve } from "@hono/node-server";
5
+ import { env } from "lib/env";
6
+ import chalk from "chalk";
7
+ import { logger as log } from "logger";
8
+ import { getDidAndHandle } from "lib/getDidAndHandle";
9
+ import { WebScrobbler, Listenbrainz, Lastfm } from "types";
10
+ import { matchTrack } from "lib/matchTrack";
11
+ import _ from "lodash";
12
+ import { publishScrobble } from "scrobble";
13
+ import { validateLastfmSignature } from "lib/lastfm";
14
+
15
+ export async function scrobbleApi({ port }) {
16
+ const [, handle] = await getDidAndHandle();
17
+ const app = new Hono();
18
+
19
+ if (
20
+ !process.env.ROCKSKY_API_KEY ||
21
+ !process.env.ROCKSKY_SHARED_SECRET ||
22
+ !process.env.ROCKSKY_SESSION_KEY
23
+ ) {
24
+ console.log(`ROCKSKY_API_KEY: ${env.ROCKSKY_API_KEY}`);
25
+ console.log(`ROCKSKY_SHARED_SECRET: ${env.ROCKSKY_SHARED_SECRET}`);
26
+ console.log(`ROCKSKY_SESSION_KEY: ${env.ROCKSKY_SESSION_KEY}`);
27
+ } else {
28
+ console.log(
29
+ "ROCKSKY_API_KEY, ROCKSKY_SHARED_SECRET and ROCKSKY_SESSION_KEY are set from environment variables",
30
+ );
31
+ }
32
+
33
+ if (!process.env.ROCKSKY_WEBSCROBBLER_KEY) {
34
+ console.log(`ROCKSKY_WEBSCROBBLER_KEY: ${env.ROCKSKY_WEBSCROBBLER_KEY}`);
35
+ } else {
36
+ console.log("ROCKSKY_WEBSCROBBLER_KEY is set from environment variables");
37
+ }
38
+
39
+ const BANNER = `
40
+ ____ __ __
41
+ / __ \\____ _____/ /_______/ /____ __
42
+ / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / /
43
+ / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ /
44
+ /_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, /
45
+ /____/
46
+ `;
47
+
48
+ console.log(chalk.cyanBright(BANNER));
49
+
50
+ app.use(logger());
51
+ app.use(cors());
52
+
53
+ app.get("/", (c) =>
54
+ c.text(
55
+ `${BANNER}\nWelcome to the lastfm/listenbrainz/webscrobbler compatibility API\n`,
56
+ ),
57
+ );
58
+
59
+ app.post("/nowplaying", async (c) => {
60
+ const formData = await c.req.parseBody();
61
+ const params = Object.fromEntries(
62
+ Object.entries(formData).map(([k, v]) => [k, String(v)]),
63
+ );
64
+
65
+ if (params.s !== env.ROCKSKY_SESSION_KEY) {
66
+ return c.text("BADSESSION\n");
67
+ }
68
+
69
+ const {
70
+ data: nowPlaying,
71
+ success,
72
+ error,
73
+ } = Lastfm.LegacyNowPlayingRequestSchema.safeParse(params);
74
+
75
+ if (!success) {
76
+ return c.text(`FAILED Invalid request: ${error}\n`);
77
+ }
78
+
79
+ log.info`Legacy API - Now playing: ${nowPlaying.t} by ${nowPlaying.a}`;
80
+
81
+ return c.text("OK\n");
82
+ });
83
+
84
+ app.post("/submission", async (c) => {
85
+ const formData = await c.req.parseBody();
86
+ const params = Object.fromEntries(
87
+ Object.entries(formData).map(([k, v]) => [k, String(v)]),
88
+ );
89
+
90
+ if (params.s !== env.ROCKSKY_SESSION_KEY) {
91
+ return c.text("BADSESSION\n");
92
+ }
93
+
94
+ const {
95
+ data: submission,
96
+ success,
97
+ error,
98
+ } = Lastfm.LegacySubmissionRequestSchema.safeParse(params);
99
+
100
+ if (!success) {
101
+ return c.text(`FAILED Invalid request: ${error}\n`);
102
+ }
103
+
104
+ log.info`Legacy API - Received scrobble: ${submission["t[0]"]} by ${submission["a[0]"]}`;
105
+
106
+ // Process scrobble asynchronously
107
+ (async () => {
108
+ const track = submission["t[0]"];
109
+ const artist = submission["a[0]"];
110
+ const timestamp = parseInt(submission["i[0]"]);
111
+
112
+ const match = await matchTrack(track, artist);
113
+
114
+ if (!match) {
115
+ log.warn`No match found for ${track} by ${artist}`;
116
+ return;
117
+ }
118
+
119
+ await publishScrobble(match, timestamp);
120
+ })().catch((err) => {
121
+ log.error`Error processing legacy API scrobble: ${err}`;
122
+ });
123
+
124
+ return c.text("OK\n");
125
+ });
126
+
127
+ app.get("/2.0", async (c) => {
128
+ const params = Object.fromEntries(
129
+ Object.entries(c.req.query()).map(([k, v]) => [k, String(v)]),
130
+ );
131
+
132
+ if (params.method === "auth.getSession") {
133
+ if (params.api_key !== env.ROCKSKY_API_KEY) {
134
+ return c.json({
135
+ error: 10,
136
+ message: "Invalid API key",
137
+ });
138
+ }
139
+
140
+ if (!validateLastfmSignature(params)) {
141
+ return c.json({
142
+ error: 13,
143
+ message: "Invalid method signature supplied",
144
+ });
145
+ }
146
+
147
+ return c.json({
148
+ session: {
149
+ name: handle,
150
+ key: env.ROCKSKY_SESSION_KEY,
151
+ subscriber: 0,
152
+ },
153
+ });
154
+ }
155
+
156
+ return c.text(`${BANNER}\nWelcome to the lastfm compatibility API\n`);
157
+ });
158
+
159
+ app.post("/2.0", async (c) => {
160
+ const contentType = c.req.header("content-type");
161
+ let params: Record<string, string> = {};
162
+
163
+ if (contentType?.includes("application/x-www-form-urlencoded")) {
164
+ const formData = await c.req.parseBody();
165
+ params = Object.fromEntries(
166
+ Object.entries(formData).map(([k, v]) => [k, String(v)]),
167
+ );
168
+ } else {
169
+ params = await c.req.json();
170
+ }
171
+
172
+ log.info`Received Last.fm API request: method=${params.method}`;
173
+
174
+ if (params.api_key !== env.ROCKSKY_API_KEY) {
175
+ return c.json({
176
+ error: 10,
177
+ message: "Invalid API key",
178
+ });
179
+ }
180
+
181
+ if (!validateLastfmSignature(params)) {
182
+ return c.json({
183
+ error: 13,
184
+ message: "Invalid method signature supplied",
185
+ });
186
+ }
187
+
188
+ if (params.method === "auth.getSession") {
189
+ return c.json({
190
+ session: {
191
+ name: handle,
192
+ key: env.ROCKSKY_SESSION_KEY,
193
+ subscriber: 0,
194
+ },
195
+ });
196
+ }
197
+
198
+ if (params.method === "track.updateNowPlaying") {
199
+ // Validate session key
200
+ if (params.sk !== env.ROCKSKY_SESSION_KEY) {
201
+ return c.json({
202
+ error: 9,
203
+ message: "Invalid session key",
204
+ });
205
+ }
206
+
207
+ log.info`Now playing: ${params.track} by ${params.artist}`;
208
+ return c.json({
209
+ nowplaying: {
210
+ artist: { "#text": params.artist },
211
+ track: { "#text": params.track },
212
+ album: { "#text": params.album || "" },
213
+ ignoredMessage: { code: "0", "#text": "" },
214
+ },
215
+ });
216
+ }
217
+
218
+ if (params.method === "track.scrobble") {
219
+ // Validate session key
220
+ if (params.sk !== env.ROCKSKY_SESSION_KEY) {
221
+ return c.json({
222
+ error: 9,
223
+ message: "Invalid session key",
224
+ });
225
+ }
226
+
227
+ const track = params["track[0]"] || params.track;
228
+ const artist = params["artist[0]"] || params.artist;
229
+ const timestamp = params["timestamp[0]"] || params.timestamp;
230
+
231
+ log.info`Received Last.fm scrobble: ${track} by ${artist}`;
232
+
233
+ // Process scrobble asynchronously
234
+ (async () => {
235
+ const match = await matchTrack(track, artist);
236
+
237
+ if (!match) {
238
+ log.warn`No match found for ${track} by ${artist}`;
239
+ return;
240
+ }
241
+
242
+ const ts = timestamp
243
+ ? parseInt(timestamp)
244
+ : Math.floor(Date.now() / 1000);
245
+ await publishScrobble(match, ts);
246
+ })().catch((err) => {
247
+ log.error`Error processing Last.fm scrobble: ${err}`;
248
+ });
249
+
250
+ return c.json({
251
+ scrobbles: {
252
+ "@attr": {
253
+ accepted: 1,
254
+ ignored: 0,
255
+ },
256
+ scrobble: {
257
+ artist: { "#text": artist },
258
+ track: { "#text": track },
259
+ album: { "#text": params["album[0]"] || params.album || "" },
260
+ timestamp: timestamp || String(Math.floor(Date.now() / 1000)),
261
+ ignoredMessage: { code: "0", "#text": "" },
262
+ },
263
+ },
264
+ });
265
+ }
266
+
267
+ return c.json({
268
+ error: 3,
269
+ message: "Invalid method",
270
+ });
271
+ });
272
+
273
+ app.post("/1/submit-listens", async (c) => {
274
+ const authHeader = c.req.header("Authorization");
275
+
276
+ if (!authHeader || !authHeader.startsWith("Token ")) {
277
+ return c.json(
278
+ {
279
+ code: 401,
280
+ error: "Unauthorized",
281
+ },
282
+ 401,
283
+ );
284
+ }
285
+
286
+ const token = authHeader.substring(6); // Remove "Token " prefix
287
+ if (token !== env.ROCKSKY_API_KEY) {
288
+ return c.json(
289
+ {
290
+ code: 401,
291
+ error: "Invalid token",
292
+ },
293
+ 401,
294
+ );
295
+ }
296
+
297
+ const body = await c.req.json();
298
+ const {
299
+ data: submitRequest,
300
+ success,
301
+ error,
302
+ } = Listenbrainz.SubmitListensRequestSchema.safeParse(body);
303
+
304
+ if (!success) {
305
+ return c.json(
306
+ {
307
+ code: 400,
308
+ error: `Invalid request body: ${error}`,
309
+ },
310
+ 400,
311
+ );
312
+ }
313
+
314
+ log.info`Received ListenBrainz submit-listens request with ${submitRequest.payload.length} payload(s)`;
315
+
316
+ if (submitRequest.listen_type !== "single") {
317
+ log.info`Skipping listen_type: ${submitRequest.listen_type} (only "single" is processed)`;
318
+ return c.json({
319
+ status: "ok",
320
+ payload: {
321
+ submitted_listens: 0,
322
+ ignored_listens: 1,
323
+ },
324
+ code: 200,
325
+ });
326
+ }
327
+
328
+ // Process scrobbles asynchronously to avoid timeout
329
+ (async () => {
330
+ for (const listen of submitRequest.payload) {
331
+ const title = listen.track_metadata.track_name;
332
+ const artist = listen.track_metadata.artist_name;
333
+
334
+ log.info`Processing listen: ${title} by ${artist}`;
335
+
336
+ const match = await matchTrack(title, artist);
337
+
338
+ if (!match) {
339
+ log.warn`No match found for ${title} by ${artist}`;
340
+ continue;
341
+ }
342
+
343
+ const timestamp = listen.listened_at || Math.floor(Date.now() / 1000);
344
+ await publishScrobble(match, timestamp);
345
+ }
346
+ })().catch((err) => {
347
+ log.error`Error processing ListenBrainz scrobbles: ${err}`;
348
+ });
349
+
350
+ return c.json({
351
+ status: "ok",
352
+ code: 200,
353
+ });
354
+ });
355
+
356
+ app.get("/1/validate-token", (c) => {
357
+ const authHeader = c.req.header("Authorization");
358
+
359
+ if (!authHeader || !authHeader.startsWith("Token ")) {
360
+ return c.json({
361
+ code: 401,
362
+ message: "Unauthorized",
363
+ valid: false,
364
+ });
365
+ }
366
+
367
+ const token = authHeader.substring(6); // Remove "Token " prefix
368
+ if (token !== env.ROCKSKY_API_KEY) {
369
+ return c.json({
370
+ code: 401,
371
+ message: "Invalid token",
372
+ valid: false,
373
+ });
374
+ }
375
+
376
+ return c.json({
377
+ code: 200,
378
+ message: "Token valid.",
379
+ valid: true,
380
+ user_name: handle,
381
+ permissions: ["recording-metadata-write", "recording-metadata-read"],
382
+ });
383
+ });
384
+
385
+ app.get("/1/search/users", (c) => {
386
+ return c.json([]);
387
+ });
388
+
389
+ app.get("/1/user/:username/listens", (c) => {
390
+ return c.json([]);
391
+ });
392
+
393
+ app.get("/1/user/:username/listen-count", (c) => {
394
+ return c.json({});
395
+ });
396
+
397
+ app.get("/1/user/:username/playing-now", (c) => {
398
+ return c.json({});
399
+ });
400
+
401
+ app.get("/1/stats/user/:username/artists", (c) => {
402
+ return c.json({});
403
+ });
404
+
405
+ app.get("/1/stats/user/:username}/releases", (c) => {
406
+ return c.json({});
407
+ });
408
+
409
+ app.get("/1/stats/user/:username/recordings", (c) => {
410
+ return c.json([]);
411
+ });
412
+
413
+ app.get("/1/stats/user/:username/release-groups", (c) => {
414
+ return c.json([]);
415
+ });
416
+
417
+ app.get("/1/stats/user/:username/recordings", (c) => {
418
+ return c.json({});
419
+ });
420
+
421
+ app.post("/webscrobbler/:uuid", async (c) => {
422
+ const { uuid } = c.req.param();
423
+ if (uuid !== env.ROCKSKY_WEBSCROBBLER_KEY) {
424
+ return c.text("Invalid UUID", 401);
425
+ }
426
+
427
+ const body = await c.req.json();
428
+ const {
429
+ data: scrobble,
430
+ success,
431
+ error,
432
+ } = WebScrobbler.ScrobbleRequestSchema.safeParse(body);
433
+
434
+ if (!success) {
435
+ return c.text(`Invalid request body: ${error}`, 400);
436
+ }
437
+
438
+ log.info`Received scrobble request: \n ${scrobble}`;
439
+
440
+ const title = scrobble.data?.song?.parsed?.track;
441
+ const artist = scrobble.data?.song?.parsed?.artist;
442
+ const match = await matchTrack(title, artist);
443
+
444
+ if (!match) {
445
+ log.warn`No match found for ${title} by ${artist}`;
446
+ return c.text("No match found", 200);
447
+ }
448
+
449
+ await publishScrobble(match, scrobble.time);
450
+
451
+ return c.text("Scrobble received");
452
+ });
453
+
454
+ log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
455
+
456
+ serve({ fetch: app.fetch, port });
457
+ }