From e3593165d122099be88bbbb0a736c82f898f454b Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 11 Jan 2026 23:33:58 +0300 Subject: [PATCH] Add Last.fm compatibility endpoints and auth --- apps/cli/src/cmd/scrobble-api.ts | 224 +++++++++++++++++++++++++++++-- apps/cli/src/lib/env.ts | 3 + apps/cli/src/lib/lastfm.ts | 26 ++++ apps/cli/src/types.ts | 139 ++++++++++++++++++- 4 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 apps/cli/src/lib/lastfm.ts diff --git a/apps/cli/src/cmd/scrobble-api.ts b/apps/cli/src/cmd/scrobble-api.ts index ac2def96..c541ecef 100644 --- a/apps/cli/src/cmd/scrobble-api.ts +++ b/apps/cli/src/cmd/scrobble-api.ts @@ -6,21 +6,27 @@ import { env } from "lib/env"; import chalk from "chalk"; import { logger as log } from "logger"; import { getDidAndHandle } from "lib/getDidAndHandle"; -import { WebScrobbler, Listenbrainz } from "types"; +import { WebScrobbler, Listenbrainz, Lastfm } from "types"; import { matchTrack } from "lib/matchTrack"; import _ from "lodash"; import { publishScrobble } from "scrobble"; +import { validateLastfmSignature } from "lib/lastfm"; export async function scrobbleApi({ port }) { const [, handle] = await getDidAndHandle(); const app = new Hono(); - if (!process.env.ROCKSKY_API_KEY || !process.env.ROCKSKY_SHARED_SECRET) { + if ( + !process.env.ROCKSKY_API_KEY || + !process.env.ROCKSKY_SHARED_SECRET || + !process.env.ROCKSKY_SESSION_KEY + ) { console.log(`ROCKSKY_API_KEY: ${env.ROCKSKY_API_KEY}`); console.log(`ROCKSKY_SHARED_SECRET: ${env.ROCKSKY_SHARED_SECRET}`); + console.log(`ROCKSKY_SESSION_KEY: ${env.ROCKSKY_SESSION_KEY}`); } else { console.log( - "ROCKSKY_API_KEY and ROCKSKY_SHARED_SECRET are set from environment variables", + "ROCKSKY_API_KEY, ROCKSKY_SHARED_SECRET and ROCKSKY_SESSION_KEY are set from environment variables", ); } @@ -50,20 +56,218 @@ export async function scrobbleApi({ port }) { ), ); - app.post("/nowplaying", (c) => { - return c.text(""); + app.post("/nowplaying", async (c) => { + const formData = await c.req.parseBody(); + const params = Object.fromEntries( + Object.entries(formData).map(([k, v]) => [k, String(v)]), + ); + + if (params.s !== env.ROCKSKY_SESSION_KEY) { + return c.text("BADSESSION\n"); + } + + const { + data: nowPlaying, + success, + error, + } = Lastfm.LegacyNowPlayingRequestSchema.safeParse(params); + + if (!success) { + return c.text(`FAILED Invalid request: ${error}\n`); + } + + log.info`Legacy API - Now playing: ${nowPlaying.t} by ${nowPlaying.a}`; + + return c.text("OK\n"); }); - app.post("/submission", (c) => { - return c.text(""); + app.post("/submission", async (c) => { + const formData = await c.req.parseBody(); + const params = Object.fromEntries( + Object.entries(formData).map(([k, v]) => [k, String(v)]), + ); + + if (params.s !== env.ROCKSKY_SESSION_KEY) { + return c.text("BADSESSION\n"); + } + + const { + data: submission, + success, + error, + } = Lastfm.LegacySubmissionRequestSchema.safeParse(params); + + if (!success) { + return c.text(`FAILED Invalid request: ${error}\n`); + } + + log.info`Legacy API - Received scrobble: ${submission["t[0]"]} by ${submission["a[0]"]}`; + + // Process scrobble asynchronously + (async () => { + const track = submission["t[0]"]; + const artist = submission["a[0]"]; + const timestamp = parseInt(submission["i[0]"]); + + const match = await matchTrack(track, artist); + + if (!match) { + log.warn`No match found for ${track} by ${artist}`; + return; + } + + await publishScrobble(match, timestamp); + })().catch((err) => { + log.error`Error processing legacy API scrobble: ${err}`; + }); + + return c.text("OK\n"); }); - app.get("/2.0", (c) => { + app.get("/2.0", async (c) => { + const params = Object.fromEntries( + Object.entries(c.req.query()).map(([k, v]) => [k, String(v)]), + ); + + if (params.method === "auth.getSession") { + if (params.api_key !== env.ROCKSKY_API_KEY) { + return c.json({ + error: 10, + message: "Invalid API key", + }); + } + + if (!validateLastfmSignature(params)) { + return c.json({ + error: 13, + message: "Invalid method signature supplied", + }); + } + + return c.json({ + session: { + name: handle, + key: env.ROCKSKY_SESSION_KEY, + subscriber: 0, + }, + }); + } + return c.text(`${BANNER}\nWelcome to the lastfm compatibility API\n`); }); - app.post("/2.0", (c) => { - return c.text(""); + app.post("/2.0", async (c) => { + const contentType = c.req.header("content-type"); + let params: Record = {}; + + if (contentType?.includes("application/x-www-form-urlencoded")) { + const formData = await c.req.parseBody(); + params = Object.fromEntries( + Object.entries(formData).map(([k, v]) => [k, String(v)]), + ); + } else { + params = await c.req.json(); + } + + log.info`Received Last.fm API request: method=${params.method}`; + + if (params.api_key !== env.ROCKSKY_API_KEY) { + return c.json({ + error: 10, + message: "Invalid API key", + }); + } + + if (!validateLastfmSignature(params)) { + return c.json({ + error: 13, + message: "Invalid method signature supplied", + }); + } + + if (params.method === "auth.getSession") { + return c.json({ + session: { + name: handle, + key: env.ROCKSKY_SESSION_KEY, + subscriber: 0, + }, + }); + } + + if (params.method === "track.updateNowPlaying") { + // Validate session key + if (params.sk !== env.ROCKSKY_SESSION_KEY) { + return c.json({ + error: 9, + message: "Invalid session key", + }); + } + + log.info`Now playing: ${params.track} by ${params.artist}`; + return c.json({ + nowplaying: { + artist: { "#text": params.artist }, + track: { "#text": params.track }, + album: { "#text": params.album || "" }, + ignoredMessage: { code: "0", "#text": "" }, + }, + }); + } + + if (params.method === "track.scrobble") { + // Validate session key + if (params.sk !== env.ROCKSKY_SESSION_KEY) { + return c.json({ + error: 9, + message: "Invalid session key", + }); + } + + const track = params["track[0]"] || params.track; + const artist = params["artist[0]"] || params.artist; + const timestamp = params["timestamp[0]"] || params.timestamp; + + log.info`Received Last.fm scrobble: ${track} by ${artist}`; + + // Process scrobble asynchronously + (async () => { + const match = await matchTrack(track, artist); + + if (!match) { + log.warn`No match found for ${track} by ${artist}`; + return; + } + + const ts = timestamp + ? parseInt(timestamp) + : Math.floor(Date.now() / 1000); + await publishScrobble(match, ts); + })().catch((err) => { + log.error`Error processing Last.fm scrobble: ${err}`; + }); + + return c.json({ + scrobbles: { + "@attr": { + accepted: 1, + ignored: 0, + }, + scrobble: { + artist: { "#text": artist }, + track: { "#text": track }, + album: { "#text": params["album[0]"] || params.album || "" }, + timestamp: timestamp || String(Math.floor(Date.now() / 1000)), + ignoredMessage: { code: "0", "#text": "" }, + }, + }, + }); + } + + return c.json({ + error: 3, + message: "Invalid method", + }); }); app.post("/1/submit-listens", async (c) => { diff --git a/apps/cli/src/lib/env.ts b/apps/cli/src/lib/env.ts index 4da0d7e4..4414774d 100644 --- a/apps/cli/src/lib/env.ts +++ b/apps/cli/src/lib/env.ts @@ -16,6 +16,9 @@ export const env = cleanEnv(process.env, { ROCKSKY_SHARED_SECRET: str({ default: crypto.randomBytes(16).toString("hex"), }), + ROCKSKY_SESSION_KEY: str({ + default: crypto.randomBytes(16).toString("hex"), + }), ROCKSKY_WEBSCROBBLER_KEY: str({ default: uuid(), }), diff --git a/apps/cli/src/lib/lastfm.ts b/apps/cli/src/lib/lastfm.ts new file mode 100644 index 00000000..2c587f01 --- /dev/null +++ b/apps/cli/src/lib/lastfm.ts @@ -0,0 +1,26 @@ +import { env } from "lib/env"; +import crypto from "node:crypto"; + +export function generateLastfmSignature( + params: Record, +): string { + const sortedKeys = Object.keys(params).sort(); + let signatureString = ""; + for (const key of sortedKeys) { + if (key !== "format" && key !== "callback") { + signatureString += key + params[key]; + } + } + signatureString += env.ROCKSKY_SHARED_SECRET; + return crypto.createHash("md5").update(signatureString, "utf8").digest("hex"); +} + +export function validateLastfmSignature( + params: Record, +): boolean { + const providedSignature = params.api_sig; + if (!providedSignature) return false; + + const expectedSignature = generateLastfmSignature(params); + return providedSignature === expectedSignature; +} diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index e55b90cd..7820f142 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -118,7 +118,142 @@ export namespace WebScrobbler { >["data"]; } -export namespace Lastfm {} +export namespace Lastfm { + /* -------------------------------- Legacy API Schemas -------------------------------- */ + + export const LegacyNowPlayingRequestSchema = z.object({ + s: z.string(), // session ID + a: z.string(), // artist + t: z.string(), // track + b: z.string().optional(), // album + l: z.string().optional(), // length in seconds + n: z.string().optional(), // track number + m: z.string().optional(), // MusicBrainz ID + }); + + export const LegacySubmissionRequestSchema = z.object({ + s: z.string(), // session ID + "a[0]": z.string(), // artist + "t[0]": z.string(), // track + "i[0]": z.string(), // timestamp + "o[0]": z.string().optional(), // source (P/R/E/L/U/B) + "r[0]": z.string().optional(), // rating (L/B/S) + "l[0]": z.string().optional(), // length in seconds + "b[0]": z.string().optional(), // album + "n[0]": z.string().optional(), // track number + "m[0]": z.string().optional(), // MusicBrainz ID + }); + + /* -------------------------------- Auth Request -------------------------------- */ + + export const AuthRequestSchema = z.object({ + method: z.string(), + api_key: z.string(), + api_sig: z.string(), + format: z.string().optional(), + }); + + /* -------------------------------- Auth GetSession Request -------------------------------- */ + + export const AuthGetSessionRequestSchema = z.object({ + method: z.literal("auth.getSession"), + api_key: z.string(), + token: z.string(), + api_sig: z.string(), + format: z.string().optional(), + }); + + /* -------------------------------- Auth GetSession Response -------------------------------- */ + + export const AuthGetSessionResponseSchema = z.object({ + session: z.object({ + name: z.string(), + key: z.string(), + subscriber: z.number(), + }), + }); + + /* -------------------------------- Track Scrobble Request -------------------------------- */ + + export const TrackScrobbleRequestSchema = z.object({ + method: z.literal("track.scrobble"), + api_key: z.string(), + api_sig: z.string(), + sk: z.string(), + "track[0]": z.string(), + "artist[0]": z.string(), + "timestamp[0]": z.string(), + "album[0]": z.string().optional(), + "albumArtist[0]": z.string().optional(), + "duration[0]": z.string().optional(), + format: z.string().optional(), + }); + + /* -------------------------------- Track Update Now Playing Request -------------------------------- */ + + export const TrackUpdateNowPlayingRequestSchema = z.object({ + method: z.literal("track.updateNowPlaying"), + api_key: z.string(), + api_sig: z.string(), + sk: z.string(), + track: z.string(), + artist: z.string(), + album: z.string().optional(), + albumArtist: z.string().optional(), + duration: z.string().optional(), + format: z.string().optional(), + }); + + /* -------------------------------- Scrobble Response -------------------------------- */ + + export const ScrobbleResponseSchema = z.object({ + scrobbles: z.object({ + "@attr": z.object({ + accepted: z.number(), + ignored: z.number(), + }), + scrobble: z + .object({ + artist: z.object({ "#text": z.string() }), + track: z.object({ "#text": z.string() }), + album: z.object({ "#text": z.string() }).optional(), + albumArtist: z.object({ "#text": z.string() }).optional(), + timestamp: z.string(), + ignoredMessage: z + .object({ code: z.string(), "#text": z.string() }) + .optional(), + }) + .optional(), + }), + }); + + /* -------------------------------- Error Response -------------------------------- */ + + export const ErrorResponseSchema = z.object({ + error: z.number(), + message: z.string(), + }); + + export type LegacyNowPlayingRequest = z.infer< + typeof LegacyNowPlayingRequestSchema + >; + export type LegacySubmissionRequest = z.infer< + typeof LegacySubmissionRequestSchema + >; + export type AuthRequest = z.infer; + export type AuthGetSessionRequest = z.infer< + typeof AuthGetSessionRequestSchema + >; + export type AuthGetSessionResponse = z.infer< + typeof AuthGetSessionResponseSchema + >; + export type TrackScrobbleRequest = z.infer; + export type TrackUpdateNowPlayingRequest = z.infer< + typeof TrackUpdateNowPlayingRequestSchema + >; + export type ScrobbleResponse = z.infer; + export type ErrorResponse = z.infer; +} export namespace Listenbrainz { /* -------------------------------- TrackMetadata -------------------------------- */ @@ -161,7 +296,7 @@ export namespace Listenbrainz { }); export type TrackMetadata = z.infer; - export type Listen = z.infer; + export type Payload = z.infer; export type SubmitListensRequest = z.infer; export type SubmitListensResponse = z.infer< -- 2.51.2