Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717import "./otel";import dns from "node:dns";import { serve } from "@hono/node-server";import { trace } from "@opentelemetry/api";import chalk from "chalk";import { consola } from "consola";import { ctx } from "context";import { and, desc, eq, isNotNull, or } from "drizzle-orm";import { Hono } from "hono";import { cors } from "hono/cors";import { createAgent, pdsSessionExpired } from "lib/agent";import { creditsArtist } from "lib/credits";import { assertNotBotFlagged, assertNotScrobbleBlocked, ScrobbleBlockedError, ScrobbleBotFlaggedError, scrobbleBlockedMessage, scrobbleBotFlaggedMessage,} from "lib/scrobbleGuard";import { verifyToken } from "lib/verifyToken";import { getLovedTracks, likeTrack, unLikeTrack,} from "lovedtracks/lovedtracks.service";import { enqueueSubmission, SubmissionUnavailable } from "lib/durableSubmission";import { env } from "lib/env";import submissionsApp from "./submissions/app";import { rateLimiter } from "ratelimiter";import subscribe from "subscribers";import { saveTrack } from "tracks/tracks.service";import { trackSchema } from "types/track";import accessTokensApp from "./access-tokens/app";import apikeys from "./apikeys/app";import bsky from "./bsky/app";import { requestCounter, requestDuration } from "./metrics";import notificationsSse from "./notifications/sse";import storageApp from "./storage/app";import uploadsApp from "./uploads/app";import "./profiling";import opengraph from "./opengraph/app";import albumTracks from "./schema/album-tracks";import albums from "./schema/albums";import artistTracks from "./schema/artist-tracks";import artists from "./schema/artists";import scrobbles from "./schema/scrobbles";import tracks from "./schema/tracks";import users from "./schema/users";import spotify from "./spotify/app";import usersApp from "./users/app";import webscrobbler from "./webscrobbler/app";
dns.setDefaultResultOrder("ipv4first");
subscribe(ctx);
const app = new Hono();
// Authenticated internal worker traffic has its own bounded concurrency.app.route("/internal/submissions", submissionsApp);
app.use( "*", rateLimiter({ limit: 1000, window: 30, // 👈 30 seconds }),);
app.use("*", async (c, next) => { const span = trace.getActiveSpan(); span?.updateName(`${c.req.method} ${c.req.path}`); span?.setAttribute("http.route", c.req.path); await next();});
app.use("*", async (c, next) => { const start = Date.now(); await next(); const duration = (Date.now() - start) / 1000; requestDuration.record(duration, { route: c.req.path, method: c.req.method, });});
app.use(cors());
app.route("/", bsky);
app.route("/spotify", spotify);
app.route("/storage", storageApp);
app.route("/apikeys", apikeys);
app.route("/access-tokens", accessTokensApp);
app.route("/notifications", notificationsSse);
app.route("/public/og", opengraph);
// Image proxy — fetches remote images server-side to avoid browser CORS restrictionsapp.get("/proxy-image", async (c) => { const url = c.req.query("url"); if (!url) return c.text("Missing url", 400); try { const upstream = await fetch(url); if (!upstream.ok) return c.text("Upstream error", 502); const contentType = upstream.headers.get("content-type") ?? "image/jpeg"; return new Response(upstream.body, { headers: { "content-type": contentType }, }); } catch { return c.text("Failed to fetch image", 502); }});
// The player remote-control WebSocket (/ws) is now served by the Elixir// `remote-ws` service; Caddy routes /ws there. See remote-ws/README.md.
app.get("/", async (c) => { return c.text("\n ____ __ __ ___ _ ___\n / __ \\____ _____/ /_______/ /____ __ / | ____ ____| | / (_)__ _ __\n / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / / / /| | / __ \\/ __ \\ | / / / _ \\ | /| / /\n / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ / / ___ |/ /_/ / /_/ / |/ / / __/ |/ |/ /\n/_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, / /_/ |_/ .___/ .___/|___/_/\\___/|__/|__/\n /____/ /_/ /_/\n\nRocksky AppView\n\nDocs: https://docs.rocksky.app\nSource: https://tangled.org/rocksky.app/rocksky\n");});
app.post("/now-playing", async (c) => { requestCounter.add(1, { method: "POST", route: "/now-playing" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const { did } = await verifyToken(bearer);
const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const body = await c.req.json(); consola.debug( `[now-playing] received payload for ${chalk.cyan(did)}:`, JSON.stringify(body), );
const parsed = trackSchema.safeParse(body);
if (parsed.error) { consola.warn( `[now-playing] validation failed for ${chalk.cyan(did)}:`, parsed.error.flatten(), ); c.status(400); return c.text("Invalid track data: " + parsed.error.message); } const track = parsed.data; consola.debug( `[now-playing] validation passed for ${chalk.cyan(did)} — title="${track.title}" artist="${track.artist}" duration=${track.duration} timestamp=${track.timestamp}`, );
const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn( `[now-playing] no agent for ${chalk.cyan(did)}, returning 401`, ); c.status(401); return c.json(pdsSessionExpired); } consola.debug( `[now-playing] agent created for ${chalk.cyan(did)}, accepting durable submission`, );
try { await assertNotBotFlagged(ctx, user.did); await assertNotScrobbleBlocked(ctx, user.did); const key = c.req.header("idempotency-key"); if (key && key.length > 512) return c.json({ error: "Invalid Idempotency-Key" }, 400); const submissionId = await enqueueSubmission( { url: env.SUBMISSIONS_URL, secret: env.SUBMISSIONS_SECRET }, user.did, track, key, ); c.header("X-Submission-Id", submissionId); } catch (err) { if (err instanceof SubmissionUnavailable) return c.json({ error: err.message }, 503); if (err instanceof ScrobbleBotFlaggedError) { consola.warn( `[now-playing] rejected bot-flagged account ${chalk.cyan(did)}`, ); c.status(429); return c.json({ status: "blocked", error: "account_flagged_as_bot", message: scrobbleBotFlaggedMessage(), }); } if (err instanceof ScrobbleBlockedError) { consola.warn( `[now-playing] blocked suspected bot ${chalk.cyan(did)} — retry after ${err.retryAfter}s`, ); c.status(429); c.header("Retry-After", err.retryAfter.toString()); return c.json({ status: "blocked", error: "scrobble_rate_exceeded", message: scrobbleBlockedMessage(err.retryAfter), retryAfter: err.retryAfter, }); } throw err; }
return c.json({ status: "ok" });});
app.get("/now-playing", async (c) => { requestCounter.add(1, { method: "GET", route: "/now-playing" });
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const payload = bearer && bearer !== "null" ? await verifyToken(bearer) : {}; const did = c.req.query("did") || payload.did;
if (!did) { c.status(401); return c.text("Unauthorized"); }
const user = await ctx.db .select() .from(users) .where(or(eq(users.did, did), eq(users.handle, did))) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const [nowPlaying, status] = await Promise.all([ ctx.redis.get(`nowplaying:${user.did}`), ctx.redis.get(`nowplaying:${user.did}:status`), ]); if (!nowPlaying) { return c.json({}); }
// Every `track` frame carries the device's own transport state, and remote-ws // keeps it in step with `status` pushes, so the blob is the truth. Overwriting // it from the status key unconditionally made every player that reports // is_playing but never calls setStatus look permanently paused. const track = JSON.parse(nowPlaying); return c.json({ ...track, is_playing: typeof track.is_playing === "boolean" ? track.is_playing : status === "1", });});
app.get("/now-playings", async (c) => { requestCounter.add(1, { method: "GET", route: "/now-playings" }); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0; const { data } = await ctx.analytics.post("library.getDistinctScrobbles", { pagination: { skip: offset, take: size, }, }); return c.json(data);});
app.post("/likes", async (c) => { requestCounter.add(1, { method: "POST", route: "/likes" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const { did } = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn(`[likes] no agent for ${chalk.cyan(did)}, returning 401`); c.status(401); return c.json(pdsSessionExpired); }
const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const body = await c.req.json(); const parsed = trackSchema.safeParse(body);
if (parsed.error) { c.status(400); return c.text("Invalid track data: " + parsed.error.message); } const track = parsed.data; await likeTrack(ctx, track, user, agent);
return c.json({ status: "ok" });});
app.delete("/likes/:sha256", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/likes/:sha256" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const { did } = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn(`[likes] no agent for ${chalk.cyan(did)}, returning 401`); c.status(401); return c.json(pdsSessionExpired); }
const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const sha256 = c.req.param("sha256"); await unLikeTrack(ctx, sha256, user, agent); return c.json({ status: "ok" });});
app.get("/likes", async (c) => { requestCounter.add(1, { method: "GET", route: "/likes" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const { did } = await verifyToken(bearer);
const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const lovedTracks = await getLovedTracks(ctx, user, size, offset); return c.json(lovedTracks);});
app.get("/public/scrobbles", async (c) => { requestCounter.add(1, { method: "GET", route: "/public/scrobbles" });
const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const scrobbleRecords = await ctx.db .select({ scrobble: scrobbles, track: tracks, user: users, }) .from(scrobbles) .innerJoin(tracks, eq(scrobbles.trackId, tracks.id)) .innerJoin(users, eq(scrobbles.userId, users.id)) .orderBy(desc(scrobbles.timestamp)) .limit(size) .offset(offset);
return c.json( scrobbleRecords.map((item) => ({ cover: item.track.albumArt, artist: item.track.artist, title: item.track.title, date: item.scrobble.timestamp, user: item.user.handle, uri: item.scrobble.uri, albumUri: item.track.albumUri, artistUri: item.track.artistUri, tags: [], listeners: 1, sha256: item.track.sha256, id: item.scrobble.id, })), );});
app.get("/public/scrobbleschart", async (c) => { requestCounter.add(1, { method: "GET", route: "/public/scrobbleschart" });
const did = c.req.query("did"); const artisturi = c.req.query("artisturi"); const albumuri = c.req.query("albumuri"); const songuri = c.req.query("songuri");
if (did) { const chart = await ctx.analytics.post("library.getScrobblesPerDay", { user_did: did, }); return c.json(chart.data); }
if (artisturi) { const chart = await ctx.analytics.post("library.getArtistScrobbles", { artist_id: artisturi, }); return c.json(chart.data); }
if (albumuri) { const chart = await ctx.analytics.post("library.getAlbumScrobbles", { album_id: albumuri, }); return c.json(chart.data); }
if (songuri) { let uri = songuri; if (songuri.includes("app.rocksky.scrobble")) { const scrobble = await ctx.db .select({ scrobble: scrobbles, track: tracks, }) .from(scrobbles) .innerJoin(tracks, eq(scrobbles.trackId, tracks.id)) .where(eq(scrobbles.uri, songuri)) .limit(1) .then((rows) => rows[0]);
uri = scrobble.track.uri; } const chart = await ctx.analytics.post("library.getTrackScrobbles", { track_id: uri, }); return c.json(chart.data); }
const chart = await ctx.analytics.post("library.getScrobblesPerDay", {}); return c.json(chart.data);});
app.get("/scrobbles", async (c) => { requestCounter.add(1, { method: "GET", route: "/scrobbles" });
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const { did } = await verifyToken(bearer);
const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const userScrobbles = await ctx.db .select({ scrobble: scrobbles, track: tracks, }) .from(scrobbles) .innerJoin(tracks, eq(scrobbles.trackId, tracks.id)) .where(and(eq(scrobbles.userId, user.id), isNotNull(scrobbles.uri))) .orderBy(desc(scrobbles.createdAt)) .limit(size) .offset(offset);
return c.json(userScrobbles);});
app.post("/tracks", async (c) => { requestCounter.add(1, { method: "POST", route: "/tracks" });
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const { did } = await verifyToken(bearer);
const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const body = await c.req.json(); const parsed = trackSchema.safeParse(body);
if (parsed.error) { c.status(400); return c.text("Invalid track data: " + parsed.error.message); }
const track = parsed.data;
const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn(`[tracks] no agent for ${chalk.cyan(did)}, returning 401`); c.status(401); return c.json(pdsSessionExpired); }
try { await saveTrack(ctx, track, agent); } catch (e) { if (!e.message.includes("duplicate key value violates unique constraint")) { consola.error("[tracks]", e.message); } }
return c.json({ status: "ok" });});
app.get("/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/tracks" });
const size = +c.req.query("size") || 100; const offset = +c.req.query("offset") || 0;
const tracksData = await ctx.analytics.post("library.getTracks", { pagination: { skip: offset, take: size, }, });
return c.json(tracksData.data);});
app.get("/albums", async (c) => { requestCounter.add(1, { method: "GET", route: "/albums" });
const size = +c.req.query("size") || 100; const offset = +c.req.query("offset") || 0;
const albumsData = await ctx.analytics.post("library.getAlbums", { pagination: { skip: offset, take: size, }, });
return c.json(albumsData.data);});
app.get("/artists", async (c) => { requestCounter.add(1, { method: "GET", route: "/artists" });
const size = +c.req.query("size") || 100; const offset = +c.req.query("offset") || 0;
const artistsData = await ctx.analytics.post("library.getArtists", { pagination: { skip: offset, take: size, }, });
return c.json(artistsData.data);});
app.get("/tracks/:sha256", async (c) => { requestCounter.add(1, { method: "GET", route: "/tracks/:sha256" });
const sha256 = c.req.param("sha256"); const track = await ctx.db .select() .from(tracks) .where(eq(tracks.sha256, sha256)) .limit(1) .then((rows) => rows[0]);
return c.json(track);});
app.get("/albums/:sha256", async (c) => { requestCounter.add(1, { method: "GET", route: "/albums/:sha256" });
const sha256 = c.req.param("sha256"); const album = await ctx.db .select() .from(albums) .where(eq(albums.sha256, sha256)) .limit(1) .then((rows) => rows[0]);
return c.json(album);});
app.get("/artists/:sha256", async (c) => { requestCounter.add(1, { method: "GET", route: "/artists/:sha256" });
const sha256 = c.req.param("sha256"); const artist = await ctx.db .select() .from(artists) .where(eq(artists.sha256, sha256)) .limit(1) .then((rows) => rows[0]);
return c.json(artist);});
app.get("/artists/:sha256/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/artists/:sha256/tracks" }); const sha256 = c.req.param("sha256");
const artistTracksData = await ctx.db .select({ track: tracks, artistName: artists.name, }) .from(artistTracks) .innerJoin(tracks, eq(artistTracks.trackId, tracks.id)) .innerJoin(artists, eq(artistTracks.artistId, artists.id)) .where(eq(artists.sha256, sha256));
return c.json( artistTracksData // artist_tracks has rows linking tracks to artists that don't credit // them; without the guard one of them hands back a stranger's track. .filter((item) => creditsArtist(item.artistName, item.track)) .map((item) => item.track), );});
app.get("/albums/:sha256/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/albums/:sha256/tracks" }); const sha256 = c.req.param("sha256");
const albumTracksData = await ctx.db .select({ track: tracks, }) .from(albumTracks) .innerJoin(tracks, eq(albumTracks.trackId, tracks.id)) .innerJoin(albums, eq(albumTracks.albumId, albums.id)) .where(eq(albums.sha256, sha256));
return c.json(albumTracksData.map((item) => item.track));});
app.route("/uploads", uploadsApp);
app.route("/users", usersApp);
app.route("/webscrobbler", webscrobbler);
serve({ fetch: app.fetch, port: 8000,});