A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at main 465 lines 12 kB view raw
1import { Hono } from "hono"; 2import { logger } from "hono/logger"; 3import { cors } from "hono/cors"; 4import { serve } from "@hono/node-server"; 5import { env } from "lib/env"; 6import chalk from "chalk"; 7import { logger as log } from "logger"; 8import { getDidAndHandle } from "lib/getDidAndHandle"; 9import { WebScrobbler, Listenbrainz, Lastfm } from "types"; 10import { matchTrack } from "lib/matchTrack"; 11import _ from "lodash"; 12import { publishScrobble } from "scrobble"; 13import { validateLastfmSignature } from "lib/lastfm"; 14import { sync } from "./sync"; 15 16export async function scrobbleApi({ port }) { 17 const [, handle] = await getDidAndHandle(); 18 const app = new Hono(); 19 20 if ( 21 !process.env.ROCKSKY_API_KEY || 22 !process.env.ROCKSKY_SHARED_SECRET || 23 !process.env.ROCKSKY_SESSION_KEY 24 ) { 25 console.log(`ROCKSKY_API_KEY: ${env.ROCKSKY_API_KEY}`); 26 console.log(`ROCKSKY_SHARED_SECRET: ${env.ROCKSKY_SHARED_SECRET}`); 27 console.log(`ROCKSKY_SESSION_KEY: ${env.ROCKSKY_SESSION_KEY}`); 28 } else { 29 console.log( 30 "ROCKSKY_API_KEY, ROCKSKY_SHARED_SECRET and ROCKSKY_SESSION_KEY are set from environment variables", 31 ); 32 } 33 34 if (!process.env.ROCKSKY_WEBSCROBBLER_KEY) { 35 console.log(`ROCKSKY_WEBSCROBBLER_KEY: ${env.ROCKSKY_WEBSCROBBLER_KEY}`); 36 } else { 37 console.log("ROCKSKY_WEBSCROBBLER_KEY is set from environment variables"); 38 } 39 40 const BANNER = ` 41 ____ __ __ 42 / __ \\____ _____/ /_______/ /____ __ 43 / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / / 44 / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ / 45/_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, / 46 /____/ 47 `; 48 49 console.log(chalk.cyanBright(BANNER)); 50 51 app.use(logger()); 52 app.use(cors()); 53 54 app.get("/", (c) => 55 c.text( 56 `${BANNER}\nWelcome to the lastfm/listenbrainz/webscrobbler compatibility API\n`, 57 ), 58 ); 59 60 app.post("/nowplaying", async (c) => { 61 const formData = await c.req.parseBody(); 62 const params = Object.fromEntries( 63 Object.entries(formData).map(([k, v]) => [k, String(v)]), 64 ); 65 66 if (params.s !== env.ROCKSKY_SESSION_KEY) { 67 return c.text("BADSESSION\n"); 68 } 69 70 const { 71 data: nowPlaying, 72 success, 73 error, 74 } = Lastfm.LegacyNowPlayingRequestSchema.safeParse(params); 75 76 if (!success) { 77 return c.text(`FAILED Invalid request: ${error}\n`); 78 } 79 80 log.info`Legacy API - Now playing: ${nowPlaying.t} by ${nowPlaying.a}`; 81 82 return c.text("OK\n"); 83 }); 84 85 app.post("/submission", async (c) => { 86 const formData = await c.req.parseBody(); 87 const params = Object.fromEntries( 88 Object.entries(formData).map(([k, v]) => [k, String(v)]), 89 ); 90 91 if (params.s !== env.ROCKSKY_SESSION_KEY) { 92 return c.text("BADSESSION\n"); 93 } 94 95 const { 96 data: submission, 97 success, 98 error, 99 } = Lastfm.LegacySubmissionRequestSchema.safeParse(params); 100 101 if (!success) { 102 return c.text(`FAILED Invalid request: ${error}\n`); 103 } 104 105 log.info`Legacy API - Received scrobble: ${submission["t[0]"]} by ${submission["a[0]"]}`; 106 107 // Process scrobble asynchronously 108 (async () => { 109 const track = submission["t[0]"]; 110 const artist = submission["a[0]"]; 111 const timestamp = parseInt(submission["i[0]"]); 112 113 const match = await matchTrack(track, artist); 114 115 if (!match) { 116 log.warn`No match found for ${track} by ${artist}`; 117 return; 118 } 119 120 await publishScrobble(match, timestamp); 121 })().catch((err) => { 122 log.error`Error processing legacy API scrobble: ${err}`; 123 }); 124 125 return c.text("OK\n"); 126 }); 127 128 app.get("/2.0", async (c) => { 129 const params = Object.fromEntries( 130 Object.entries(c.req.query()).map(([k, v]) => [k, String(v)]), 131 ); 132 133 if (params.method === "auth.getSession") { 134 if (params.api_key !== env.ROCKSKY_API_KEY) { 135 return c.json({ 136 error: 10, 137 message: "Invalid API key", 138 }); 139 } 140 141 if (!validateLastfmSignature(params)) { 142 return c.json({ 143 error: 13, 144 message: "Invalid method signature supplied", 145 }); 146 } 147 148 return c.json({ 149 session: { 150 name: handle, 151 key: env.ROCKSKY_SESSION_KEY, 152 subscriber: 0, 153 }, 154 }); 155 } 156 157 return c.text(`${BANNER}\nWelcome to the lastfm compatibility API\n`); 158 }); 159 160 app.post("/2.0", async (c) => { 161 const contentType = c.req.header("content-type"); 162 let params: Record<string, string> = {}; 163 164 if (contentType?.includes("application/x-www-form-urlencoded")) { 165 const formData = await c.req.parseBody(); 166 params = Object.fromEntries( 167 Object.entries(formData).map(([k, v]) => [k, String(v)]), 168 ); 169 } else { 170 params = await c.req.json(); 171 } 172 173 log.info`Received Last.fm API request: method=${params.method}`; 174 175 if (params.api_key !== env.ROCKSKY_API_KEY) { 176 return c.json({ 177 error: 10, 178 message: "Invalid API key", 179 }); 180 } 181 182 if (!validateLastfmSignature(params)) { 183 return c.json({ 184 error: 13, 185 message: "Invalid method signature supplied", 186 }); 187 } 188 189 if (params.method === "auth.getSession") { 190 return c.json({ 191 session: { 192 name: handle, 193 key: env.ROCKSKY_SESSION_KEY, 194 subscriber: 0, 195 }, 196 }); 197 } 198 199 if (params.method === "track.updateNowPlaying") { 200 // Validate session key 201 if (params.sk !== env.ROCKSKY_SESSION_KEY) { 202 return c.json({ 203 error: 9, 204 message: "Invalid session key", 205 }); 206 } 207 208 log.info`Now playing: ${params.track} by ${params.artist}`; 209 return c.json({ 210 nowplaying: { 211 artist: { "#text": params.artist }, 212 track: { "#text": params.track }, 213 album: { "#text": params.album || "" }, 214 ignoredMessage: { code: "0", "#text": "" }, 215 }, 216 }); 217 } 218 219 if (params.method === "track.scrobble") { 220 // Validate session key 221 if (params.sk !== env.ROCKSKY_SESSION_KEY) { 222 return c.json({ 223 error: 9, 224 message: "Invalid session key", 225 }); 226 } 227 228 const track = params["track[0]"] || params.track; 229 const artist = params["artist[0]"] || params.artist; 230 const timestamp = params["timestamp[0]"] || params.timestamp; 231 232 log.info`Received Last.fm scrobble: ${track} by ${artist}`; 233 234 // Process scrobble asynchronously 235 (async () => { 236 const match = await matchTrack(track, artist); 237 238 if (!match) { 239 log.warn`No match found for ${track} by ${artist}`; 240 return; 241 } 242 243 const ts = timestamp 244 ? parseInt(timestamp) 245 : Math.floor(Date.now() / 1000); 246 await publishScrobble(match, ts); 247 })().catch((err) => { 248 log.error`Error processing Last.fm scrobble: ${err}`; 249 }); 250 251 return c.json({ 252 scrobbles: { 253 "@attr": { 254 accepted: 1, 255 ignored: 0, 256 }, 257 scrobble: { 258 artist: { "#text": artist }, 259 track: { "#text": track }, 260 album: { "#text": params["album[0]"] || params.album || "" }, 261 timestamp: timestamp || String(Math.floor(Date.now() / 1000)), 262 ignoredMessage: { code: "0", "#text": "" }, 263 }, 264 }, 265 }); 266 } 267 268 return c.json({ 269 error: 3, 270 message: "Invalid method", 271 }); 272 }); 273 274 app.post("/1/submit-listens", async (c) => { 275 const authHeader = c.req.header("Authorization"); 276 277 if (!authHeader || !authHeader.startsWith("Token ")) { 278 return c.json( 279 { 280 code: 401, 281 error: "Unauthorized", 282 }, 283 401, 284 ); 285 } 286 287 const token = authHeader.substring(6); // Remove "Token " prefix 288 if (token !== env.ROCKSKY_API_KEY) { 289 return c.json( 290 { 291 code: 401, 292 error: "Invalid token", 293 }, 294 401, 295 ); 296 } 297 298 const body = await c.req.json(); 299 const { 300 data: submitRequest, 301 success, 302 error, 303 } = Listenbrainz.SubmitListensRequestSchema.safeParse(body); 304 305 if (!success) { 306 return c.json( 307 { 308 code: 400, 309 error: `Invalid request body: ${error}`, 310 }, 311 400, 312 ); 313 } 314 315 log.info`Received ListenBrainz submit-listens request with ${submitRequest.payload.length} payload(s)`; 316 317 if (submitRequest.listen_type !== "single") { 318 log.info`Skipping listen_type: ${submitRequest.listen_type} (only "single" is processed)`; 319 return c.json({ 320 status: "ok", 321 payload: { 322 submitted_listens: 0, 323 ignored_listens: 1, 324 }, 325 code: 200, 326 }); 327 } 328 329 // Process scrobbles asynchronously to avoid timeout 330 (async () => { 331 for (const listen of submitRequest.payload) { 332 const title = listen.track_metadata.track_name; 333 const artist = listen.track_metadata.artist_name; 334 335 log.info`Processing listen: ${title} by ${artist}`; 336 337 const match = await matchTrack(title, artist); 338 339 if (!match) { 340 log.warn`No match found for ${title} by ${artist}`; 341 continue; 342 } 343 344 const timestamp = listen.listened_at || Math.floor(Date.now() / 1000); 345 await publishScrobble(match, timestamp); 346 } 347 })().catch((err) => { 348 log.error`Error processing ListenBrainz scrobbles: ${err}`; 349 }); 350 351 return c.json({ 352 status: "ok", 353 code: 200, 354 }); 355 }); 356 357 app.get("/1/validate-token", (c) => { 358 const authHeader = c.req.header("Authorization"); 359 360 if (!authHeader || !authHeader.startsWith("Token ")) { 361 return c.json({ 362 code: 401, 363 message: "Unauthorized", 364 valid: false, 365 }); 366 } 367 368 const token = authHeader.substring(6); // Remove "Token " prefix 369 if (token !== env.ROCKSKY_API_KEY) { 370 return c.json({ 371 code: 401, 372 message: "Invalid token", 373 valid: false, 374 }); 375 } 376 377 return c.json({ 378 code: 200, 379 message: "Token valid.", 380 valid: true, 381 user_name: handle, 382 permissions: ["recording-metadata-write", "recording-metadata-read"], 383 }); 384 }); 385 386 app.get("/1/search/users", (c) => { 387 return c.json([]); 388 }); 389 390 app.get("/1/user/:username/listens", (c) => { 391 return c.json([]); 392 }); 393 394 app.get("/1/user/:username/listen-count", (c) => { 395 return c.json({}); 396 }); 397 398 app.get("/1/user/:username/playing-now", (c) => { 399 return c.json({}); 400 }); 401 402 app.get("/1/stats/user/:username/artists", (c) => { 403 return c.json({}); 404 }); 405 406 app.get("/1/stats/user/:username}/releases", (c) => { 407 return c.json({}); 408 }); 409 410 app.get("/1/stats/user/:username/recordings", (c) => { 411 return c.json([]); 412 }); 413 414 app.get("/1/stats/user/:username/release-groups", (c) => { 415 return c.json([]); 416 }); 417 418 app.get("/1/stats/user/:username/recordings", (c) => { 419 return c.json({}); 420 }); 421 422 app.post("/webscrobbler/:uuid", async (c) => { 423 const { uuid } = c.req.param(); 424 if (uuid !== env.ROCKSKY_WEBSCROBBLER_KEY) { 425 return c.text("Invalid UUID", 401); 426 } 427 428 const body = await c.req.json(); 429 const { 430 data: scrobble, 431 success, 432 error, 433 } = WebScrobbler.ScrobbleRequestSchema.safeParse(body); 434 435 if (!success) { 436 return c.text(`Invalid request body: ${error}`, 400); 437 } 438 439 log.info`Received scrobble request: \n ${scrobble}`; 440 441 const title = scrobble.data?.song?.parsed?.track; 442 const artist = scrobble.data?.song?.parsed?.artist; 443 const match = await matchTrack(title, artist); 444 445 if (!match) { 446 log.warn`No match found for ${title} by ${artist}`; 447 return c.text("No match found", 200); 448 } 449 450 await publishScrobble(match, scrobble.time); 451 452 return c.text("Scrobble received"); 453 }); 454 455 log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`; 456 457 new Promise(async () => { 458 try { 459 await sync(); 460 } catch (err) { 461 log.warn`Error during initial sync: ${err}`; 462 } 463 }); 464 serve({ fetch: app.fetch, port }); 465}