@rocksky/cli 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +270 -1
- package/TOOLS.md +194 -0
- package/bun.lock +28 -0
- package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/dist/drizzle/meta/0000_snapshot.json +1559 -0
- package/dist/drizzle/meta/_journal.json +13 -0
- package/dist/index.js +8718 -165
- package/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/drizzle/meta/0000_snapshot.json +1559 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +18 -0
- package/package.json +34 -4
- package/src/client.ts +32 -14
- package/src/cmd/mcp.ts +8 -0
- package/src/cmd/scrobble-api.ts +457 -0
- package/src/cmd/scrobble.ts +14 -61
- package/src/cmd/search.ts +27 -25
- package/src/cmd/sync.ts +812 -0
- package/src/cmd/whoami.ts +36 -7
- package/src/context.ts +24 -0
- package/src/drizzle.ts +53 -0
- package/src/index.ts +72 -23
- package/src/jetstream.ts +285 -0
- package/src/lexicon/index.ts +1321 -0
- package/src/lexicon/lexicons.ts +5453 -0
- package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
- package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
- package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
- package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
- package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
- package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
- package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
- package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
- package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
- package/src/lexicon/types/app/rocksky/album.ts +51 -0
- package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
- package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
- package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
- package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
- package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
- package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
- package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
- package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist.ts +41 -0
- package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
- package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
- package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
- package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
- package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
- package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
- package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
- package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
- package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
- package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
- package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
- package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
- package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
- package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
- package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
- package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
- package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
- package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
- package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
- package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
- package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
- package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
- package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
- package/src/lexicon/types/app/rocksky/like.ts +27 -0
- package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
- package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
- package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
- package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
- package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
- package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
- package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
- package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
- package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
- package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
- package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
- package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
- package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
- package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
- package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
- package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
- package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
- package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
- package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
- package/src/lexicon/types/app/rocksky/radio.ts +37 -0
- package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
- package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
- package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
- package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
- package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
- package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
- package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
- package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
- package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
- package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
- package/src/lexicon/types/app/rocksky/shout.ts +30 -0
- package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
- package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
- package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
- package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
- package/src/lexicon/types/app/rocksky/song.ts +74 -0
- package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
- package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
- package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
- package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
- package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
- package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
- package/src/lexicon/util.ts +13 -0
- package/src/lib/agent.ts +56 -0
- package/src/lib/cleanUpJetstreamLock.ts +66 -0
- package/src/lib/cleanUpSyncLock.ts +56 -0
- package/src/lib/didUnstorageCache.ts +72 -0
- package/src/lib/env.ts +25 -0
- package/src/lib/extractPdsFromDid.ts +33 -0
- package/src/lib/getDidAndHandle.ts +39 -0
- package/src/lib/idResolver.ts +52 -0
- package/src/lib/lastfm.ts +26 -0
- package/src/lib/matchTrack.ts +47 -0
- package/src/logger.ts +18 -0
- package/src/mcp/index.ts +269 -0
- package/src/mcp/tools/albums.ts +13 -0
- package/src/mcp/tools/artists.ts +17 -0
- package/src/mcp/tools/create.ts +27 -0
- package/src/mcp/tools/myscrobbles.ts +42 -0
- package/src/mcp/tools/nowplaying.ts +53 -0
- package/src/mcp/tools/scrobbles.ts +39 -0
- package/src/mcp/tools/search.ts +88 -0
- package/src/mcp/tools/stats.ts +40 -0
- package/src/mcp/tools/tracks.ts +15 -0
- package/src/mcp/tools/whoami.ts +27 -0
- package/src/schema/album-tracks.ts +30 -0
- package/src/schema/albums.ts +29 -0
- package/src/schema/artist-albums.ts +29 -0
- package/src/schema/artist-genres.ts +17 -0
- package/src/schema/artist-tracks.ts +29 -0
- package/src/schema/artists.ts +30 -0
- package/src/schema/auth-session.ts +18 -0
- package/src/schema/genres.ts +18 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/loved-tracks.ts +27 -0
- package/src/schema/scrobbles.ts +30 -0
- package/src/schema/tracks.ts +39 -0
- package/src/schema/user-albums.ts +31 -0
- package/src/schema/user-artists.ts +32 -0
- package/src/schema/user-tracks.ts +31 -0
- package/src/schema/users.ts +21 -0
- package/src/scrobble.ts +410 -0
- package/src/sqliteKv.ts +173 -0
- package/src/types.ts +308 -0
- package/tsconfig.json +26 -29
|
@@ -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.
|
|
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?
|
|
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,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
|
+
}
|