forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
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}