···11+import { Hono } from "hono";
22+import { logger } from "hono/logger";
33+import { cors } from "hono/cors";
44+import { serve } from "@hono/node-server";
55+import { env } from "lib/env";
66+import chalk from "chalk";
77+import { logger as log } from "logger";
88+import { getDidAndHandle } from "lib/getDidAndHandle";
99+import { WebScrobbler } from "types";
1010+import { matchTrack } from "lib/matchTrack";
1111+import _ from "lodash";
1212+import { publishScrobble } from "scrobble";
1313+1414+export async function scrobbleApi({ port }) {
1515+ await getDidAndHandle();
1616+ const app = new Hono();
1717+1818+ if (!process.env.ROCKSKY_API_KEY || !process.env.ROCKSKY_SHARED_SECRET) {
1919+ console.log(`ROCKSKY_API_KEY: ${env.ROCKSKY_API_KEY}`);
2020+ console.log(`ROCKSKY_SHARED_SECRET: ${env.ROCKSKY_SHARED_SECRET}`);
2121+ } else {
2222+ console.log(
2323+ "ROCKSKY_API_KEY and ROCKSKY_SHARED_SECRET are set from environment variables",
2424+ );
2525+ }
2626+2727+ if (!process.env.ROCKSKY_WEBSCROBBLER_KEY) {
2828+ console.log(`ROCKSKY_WEBSCROBBLER_KEY: ${env.ROCKSKY_WEBSCROBBLER_KEY}`);
2929+ } else {
3030+ console.log("ROCKSKY_WEBSCROBBLER_KEY is set from environment variables");
3131+ }
3232+3333+ const BANNER = `
3434+ ____ __ __
3535+ / __ \\____ _____/ /_______/ /____ __
3636+ / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / /
3737+ / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ /
3838+/_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, /
3939+ /____/
4040+ `;
4141+4242+ console.log(chalk.cyanBright(BANNER));
4343+4444+ app.use(logger());
4545+ app.use(cors());
4646+4747+ app.get("/", (c) =>
4848+ c.text(
4949+ `${BANNER}\nWelcome to the lastfm/listenbrainz/webscrobbler compatibility API\n`,
5050+ ),
5151+ );
5252+5353+ app.post("/webscrobbler/:uuid", async (c) => {
5454+ const { uuid } = c.req.param();
5555+ if (uuid !== env.ROCKSKY_WEBSCROBBLER_KEY) {
5656+ return c.text("Invalid UUID", 401);
5757+ }
5858+5959+ const body = await c.req.json();
6060+ const {
6161+ data: scrobble,
6262+ success,
6363+ error,
6464+ } = WebScrobbler.ScrobbleRequestSchema.safeParse(body);
6565+6666+ if (!success) {
6767+ return c.text(`Invalid request body: ${error}`, 400);
6868+ }
6969+7070+ log.info`Received scrobble request: \n ${scrobble}`;
7171+7272+ const title = scrobble.data?.song?.parsed?.track;
7373+ const artist = scrobble.data?.song?.parsed?.artist;
7474+ const match = await matchTrack(title, artist);
7575+7676+ if (!match) {
7777+ log.warn`No match found for ${title} by ${artist}`;
7878+ return c.text("No match found", 200);
7979+ }
8080+8181+ await publishScrobble(match, scrobble.time);
8282+8383+ return c.text("Scrobble received");
8484+ });
8585+8686+ log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
8787+8888+ serve({ fetch: app.fetch, port });
8989+}
+27-20
apps/cli/src/index.ts
···1717import { login } from "./cmd/login";
1818import { sync } from "cmd/sync";
1919import { initializeDatabase } from "./drizzle";
2020+import { scrobbleApi } from "cmd/scrobble-api";
20212122await initializeDatabase();
2223···5758program
5859 .command("login")
5960 .argument("<handle>", "your AT Proto handle (e.g., <username>.bsky.social)")
6060- .description("login with your AT Proto account and get a session token.")
6161+ .description("login with your AT Proto account and get a session token")
6162 .action(login);
62636364program
6465 .command("whoami")
6565- .description("get the current logged-in user.")
6666+ .description("get the current logged-in user")
6667 .action(whoami);
67686869program
6970 .command("nowplaying")
7071 .argument(
7172 "[did]",
7272- "the DID or handle of the user to get the now playing track for.",
7373+ "the DID or handle of the user to get the now playing track for",
7374 )
7474- .description("get the currently playing track.")
7575+ .description("get the currently playing track")
7576 .action(nowplaying);
76777778program
7879 .command("scrobbles")
7980 .option("-s, --skip <number>", "number of scrobbles to skip")
8081 .option("-l, --limit <number>", "number of scrobbles to limit")
8181- .argument("[did]", "the DID or handle of the user to get the scrobbles for.")
8282- .description("display recently played tracks.")
8282+ .argument("[did]", "the DID or handle of the user to get the scrobbles for")
8383+ .description("display recently played tracks")
8384 .action(scrobbles);
84858586program
···9293 "<query>",
9394 "the search query, e.g., artist, album, title or account",
9495 )
9595- .description("search for tracks, albums, or accounts.")
9696+ .description("search for tracks, albums, or accounts")
9697 .action(search);
97989899program
99100 .command("stats")
100101 .option("-l, --limit <number>", "number of results to limit")
101101- .argument("[did]", "the DID or handle of the user to get stats for.")
102102- .description("get the user's listening stats.")
102102+ .argument("[did]", "the DID or handle of the user to get stats for")
103103+ .description("get the user's listening stats")
103104 .action(stats);
104105105106program
106107 .command("artists")
107108 .option("-l, --limit <number>", "number of results to limit")
108108- .argument("[did]", "the DID or handle of the user to get artists for.")
109109- .description("get the user's top artists.")
109109+ .argument("[did]", "the DID or handle of the user to get artists for")
110110+ .description("get the user's top artists")
110111 .action(artists);
111112112113program
113114 .command("albums")
114115 .option("-l, --limit <number>", "number of results to limit")
115115- .argument("[did]", "the DID or handle of the user to get albums for.")
116116- .description("get the user's top albums.")
116116+ .argument("[did]", "the DID or handle of the user to get albums for")
117117+ .description("get the user's top albums")
117118 .action(albums);
118119119120program
120121 .command("tracks")
121122 .option("-l, --limit <number>", "number of results to limit")
122122- .argument("[did]", "the DID or handle of the user to get tracks for.")
123123- .description("get the user's top tracks.")
123123+ .argument("[did]", "the DID or handle of the user to get tracks for")
124124+ .description("get the user's top tracks")
124125 .action(tracks);
125126126127program
···129130 .argument("<artist>", "the artist of the track")
130131 .option("-t, --timestamp <timestamp>", "the timestamp of the scrobble")
131132 .option("-d, --dry-run", "simulate the scrobble without actually sending it")
132132- .description("scrobble a track to your profile.")
133133+ .description("scrobble a track to your profile")
133134 .action(scrobble);
134135135136program
136137 .command("create")
137137- .description("create a new API key.")
138138+ .description("create a new API key")
138139 .command("apikey")
139140 .argument("<name>", "the name of the API key")
140141 .option("-d, --description <description>", "the description of the API key")
141141- .description("create a new API key.")
142142+ .description("create a new API key")
142143 .action(createApiKey);
143144144145program
145146 .command("mcp")
146146- .description("starts an MCP server to use with Claude or other LLMs.")
147147+ .description("starts an MCP server to use with Claude or other LLMs")
147148 .action(mcp);
148149149150program
150151 .command("sync")
151151- .description("sync your local Rocksky data from AT Protocol.")
152152+ .description("sync your local Rocksky data from AT Protocol")
152153 .action(sync);
154154+155155+program
156156+ .command("scrobble-api")
157157+ .description("start a local listenbrainz/lastfm compatibility server")
158158+ .option("-p, --port <port>", "the port to listen on", "8778")
159159+ .action(scrobbleApi);
153160154161program.parse(process.argv);