···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 } from "types";
10+import { matchTrack } from "lib/matchTrack";
11+import _ from "lodash";
12+import { publishScrobble } from "scrobble";
13+14+export async function scrobbleApi({ port }) {
15+ await getDidAndHandle();
16+ const app = new Hono();
17+18+ if (!process.env.ROCKSKY_API_KEY || !process.env.ROCKSKY_SHARED_SECRET) {
19+ console.log(`ROCKSKY_API_KEY: ${env.ROCKSKY_API_KEY}`);
20+ console.log(`ROCKSKY_SHARED_SECRET: ${env.ROCKSKY_SHARED_SECRET}`);
21+ } else {
22+ console.log(
23+ "ROCKSKY_API_KEY and ROCKSKY_SHARED_SECRET are set from environment variables",
24+ );
25+ }
26+27+ if (!process.env.ROCKSKY_WEBSCROBBLER_KEY) {
28+ console.log(`ROCKSKY_WEBSCROBBLER_KEY: ${env.ROCKSKY_WEBSCROBBLER_KEY}`);
29+ } else {
30+ console.log("ROCKSKY_WEBSCROBBLER_KEY is set from environment variables");
31+ }
32+33+ const BANNER = `
34+ ____ __ __
35+ / __ \\____ _____/ /_______/ /____ __
36+ / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / /
37+ / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ /
38+/_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, /
39+ /____/
40+ `;
41+42+ console.log(chalk.cyanBright(BANNER));
43+44+ app.use(logger());
45+ app.use(cors());
46+47+ app.get("/", (c) =>
48+ c.text(
49+ `${BANNER}\nWelcome to the lastfm/listenbrainz/webscrobbler compatibility API\n`,
50+ ),
51+ );
52+53+ app.post("/webscrobbler/:uuid", async (c) => {
54+ const { uuid } = c.req.param();
55+ if (uuid !== env.ROCKSKY_WEBSCROBBLER_KEY) {
56+ return c.text("Invalid UUID", 401);
57+ }
58+59+ const body = await c.req.json();
60+ const {
61+ data: scrobble,
62+ success,
63+ error,
64+ } = WebScrobbler.ScrobbleRequestSchema.safeParse(body);
65+66+ if (!success) {
67+ return c.text(`Invalid request body: ${error}`, 400);
68+ }
69+70+ log.info`Received scrobble request: \n ${scrobble}`;
71+72+ const title = scrobble.data?.song?.parsed?.track;
73+ const artist = scrobble.data?.song?.parsed?.artist;
74+ const match = await matchTrack(title, artist);
75+76+ if (!match) {
77+ log.warn`No match found for ${title} by ${artist}`;
78+ return c.text("No match found", 200);
79+ }
80+81+ await publishScrobble(match, scrobble.time);
82+83+ return c.text("Scrobble received");
84+ });
85+86+ log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
87+88+ serve({ fetch: app.fetch, port });
89+}
+27-20
apps/cli/src/index.ts
···17import { login } from "./cmd/login";
18import { sync } from "cmd/sync";
19import { initializeDatabase } from "./drizzle";
02021await initializeDatabase();
22···57program
58 .command("login")
59 .argument("<handle>", "your AT Proto handle (e.g., <username>.bsky.social)")
60- .description("login with your AT Proto account and get a session token.")
61 .action(login);
6263program
64 .command("whoami")
65- .description("get the current logged-in user.")
66 .action(whoami);
6768program
69 .command("nowplaying")
70 .argument(
71 "[did]",
72- "the DID or handle of the user to get the now playing track for.",
73 )
74- .description("get the currently playing track.")
75 .action(nowplaying);
7677program
78 .command("scrobbles")
79 .option("-s, --skip <number>", "number of scrobbles to skip")
80 .option("-l, --limit <number>", "number of scrobbles to limit")
81- .argument("[did]", "the DID or handle of the user to get the scrobbles for.")
82- .description("display recently played tracks.")
83 .action(scrobbles);
8485program
···92 "<query>",
93 "the search query, e.g., artist, album, title or account",
94 )
95- .description("search for tracks, albums, or accounts.")
96 .action(search);
9798program
99 .command("stats")
100 .option("-l, --limit <number>", "number of results to limit")
101- .argument("[did]", "the DID or handle of the user to get stats for.")
102- .description("get the user's listening stats.")
103 .action(stats);
104105program
106 .command("artists")
107 .option("-l, --limit <number>", "number of results to limit")
108- .argument("[did]", "the DID or handle of the user to get artists for.")
109- .description("get the user's top artists.")
110 .action(artists);
111112program
113 .command("albums")
114 .option("-l, --limit <number>", "number of results to limit")
115- .argument("[did]", "the DID or handle of the user to get albums for.")
116- .description("get the user's top albums.")
117 .action(albums);
118119program
120 .command("tracks")
121 .option("-l, --limit <number>", "number of results to limit")
122- .argument("[did]", "the DID or handle of the user to get tracks for.")
123- .description("get the user's top tracks.")
124 .action(tracks);
125126program
···129 .argument("<artist>", "the artist of the track")
130 .option("-t, --timestamp <timestamp>", "the timestamp of the scrobble")
131 .option("-d, --dry-run", "simulate the scrobble without actually sending it")
132- .description("scrobble a track to your profile.")
133 .action(scrobble);
134135program
136 .command("create")
137- .description("create a new API key.")
138 .command("apikey")
139 .argument("<name>", "the name of the API key")
140 .option("-d, --description <description>", "the description of the API key")
141- .description("create a new API key.")
142 .action(createApiKey);
143144program
145 .command("mcp")
146- .description("starts an MCP server to use with Claude or other LLMs.")
147 .action(mcp);
148149program
150 .command("sync")
151- .description("sync your local Rocksky data from AT Protocol.")
152 .action(sync);
000000153154program.parse(process.argv);
···17import { login } from "./cmd/login";
18import { sync } from "cmd/sync";
19import { initializeDatabase } from "./drizzle";
20+import { scrobbleApi } from "cmd/scrobble-api";
2122await initializeDatabase();
23···58program
59 .command("login")
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")
62 .action(login);
6364program
65 .command("whoami")
66+ .description("get the current logged-in user")
67 .action(whoami);
6869program
70 .command("nowplaying")
71 .argument(
72 "[did]",
73+ "the DID or handle of the user to get the now playing track for",
74 )
75+ .description("get the currently playing track")
76 .action(nowplaying);
7778program
79 .command("scrobbles")
80 .option("-s, --skip <number>", "number of scrobbles to skip")
81 .option("-l, --limit <number>", "number of scrobbles to limit")
82+ .argument("[did]", "the DID or handle of the user to get the scrobbles for")
83+ .description("display recently played tracks")
84 .action(scrobbles);
8586program
···93 "<query>",
94 "the search query, e.g., artist, album, title or account",
95 )
96+ .description("search for tracks, albums, or accounts")
97 .action(search);
9899program
100 .command("stats")
101 .option("-l, --limit <number>", "number of results to limit")
102+ .argument("[did]", "the DID or handle of the user to get stats for")
103+ .description("get the user's listening stats")
104 .action(stats);
105106program
107 .command("artists")
108 .option("-l, --limit <number>", "number of results to limit")
109+ .argument("[did]", "the DID or handle of the user to get artists for")
110+ .description("get the user's top artists")
111 .action(artists);
112113program
114 .command("albums")
115 .option("-l, --limit <number>", "number of results to limit")
116+ .argument("[did]", "the DID or handle of the user to get albums for")
117+ .description("get the user's top albums")
118 .action(albums);
119120program
121 .command("tracks")
122 .option("-l, --limit <number>", "number of results to limit")
123+ .argument("[did]", "the DID or handle of the user to get tracks for")
124+ .description("get the user's top tracks")
125 .action(tracks);
126127program
···130 .argument("<artist>", "the artist of the track")
131 .option("-t, --timestamp <timestamp>", "the timestamp of the scrobble")
132 .option("-d, --dry-run", "simulate the scrobble without actually sending it")
133+ .description("scrobble a track to your profile")
134 .action(scrobble);
135136program
137 .command("create")
138+ .description("create a new API key")
139 .command("apikey")
140 .argument("<name>", "the name of the API key")
141 .option("-d, --description <description>", "the description of the API key")
142+ .description("create a new API key")
143 .action(createApiKey);
144145program
146 .command("mcp")
147+ .description("starts an MCP server to use with Claude or other LLMs")
148 .action(mcp);
149150program
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);
160161program.parse(process.argv);