A decentralized music tracking and discovery platform built on AT Protocol 🎵

Add scrobble API and multi-artist Spotify search

+269 -21
+18 -1
apps/api/src/xrpc/app/rocksky/song/matchSong.ts
··· 266 266 access_token: string; 267 267 }; 268 268 269 - const q = `q=track:"${encodeURIComponent(title)}"%20artist:"${encodeURIComponent(artist)}"&type=track`; 269 + let q = `q=track:"${encodeURIComponent(title)}"%20artist:"${encodeURIComponent(artist)}"&type=track`; 270 + 271 + if (artist.includes(", ")) { 272 + const artists = artist 273 + .split(", ") 274 + .map((a) => `artist:"${encodeURIComponent(a.trim())}"`) 275 + .join(" "); 276 + q = `q=track:"${encodeURIComponent(title)}" ${artists}&type=track`; 277 + } 278 + 279 + if (artist.includes(" x ")) { 280 + const artists = artist 281 + .split(" x ") 282 + .map((a) => `artist:"${encodeURIComponent(a.trim())}"`) 283 + .join(" "); 284 + q = `q=track:"${encodeURIComponent(title)}" ${artists}&type=track`; 285 + } 286 + 270 287 const response = await fetch(`https://api.spotify.com/v1/search?${q}`, { 271 288 method: "GET", 272 289 headers: {
+2
apps/cli/package.json
··· 34 34 "@atproto/lexicon": "^0.4.5", 35 35 "@atproto/sync": "^0.1.11", 36 36 "@atproto/syntax": "^0.3.1", 37 + "@hono/node-server": "^1.13.8", 37 38 "@logtape/logtape": "^1.3.6", 38 39 "@modelcontextprotocol/sdk": "^1.10.2", 39 40 "@paralleldrive/cuid2": "^3.0.6", ··· 58 59 "open": "^10.1.0", 59 60 "table": "^6.9.0", 60 61 "unstorage": "^1.14.4", 62 + "uuid": "^13.0.0", 61 63 "zod": "^3.24.3" 62 64 }, 63 65 "devDependencies": {
+89
apps/cli/src/cmd/scrobble-api.ts
··· 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
··· 17 17 import { login } from "./cmd/login"; 18 18 import { sync } from "cmd/sync"; 19 19 import { initializeDatabase } from "./drizzle"; 20 + import { scrobbleApi } from "cmd/scrobble-api"; 20 21 21 22 await initializeDatabase(); 22 23 ··· 57 58 program 58 59 .command("login") 59 60 .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 + .description("login with your AT Proto account and get a session token") 61 62 .action(login); 62 63 63 64 program 64 65 .command("whoami") 65 - .description("get the current logged-in user.") 66 + .description("get the current logged-in user") 66 67 .action(whoami); 67 68 68 69 program 69 70 .command("nowplaying") 70 71 .argument( 71 72 "[did]", 72 - "the DID or handle of the user to get the now playing track for.", 73 + "the DID or handle of the user to get the now playing track for", 73 74 ) 74 - .description("get the currently playing track.") 75 + .description("get the currently playing track") 75 76 .action(nowplaying); 76 77 77 78 program 78 79 .command("scrobbles") 79 80 .option("-s, --skip <number>", "number of scrobbles to skip") 80 81 .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.") 82 + .argument("[did]", "the DID or handle of the user to get the scrobbles for") 83 + .description("display recently played tracks") 83 84 .action(scrobbles); 84 85 85 86 program ··· 92 93 "<query>", 93 94 "the search query, e.g., artist, album, title or account", 94 95 ) 95 - .description("search for tracks, albums, or accounts.") 96 + .description("search for tracks, albums, or accounts") 96 97 .action(search); 97 98 98 99 program 99 100 .command("stats") 100 101 .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.") 102 + .argument("[did]", "the DID or handle of the user to get stats for") 103 + .description("get the user's listening stats") 103 104 .action(stats); 104 105 105 106 program 106 107 .command("artists") 107 108 .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.") 109 + .argument("[did]", "the DID or handle of the user to get artists for") 110 + .description("get the user's top artists") 110 111 .action(artists); 111 112 112 113 program 113 114 .command("albums") 114 115 .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.") 116 + .argument("[did]", "the DID or handle of the user to get albums for") 117 + .description("get the user's top albums") 117 118 .action(albums); 118 119 119 120 program 120 121 .command("tracks") 121 122 .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.") 123 + .argument("[did]", "the DID or handle of the user to get tracks for") 124 + .description("get the user's top tracks") 124 125 .action(tracks); 125 126 126 127 program ··· 129 130 .argument("<artist>", "the artist of the track") 130 131 .option("-t, --timestamp <timestamp>", "the timestamp of the scrobble") 131 132 .option("-d, --dry-run", "simulate the scrobble without actually sending it") 132 - .description("scrobble a track to your profile.") 133 + .description("scrobble a track to your profile") 133 134 .action(scrobble); 134 135 135 136 program 136 137 .command("create") 137 - .description("create a new API key.") 138 + .description("create a new API key") 138 139 .command("apikey") 139 140 .argument("<name>", "the name of the API key") 140 141 .option("-d, --description <description>", "the description of the API key") 141 - .description("create a new API key.") 142 + .description("create a new API key") 142 143 .action(createApiKey); 143 144 144 145 program 145 146 .command("mcp") 146 - .description("starts an MCP server to use with Claude or other LLMs.") 147 + .description("starts an MCP server to use with Claude or other LLMs") 147 148 .action(mcp); 148 149 149 150 program 150 151 .command("sync") 151 - .description("sync your local Rocksky data from AT Protocol.") 152 + .description("sync your local Rocksky data from AT Protocol") 152 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); 153 160 154 161 program.parse(process.argv);
+9
apps/cli/src/lib/env.ts
··· 1 1 import dotenv from "dotenv"; 2 2 import { cleanEnv, str } from "envalid"; 3 + import crypto from "node:crypto"; 4 + import { v4 as uuid } from "uuid"; 3 5 4 6 dotenv.config(); 5 7 ··· 9 11 ROCKSKY_PASSWORD: str({ default: "" }), 10 12 JETSTREAM_SERVER: str({ 11 13 default: "wss://jetstream1.us-west.bsky.network/subscribe", 14 + }), 15 + ROCKSKY_API_KEY: str({ default: crypto.randomBytes(16).toString("hex") }), 16 + ROCKSKY_SHARED_SECRET: str({ 17 + default: crypto.randomBytes(16).toString("hex"), 18 + }), 19 + ROCKSKY_WEBSCROBBLER_KEY: str({ 20 + default: uuid(), 12 21 }), 13 22 });
+119
apps/cli/src/types.ts
··· 1 + import { z } from "zod"; 2 + 3 + export namespace WebScrobbler { 4 + /* -------------------------------- Connector -------------------------------- */ 5 + 6 + export const ConnectorSchema = z.object({ 7 + id: z.string(), 8 + js: z.string(), 9 + label: z.string(), 10 + matches: z.array(z.string()), 11 + }); 12 + 13 + /* ----------------------- IsRegrexEditedByUser ----------------------- */ 14 + 15 + export const IsRegrexEditedByUserSchema = z.object({ 16 + album: z.boolean(), 17 + albumArtist: z.boolean(), 18 + artist: z.boolean(), 19 + track: z.boolean(), 20 + }); 21 + 22 + /* ---------------------------------- Flags ---------------------------------- */ 23 + 24 + export const FlagsSchema = z.object({ 25 + finishedProcessing: z.boolean(), 26 + hasBlockedTag: z.boolean(), 27 + isAlbumFetched: z.boolean(), 28 + isCorrectedByUser: z.boolean(), 29 + isLovedInService: z.boolean().optional(), 30 + isMarkedAsPlaying: z.boolean(), 31 + isRegexEditedByUser: IsRegrexEditedByUserSchema, 32 + isReplaying: z.boolean(), 33 + isScrobbled: z.boolean(), 34 + isSkipped: z.boolean(), 35 + isValid: z.boolean(), 36 + }); 37 + 38 + /* -------------------------------- Metadata -------------------------------- */ 39 + 40 + export const MetadataSchema = z.object({ 41 + albumUrl: z.string().url().optional(), 42 + artistUrl: z.string().url().optional(), 43 + label: z.string(), 44 + startTimestamp: z.number().int().nonnegative(), 45 + trackUrl: z.string().url().optional(), 46 + userPlayCount: z.number().int().nonnegative().optional(), 47 + userloved: z.boolean().optional(), 48 + }); 49 + 50 + /* -------------------------------- NoRegex -------------------------------- */ 51 + 52 + export const NoRegexSchema = z.object({ 53 + album: z.string().optional(), 54 + albumArtist: z.string().optional(), 55 + artist: z.string(), 56 + duration: z.number().int().nonnegative().optional(), 57 + track: z.string(), 58 + }); 59 + 60 + /* ---------------------------------- Parsed --------------------------------- */ 61 + 62 + export const ParsedSchema = z.object({ 63 + album: z.string().optional(), 64 + albumArtist: z.string().optional(), 65 + artist: z.string(), 66 + currentTime: z.number().int().nonnegative().optional(), 67 + duration: z.number().int().nonnegative().optional(), 68 + isPlaying: z.boolean(), 69 + isPodcast: z.boolean(), 70 + originUrl: z.string().url().optional(), 71 + scrobblingDisallowedReason: z.string().optional(), 72 + track: z.string(), 73 + trackArt: z.string().url().optional(), 74 + uniqueID: z.string().optional(), 75 + }); 76 + 77 + /* ----------------------------------- Song ---------------------------------- */ 78 + 79 + export const SongSchema = z.object({ 80 + connector: ConnectorSchema, 81 + controllerTabId: z.number().int().nonnegative(), 82 + flags: FlagsSchema, 83 + metadata: MetadataSchema, 84 + noRegex: NoRegexSchema, 85 + parsed: ParsedSchema, 86 + }); 87 + 88 + /* -------------------------------- Processed -------------------------------- */ 89 + 90 + export const ProcessedSchema = z.object({ 91 + album: z.string(), 92 + albumArtist: z.string().optional(), 93 + artist: z.string(), 94 + duration: z.number().int().nonnegative(), 95 + track: z.string(), 96 + }); 97 + 98 + /* --------------------------------- Scrobble -------------------------------- */ 99 + 100 + export const ScrobbleSchema = z.object({ 101 + song: SongSchema, 102 + }); 103 + 104 + /* ------------------------------ ScrobbleRequest ----------------------------- */ 105 + 106 + export const ScrobbleRequestSchema = z.object({ 107 + data: ScrobbleSchema, 108 + eventName: z.string(), 109 + time: z.number().int().nonnegative(), 110 + }); 111 + 112 + export type Song = z.infer<typeof SongSchema>; 113 + export type Processed = z.infer<typeof ProcessedSchema>; 114 + export type Scrobble = z.infer<typeof ScrobbleSchema>; 115 + export type ScrobbleRequest = z.infer<typeof ScrobbleRequestSchema>; 116 + export type ScrobbleRequestData = z.infer< 117 + typeof ScrobbleRequestSchema 118 + >["data"]; 119 + }
+5
bun.lock
··· 116 116 "@atproto/lexicon": "^0.4.5", 117 117 "@atproto/sync": "^0.1.11", 118 118 "@atproto/syntax": "^0.3.1", 119 + "@hono/node-server": "^1.13.8", 119 120 "@logtape/logtape": "^1.3.6", 120 121 "@modelcontextprotocol/sdk": "^1.10.2", 121 122 "@paralleldrive/cuid2": "^3.0.6", ··· 133 134 "env-paths": "^3.0.0", 134 135 "envalid": "^8.0.0", 135 136 "express": "^5.1.0", 137 + "hono": "^4.4.7", 136 138 "kysely": "^0.27.5", 137 139 "lodash": "^4.17.21", 138 140 "md5": "^2.3.0", 139 141 "open": "^10.1.0", 140 142 "table": "^6.9.0", 141 143 "unstorage": "^1.14.4", 144 + "uuid": "^13.0.0", 142 145 "zod": "^3.24.3", 143 146 }, 144 147 "devDependencies": { ··· 3085 3088 "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], 3086 3089 3087 3090 "@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=="], 3091 + 3092 + "@rocksky/cli/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], 3088 3093 3089 3094 "@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=="], 3090 3095