From 0753c49f8b9866c10f2f9e4916b8df2cddbb0c8e Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 11 Jan 2026 22:02:26 +0300 Subject: [PATCH] Add scrobble API and multi-artist Spotify search --- .../src/xrpc/app/rocksky/song/matchSong.ts | 19 ++- apps/cli/package.json | 2 + apps/cli/src/cmd/scrobble-api.ts | 89 +++++++++++++ apps/cli/src/index.ts | 47 ++++--- apps/cli/src/lib/env.ts | 9 ++ apps/cli/src/types.ts | 119 ++++++++++++++++++ bun.lock | 5 + 7 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 apps/cli/src/cmd/scrobble-api.ts create mode 100644 apps/cli/src/types.ts diff --git a/apps/api/src/xrpc/app/rocksky/song/matchSong.ts b/apps/api/src/xrpc/app/rocksky/song/matchSong.ts index f89e3853..b0e551d7 100644 --- a/apps/api/src/xrpc/app/rocksky/song/matchSong.ts +++ b/apps/api/src/xrpc/app/rocksky/song/matchSong.ts @@ -266,7 +266,24 @@ const searchOnSpotify = async ( access_token: string; }; - const q = `q=track:"${encodeURIComponent(title)}"%20artist:"${encodeURIComponent(artist)}"&type=track`; + let q = `q=track:"${encodeURIComponent(title)}"%20artist:"${encodeURIComponent(artist)}"&type=track`; + + if (artist.includes(", ")) { + const artists = artist + .split(", ") + .map((a) => `artist:"${encodeURIComponent(a.trim())}"`) + .join(" "); + q = `q=track:"${encodeURIComponent(title)}" ${artists}&type=track`; + } + + if (artist.includes(" x ")) { + const artists = artist + .split(" x ") + .map((a) => `artist:"${encodeURIComponent(a.trim())}"`) + .join(" "); + q = `q=track:"${encodeURIComponent(title)}" ${artists}&type=track`; + } + const response = await fetch(`https://api.spotify.com/v1/search?${q}`, { method: "GET", headers: { diff --git a/apps/cli/package.json b/apps/cli/package.json index 1de58e0d..a474d4e9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,6 +34,7 @@ "@atproto/lexicon": "^0.4.5", "@atproto/sync": "^0.1.11", "@atproto/syntax": "^0.3.1", + "@hono/node-server": "^1.13.8", "@logtape/logtape": "^1.3.6", "@modelcontextprotocol/sdk": "^1.10.2", "@paralleldrive/cuid2": "^3.0.6", @@ -58,6 +59,7 @@ "open": "^10.1.0", "table": "^6.9.0", "unstorage": "^1.14.4", + "uuid": "^13.0.0", "zod": "^3.24.3" }, "devDependencies": { diff --git a/apps/cli/src/cmd/scrobble-api.ts b/apps/cli/src/cmd/scrobble-api.ts new file mode 100644 index 00000000..b02b385c --- /dev/null +++ b/apps/cli/src/cmd/scrobble-api.ts @@ -0,0 +1,89 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { cors } from "hono/cors"; +import { serve } from "@hono/node-server"; +import { env } from "lib/env"; +import chalk from "chalk"; +import { logger as log } from "logger"; +import { getDidAndHandle } from "lib/getDidAndHandle"; +import { WebScrobbler } from "types"; +import { matchTrack } from "lib/matchTrack"; +import _ from "lodash"; +import { publishScrobble } from "scrobble"; + +export async function scrobbleApi({ port }) { + await getDidAndHandle(); + const app = new Hono(); + + if (!process.env.ROCKSKY_API_KEY || !process.env.ROCKSKY_SHARED_SECRET) { + console.log(`ROCKSKY_API_KEY: ${env.ROCKSKY_API_KEY}`); + console.log(`ROCKSKY_SHARED_SECRET: ${env.ROCKSKY_SHARED_SECRET}`); + } else { + console.log( + "ROCKSKY_API_KEY and ROCKSKY_SHARED_SECRET are set from environment variables", + ); + } + + if (!process.env.ROCKSKY_WEBSCROBBLER_KEY) { + console.log(`ROCKSKY_WEBSCROBBLER_KEY: ${env.ROCKSKY_WEBSCROBBLER_KEY}`); + } else { + console.log("ROCKSKY_WEBSCROBBLER_KEY is set from environment variables"); + } + + const BANNER = ` + ____ __ __ + / __ \\____ _____/ /_______/ /____ __ + / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / / + / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ / +/_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, / + /____/ + `; + + console.log(chalk.cyanBright(BANNER)); + + app.use(logger()); + app.use(cors()); + + app.get("/", (c) => + c.text( + `${BANNER}\nWelcome to the lastfm/listenbrainz/webscrobbler compatibility API\n`, + ), + ); + + app.post("/webscrobbler/:uuid", async (c) => { + const { uuid } = c.req.param(); + if (uuid !== env.ROCKSKY_WEBSCROBBLER_KEY) { + return c.text("Invalid UUID", 401); + } + + const body = await c.req.json(); + const { + data: scrobble, + success, + error, + } = WebScrobbler.ScrobbleRequestSchema.safeParse(body); + + if (!success) { + return c.text(`Invalid request body: ${error}`, 400); + } + + log.info`Received scrobble request: \n ${scrobble}`; + + const title = scrobble.data?.song?.parsed?.track; + const artist = scrobble.data?.song?.parsed?.artist; + const match = await matchTrack(title, artist); + + if (!match) { + log.warn`No match found for ${title} by ${artist}`; + return c.text("No match found", 200); + } + + await publishScrobble(match, scrobble.time); + + return c.text("Scrobble received"); + }); + + log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`; + + serve({ fetch: app.fetch, port }); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 60e332cc..303a71cb 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -17,6 +17,7 @@ import { version } from "../package.json" assert { type: "json" }; import { login } from "./cmd/login"; import { sync } from "cmd/sync"; import { initializeDatabase } from "./drizzle"; +import { scrobbleApi } from "cmd/scrobble-api"; await initializeDatabase(); @@ -57,29 +58,29 @@ ${chalk.bold("Join our Discord community:")} ${chalk.blueBright("https:// program .command("login") .argument("", "your AT Proto handle (e.g., .bsky.social)") - .description("login with your AT Proto account and get a session token.") + .description("login with your AT Proto account and get a session token") .action(login); program .command("whoami") - .description("get the current logged-in user.") + .description("get the current logged-in user") .action(whoami); program .command("nowplaying") .argument( "[did]", - "the DID or handle of the user to get the now playing track for.", + "the DID or handle of the user to get the now playing track for", ) - .description("get the currently playing track.") + .description("get the currently playing track") .action(nowplaying); program .command("scrobbles") .option("-s, --skip ", "number of scrobbles to skip") .option("-l, --limit ", "number of scrobbles to limit") - .argument("[did]", "the DID or handle of the user to get the scrobbles for.") - .description("display recently played tracks.") + .argument("[did]", "the DID or handle of the user to get the scrobbles for") + .description("display recently played tracks") .action(scrobbles); program @@ -92,35 +93,35 @@ program "", "the search query, e.g., artist, album, title or account", ) - .description("search for tracks, albums, or accounts.") + .description("search for tracks, albums, or accounts") .action(search); program .command("stats") .option("-l, --limit ", "number of results to limit") - .argument("[did]", "the DID or handle of the user to get stats for.") - .description("get the user's listening stats.") + .argument("[did]", "the DID or handle of the user to get stats for") + .description("get the user's listening stats") .action(stats); program .command("artists") .option("-l, --limit ", "number of results to limit") - .argument("[did]", "the DID or handle of the user to get artists for.") - .description("get the user's top artists.") + .argument("[did]", "the DID or handle of the user to get artists for") + .description("get the user's top artists") .action(artists); program .command("albums") .option("-l, --limit ", "number of results to limit") - .argument("[did]", "the DID or handle of the user to get albums for.") - .description("get the user's top albums.") + .argument("[did]", "the DID or handle of the user to get albums for") + .description("get the user's top albums") .action(albums); program .command("tracks") .option("-l, --limit ", "number of results to limit") - .argument("[did]", "the DID or handle of the user to get tracks for.") - .description("get the user's top tracks.") + .argument("[did]", "the DID or handle of the user to get tracks for") + .description("get the user's top tracks") .action(tracks); program @@ -129,26 +130,32 @@ program .argument("", "the artist of the track") .option("-t, --timestamp ", "the timestamp of the scrobble") .option("-d, --dry-run", "simulate the scrobble without actually sending it") - .description("scrobble a track to your profile.") + .description("scrobble a track to your profile") .action(scrobble); program .command("create") - .description("create a new API key.") + .description("create a new API key") .command("apikey") .argument("", "the name of the API key") .option("-d, --description ", "the description of the API key") - .description("create a new API key.") + .description("create a new API key") .action(createApiKey); program .command("mcp") - .description("starts an MCP server to use with Claude or other LLMs.") + .description("starts an MCP server to use with Claude or other LLMs") .action(mcp); program .command("sync") - .description("sync your local Rocksky data from AT Protocol.") + .description("sync your local Rocksky data from AT Protocol") .action(sync); +program + .command("scrobble-api") + .description("start a local listenbrainz/lastfm compatibility server") + .option("-p, --port ", "the port to listen on", "8778") + .action(scrobbleApi); + program.parse(process.argv); diff --git a/apps/cli/src/lib/env.ts b/apps/cli/src/lib/env.ts index 61bbe3eb..4da0d7e4 100644 --- a/apps/cli/src/lib/env.ts +++ b/apps/cli/src/lib/env.ts @@ -1,5 +1,7 @@ import dotenv from "dotenv"; import { cleanEnv, str } from "envalid"; +import crypto from "node:crypto"; +import { v4 as uuid } from "uuid"; dotenv.config(); @@ -10,4 +12,11 @@ export const env = cleanEnv(process.env, { JETSTREAM_SERVER: str({ default: "wss://jetstream1.us-west.bsky.network/subscribe", }), + ROCKSKY_API_KEY: str({ default: crypto.randomBytes(16).toString("hex") }), + ROCKSKY_SHARED_SECRET: str({ + default: crypto.randomBytes(16).toString("hex"), + }), + ROCKSKY_WEBSCROBBLER_KEY: str({ + default: uuid(), + }), }); diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts new file mode 100644 index 00000000..0affc0a0 --- /dev/null +++ b/apps/cli/src/types.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; + +export namespace WebScrobbler { + /* -------------------------------- Connector -------------------------------- */ + + export const ConnectorSchema = z.object({ + id: z.string(), + js: z.string(), + label: z.string(), + matches: z.array(z.string()), + }); + + /* ----------------------- IsRegrexEditedByUser ----------------------- */ + + export const IsRegrexEditedByUserSchema = z.object({ + album: z.boolean(), + albumArtist: z.boolean(), + artist: z.boolean(), + track: z.boolean(), + }); + + /* ---------------------------------- Flags ---------------------------------- */ + + export const FlagsSchema = z.object({ + finishedProcessing: z.boolean(), + hasBlockedTag: z.boolean(), + isAlbumFetched: z.boolean(), + isCorrectedByUser: z.boolean(), + isLovedInService: z.boolean().optional(), + isMarkedAsPlaying: z.boolean(), + isRegexEditedByUser: IsRegrexEditedByUserSchema, + isReplaying: z.boolean(), + isScrobbled: z.boolean(), + isSkipped: z.boolean(), + isValid: z.boolean(), + }); + + /* -------------------------------- Metadata -------------------------------- */ + + export const MetadataSchema = z.object({ + albumUrl: z.string().url().optional(), + artistUrl: z.string().url().optional(), + label: z.string(), + startTimestamp: z.number().int().nonnegative(), + trackUrl: z.string().url().optional(), + userPlayCount: z.number().int().nonnegative().optional(), + userloved: z.boolean().optional(), + }); + + /* -------------------------------- NoRegex -------------------------------- */ + + export const NoRegexSchema = z.object({ + album: z.string().optional(), + albumArtist: z.string().optional(), + artist: z.string(), + duration: z.number().int().nonnegative().optional(), + track: z.string(), + }); + + /* ---------------------------------- Parsed --------------------------------- */ + + export const ParsedSchema = z.object({ + album: z.string().optional(), + albumArtist: z.string().optional(), + artist: z.string(), + currentTime: z.number().int().nonnegative().optional(), + duration: z.number().int().nonnegative().optional(), + isPlaying: z.boolean(), + isPodcast: z.boolean(), + originUrl: z.string().url().optional(), + scrobblingDisallowedReason: z.string().optional(), + track: z.string(), + trackArt: z.string().url().optional(), + uniqueID: z.string().optional(), + }); + + /* ----------------------------------- Song ---------------------------------- */ + + export const SongSchema = z.object({ + connector: ConnectorSchema, + controllerTabId: z.number().int().nonnegative(), + flags: FlagsSchema, + metadata: MetadataSchema, + noRegex: NoRegexSchema, + parsed: ParsedSchema, + }); + + /* -------------------------------- Processed -------------------------------- */ + + export const ProcessedSchema = z.object({ + album: z.string(), + albumArtist: z.string().optional(), + artist: z.string(), + duration: z.number().int().nonnegative(), + track: z.string(), + }); + + /* --------------------------------- Scrobble -------------------------------- */ + + export const ScrobbleSchema = z.object({ + song: SongSchema, + }); + + /* ------------------------------ ScrobbleRequest ----------------------------- */ + + export const ScrobbleRequestSchema = z.object({ + data: ScrobbleSchema, + eventName: z.string(), + time: z.number().int().nonnegative(), + }); + + export type Song = z.infer; + export type Processed = z.infer; + export type Scrobble = z.infer; + export type ScrobbleRequest = z.infer; + export type ScrobbleRequestData = z.infer< + typeof ScrobbleRequestSchema + >["data"]; +} diff --git a/bun.lock b/bun.lock index f16ded7b..2bf94dc9 100644 --- a/bun.lock +++ b/bun.lock @@ -116,6 +116,7 @@ "@atproto/lexicon": "^0.4.5", "@atproto/sync": "^0.1.11", "@atproto/syntax": "^0.3.1", + "@hono/node-server": "^1.13.8", "@logtape/logtape": "^1.3.6", "@modelcontextprotocol/sdk": "^1.10.2", "@paralleldrive/cuid2": "^3.0.6", @@ -133,12 +134,14 @@ "env-paths": "^3.0.0", "envalid": "^8.0.0", "express": "^5.1.0", + "hono": "^4.4.7", "kysely": "^0.27.5", "lodash": "^4.17.21", "md5": "^2.3.0", "open": "^10.1.0", "table": "^6.9.0", "unstorage": "^1.14.4", + "uuid": "^13.0.0", "zod": "^3.24.3", }, "devDependencies": { @@ -3086,6 +3089,8 @@ "@rocksky/cli/drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + "@rocksky/cli/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "@rocksky/doc/vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], "@rocksky/spotify-proxy/@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.8.71", "", { "dependencies": { "birpc": "0.2.14", "cjs-module-lexer": "^1.2.3", "devalue": "^5.3.2", "miniflare": "4.20250906.0", "semver": "^7.7.1", "wrangler": "4.35.0", "zod": "^3.22.3" }, "peerDependencies": { "@vitest/runner": "2.0.x - 3.2.x", "@vitest/snapshot": "2.0.x - 3.2.x", "vitest": "2.0.x - 3.2.x" } }, "sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg=="], -- 2.51.2