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

Add ListenBrainz endpoints and types

Implement /1/submit-listens with Token auth, validate body via zod, and
asynchronously match tracks then publish scrobbles for 'single' listens.
Add /1/validate-token and several stub ListenBrainz-compatible routes.
Add Listenbrainz zod schemas and TypeScript types. Use handle from
getDidAndHandle for token validation.

+220 -2
+166 -2
apps/cli/src/cmd/scrobble-api.ts
··· 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) { ··· 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();
··· 6 import chalk from "chalk"; 7 import { logger as log } from "logger"; 8 import { getDidAndHandle } from "lib/getDidAndHandle"; 9 + import { WebScrobbler, Listenbrainz } 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 + const [, handle] = await getDidAndHandle(); 16 const app = new Hono(); 17 18 if (!process.env.ROCKSKY_API_KEY || !process.env.ROCKSKY_SHARED_SECRET) { ··· 49 `${BANNER}\nWelcome to the lastfm/listenbrainz/webscrobbler compatibility API\n`, 50 ), 51 ); 52 + 53 + app.post("/nowplaying", (c) => { 54 + return c.text(""); 55 + }); 56 + 57 + app.post("/submission", (c) => { 58 + return c.text(""); 59 + }); 60 + 61 + app.get("/2.0", (c) => { 62 + return c.text(`${BANNER}\nWelcome to the lastfm compatibility API\n`); 63 + }); 64 + 65 + app.post("/2.0", (c) => { 66 + return c.text(""); 67 + }); 68 + 69 + app.post("/1/submit-listens", async (c) => { 70 + const authHeader = c.req.header("Authorization"); 71 + 72 + if (!authHeader || !authHeader.startsWith("Token ")) { 73 + return c.json( 74 + { 75 + code: 401, 76 + error: "Unauthorized", 77 + }, 78 + 401, 79 + ); 80 + } 81 + 82 + const token = authHeader.substring(6); // Remove "Token " prefix 83 + if (token !== env.ROCKSKY_API_KEY) { 84 + return c.json( 85 + { 86 + code: 401, 87 + error: "Invalid token", 88 + }, 89 + 401, 90 + ); 91 + } 92 + 93 + const body = await c.req.json(); 94 + const { 95 + data: submitRequest, 96 + success, 97 + error, 98 + } = Listenbrainz.SubmitListensRequestSchema.safeParse(body); 99 + 100 + if (!success) { 101 + return c.json( 102 + { 103 + code: 400, 104 + error: `Invalid request body: ${error}`, 105 + }, 106 + 400, 107 + ); 108 + } 109 + 110 + log.info`Received ListenBrainz submit-listens request with ${submitRequest.payload.length} payload(s)`; 111 + 112 + if (submitRequest.listen_type !== "single") { 113 + log.info`Skipping listen_type: ${submitRequest.listen_type} (only "single" is processed)`; 114 + return c.json({ 115 + status: "ok", 116 + payload: { 117 + submitted_listens: 0, 118 + ignored_listens: 1, 119 + }, 120 + code: 200, 121 + }); 122 + } 123 + 124 + // Process scrobbles asynchronously to avoid timeout 125 + (async () => { 126 + for (const listen of submitRequest.payload) { 127 + const title = listen.track_metadata.track_name; 128 + const artist = listen.track_metadata.artist_name; 129 + 130 + log.info`Processing listen: ${title} by ${artist}`; 131 + 132 + const match = await matchTrack(title, artist); 133 + 134 + if (!match) { 135 + log.warn`No match found for ${title} by ${artist}`; 136 + continue; 137 + } 138 + 139 + const timestamp = listen.listened_at || Math.floor(Date.now() / 1000); 140 + await publishScrobble(match, timestamp); 141 + } 142 + })().catch((err) => { 143 + log.error`Error processing ListenBrainz scrobbles: ${err}`; 144 + }); 145 + 146 + return c.json({ 147 + status: "ok", 148 + code: 200, 149 + }); 150 + }); 151 + 152 + app.get("/1/validate-token", (c) => { 153 + const authHeader = c.req.header("Authorization"); 154 + 155 + if (!authHeader || !authHeader.startsWith("Token ")) { 156 + return c.json({ 157 + code: 401, 158 + message: "Unauthorized", 159 + valid: false, 160 + }); 161 + } 162 + 163 + const token = authHeader.substring(6); // Remove "Token " prefix 164 + if (token !== env.ROCKSKY_API_KEY) { 165 + return c.json({ 166 + code: 401, 167 + message: "Invalid token", 168 + valid: false, 169 + }); 170 + } 171 + 172 + return c.json({ 173 + code: 200, 174 + message: "Token valid.", 175 + valid: true, 176 + user_name: handle, 177 + permissions: ["recording-metadata-write", "recording-metadata-read"], 178 + }); 179 + }); 180 + 181 + app.get("/1/search/users", (c) => { 182 + return c.json([]); 183 + }); 184 + 185 + app.get("/1/user/:username/listens", (c) => { 186 + return c.json([]); 187 + }); 188 + 189 + app.get("/1/user/:username/listen-count", (c) => { 190 + return c.json({}); 191 + }); 192 + 193 + app.get("/1/user/:username/playing-now", (c) => { 194 + return c.json({}); 195 + }); 196 + 197 + app.get("/1/stats/user/:username/artists", (c) => { 198 + return c.json({}); 199 + }); 200 + 201 + app.get("/1/stats/user/:username}/releases", (c) => { 202 + return c.json({}); 203 + }); 204 + 205 + app.get("/1/stats/user/:username/recordings", (c) => { 206 + return c.json([]); 207 + }); 208 + 209 + app.get("/1/stats/user/:username/release-groups", (c) => { 210 + return c.json([]); 211 + }); 212 + 213 + app.get("/1/stats/user/:username/recordings", (c) => { 214 + return c.json({}); 215 + }); 216 217 app.post("/webscrobbler/:uuid", async (c) => { 218 const { uuid } = c.req.param();
+54
apps/cli/src/types.ts
··· 117 typeof ScrobbleRequestSchema 118 >["data"]; 119 }
··· 117 typeof ScrobbleRequestSchema 118 >["data"]; 119 } 120 + 121 + export namespace Lastfm {} 122 + 123 + export namespace Listenbrainz { 124 + /* -------------------------------- TrackMetadata -------------------------------- */ 125 + 126 + export const TrackMetadataSchema = z.object({ 127 + artist_name: z.string(), 128 + track_name: z.string(), 129 + release_name: z.string().optional(), 130 + additional_info: z.record(z.any()).optional(), 131 + }); 132 + 133 + /* -------------------------------- Payload -------------------------------- */ 134 + 135 + export const PayloadSchema = z.object({ 136 + listened_at: z.number().int().nonnegative().optional(), 137 + track_metadata: TrackMetadataSchema, 138 + }); 139 + 140 + /* -------------------------------- SubmitListensRequest -------------------------------- */ 141 + 142 + export const SubmitListensRequestSchema = z.object({ 143 + listen_type: z.enum(["single", "playing_now", "import"]), 144 + payload: z.array(PayloadSchema), 145 + }); 146 + 147 + /* -------------------------------- SubmitListensResponse -------------------------------- */ 148 + 149 + export const SubmitListensResponseSchema = z.object({ 150 + status: z.string(), 151 + code: z.number().int(), 152 + }); 153 + 154 + /* -------------------------------- ValidateTokenResponse -------------------------------- */ 155 + 156 + export const ValidateTokenResponseSchema = z.object({ 157 + code: z.number().int(), 158 + message: z.string(), 159 + valid: z.boolean(), 160 + user_name: z.string().optional(), 161 + }); 162 + 163 + export type TrackMetadata = z.infer<typeof TrackMetadataSchema>; 164 + export type Listen = z.infer<typeof ListenSchema>; 165 + export type Payload = z.infer<typeof PayloadSchema>; 166 + export type SubmitListensRequest = z.infer<typeof SubmitListensRequestSchema>; 167 + export type SubmitListensResponse = z.infer< 168 + typeof SubmitListensResponseSchema 169 + >; 170 + export type ValidateTokenResponse = z.infer< 171 + typeof ValidateTokenResponseSchema 172 + >; 173 + }