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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883import { consola } from "consola";import { ctx } from "context";import { and, eq, or, sql } from "drizzle-orm";import { Hono } from "hono";import { decrypt, encrypt } from "lib/crypto";import { env } from "lib/env";import { verifyToken } from "lib/verifyToken";import _ from "lodash";import { requestCounter } from "metrics";import crypto, { createHash } from "node:crypto";import { rateLimiter } from "ratelimiter";import lovedTracks from "schema/loved-tracks";import spotifyAccounts from "schema/spotify-accounts";import spotifyApps from "schema/spotify-apps";import spotifyTokens from "schema/spotify-tokens";import tracks from "schema/tracks";import users from "schema/users";import { emailSchema } from "types/email";
const app = new Hono();
app.use( "/currently-playing", rateLimiter({ limit: 10, // max Spotify API calls window: 15, // per 10 seconds keyPrefix: "spotify-ratelimit", }),);
app.get("/login", async (c) => { requestCounter.add(1, { method: "GET", route: "/spotify/login" }); 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 spotifyAccount = await ctx.db .select() .from(spotifyAccounts) .leftJoin(users, eq(spotifyAccounts.userId, users.id)) .leftJoin( spotifyApps, eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId), ) .where( and( eq(spotifyAccounts.userId, user.id), eq(spotifyAccounts.isBetaUser, true), ), ) .limit(1) .then((rows) => rows[0]);
const state = crypto.randomBytes(16).toString("hex"); ctx.kv.set(state, did); const scopes = [ "user-read-private", "user-read-email", "user-read-playback-state", "user-read-currently-playing", "user-modify-playback-state", "playlist-modify-public", "playlist-modify-private", "playlist-read-private", "playlist-read-collaborative", ]; const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${spotifyAccount?.spotify_apps?.spotifyAppId}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=${scopes.join("%20")}&state=${state}`; c.header( "Set-Cookie", `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, ); return c.json({ redirectUrl });});
app.get("/callback", async (c) => { requestCounter.add(1, { method: "GET", route: "/spotify/callback" }); const params = new URLSearchParams(c.req.url.split("?")[1]); const { code, state } = Object.fromEntries(params.entries());
if (!state) { return c.redirect(env.FRONTEND_URL); }
const did = ctx.kv.get(state); if (!did) { return c.redirect(env.FRONTEND_URL); }
ctx.kv.delete(state); const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]);
if (!user) { return c.redirect(env.FRONTEND_URL); }
const spotifyAccount = await ctx.db .select() .from(spotifyAccounts) .leftJoin( spotifyApps, eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId), ) .where( and( eq(spotifyAccounts.userId, user.id), eq(spotifyAccounts.isBetaUser, true), ), ) .limit(1) .then((rows) => rows[0]);
const spotifyAppId = spotifyAccount.spotify_accounts.spotifyAppId ? spotifyAccount.spotify_accounts.spotifyAppId : env.SPOTIFY_CLIENT_ID; const spotifySecret = spotifyAccount.spotify_apps.spotifySecret ? spotifyAccount.spotify_apps.spotifySecret : env.SPOTIFY_CLIENT_SECRET;
const response = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: env.SPOTIFY_REDIRECT_URI, client_id: spotifyAppId, client_secret: decrypt(spotifySecret, env.SPOTIFY_ENCRYPTION_KEY), }), }); const { access_token, refresh_token, }: { access_token: string; refresh_token: string; } = await response.json();
const existingSpotifyToken = await ctx.db .select() .from(spotifyTokens) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (existingSpotifyToken) { await ctx.db .update(spotifyTokens) .set({ accessToken: encrypt(access_token, env.SPOTIFY_ENCRYPTION_KEY), refreshToken: encrypt(refresh_token, env.SPOTIFY_ENCRYPTION_KEY), }) .where(eq(spotifyTokens.id, existingSpotifyToken.id)); } else { await ctx.db.insert(spotifyTokens).values({ userId: user.id, accessToken: encrypt(access_token, env.SPOTIFY_ENCRYPTION_KEY), refreshToken: encrypt(refresh_token, env.SPOTIFY_ENCRYPTION_KEY), spotifyAppId, }); }
const spotifyUser = await ctx.db .select() .from(spotifyAccounts) .where( and( eq(spotifyAccounts.userId, user.id), eq(spotifyAccounts.isBetaUser, true), ), ) .limit(1) .then((rows) => rows[0]);
if (spotifyUser?.email) { ctx.nc.publish("rocksky.spotify.user", Buffer.from(spotifyUser.email)); }
return c.redirect(env.FRONTEND_URL);});
app.post("/join", async (c) => { requestCounter.add(1, { method: "POST", route: "/spotify/join" }); 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 = emailSchema.safeParse(body);
if (parsed.error) { c.status(400); return c.text(`Invalid email: ${parsed.error.message}`); }
const apps = await ctx.db .select({ appId: spotifyApps.id, spotifyAppId: spotifyApps.spotifyAppId, accountCount: sql<number>`COUNT(${spotifyAccounts.id})`.as( "account_count", ), }) .from(spotifyApps) .leftJoin( spotifyAccounts, eq(spotifyApps.spotifyAppId, spotifyAccounts.spotifyAppId), ) .groupBy(spotifyApps.id, spotifyApps.spotifyAppId) .having(sql`COUNT(${spotifyAccounts.id}) < 25`);
const { email } = parsed.data;
try { await ctx.db.insert(spotifyAccounts).values({ userId: user.id, email, isBetaUser: false, spotifyAppId: _.get(apps, "[0].spotifyAppId"), }); } catch (e) { if (!e.message.includes("duplicate key value violates unique constraint")) { consola.error(e.message); } else { throw e; } }
await fetch("https://beta.rocksky.app", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.ROCKSKY_BETA_TOKEN}`, }, body: JSON.stringify({ email }), });
return c.json({ status: "ok" });});
app.get("/currently-playing", async (c) => { requestCounter.add(1, { method: "GET", route: "/spotify/currently-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 spotifyAccount = await ctx.db .select({ spotifyAccount: spotifyAccounts, user: users, }) .from(spotifyAccounts) .innerJoin(users, eq(spotifyAccounts.userId, users.id)) .where(or(eq(users.did, did), eq(users.handle, did))) .limit(1) .then((rows) => rows[0]);
if (!spotifyAccount) { c.status(401); return c.text("Unauthorized"); }
const cached = await ctx.redis.get( `${spotifyAccount.spotifyAccount.email}:current`, ); if (!cached) { return c.json({}); }
const track = JSON.parse(cached);
const sha256 = createHash("sha256") .update( `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), ) .digest("hex");
const [result, liked] = await Promise.all([ ctx.db .select() .from(tracks) .where(eq(tracks.sha256, sha256)) .limit(1) .then((rows) => rows[0]), ctx.db .select({ lovedTrack: lovedTracks, track: tracks, }) .from(lovedTracks) .innerJoin(tracks, eq(lovedTracks.trackId, tracks.id)) .where(and(eq(lovedTracks.userId, user.id), eq(tracks.sha256, sha256))) .limit(1) .then((rows) => rows[0]), ]);
return c.json({ ...track, songUri: result?.uri, artistUri: result?.artistUri, albumUri: result?.albumUri, liked: !!liked, sha256, });});
app.put("/pause", async (c) => { requestCounter.add(1, { method: "PUT", route: "/spotify/pause" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const { did } = bearer && bearer !== "null" ? await verifyToken(bearer) : {};
if (!did) { c.status(401); return c.text("Unauthorized"); }
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 spotifyToken = await ctx.db .select() .from(spotifyTokens) .leftJoin( spotifyApps, eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), ) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (!spotifyToken) { c.status(401); return c.text("Unauthorized"); }
const refreshToken = decrypt( spotifyToken.spotify_tokens.refreshToken, env.SPOTIFY_ENCRYPTION_KEY, );
// get new access token const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: spotifyToken.spotify_apps.spotifyAppId, client_secret: decrypt( spotifyToken.spotify_apps.spotifySecret, env.SPOTIFY_ENCRYPTION_KEY, ), }), });
const { access_token } = (await newAccessToken.json()) as { access_token: string; };
const response = await fetch(`${env.SPOTIFY_API_URL}/me/player/pause`, { method: "PUT", headers: { Authorization: `Bearer ${access_token}`, }, });
if (response.status === 403) { c.status(403); return c.text(await response.text()); }
return c.json(await response.json());});
app.put("/play", async (c) => { requestCounter.add(1, { method: "PUT", route: "/spotify/play" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const { did } = bearer && bearer !== "null" ? await verifyToken(bearer) : {};
if (!did) { c.status(401); return c.text("Unauthorized"); }
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 spotifyToken = await ctx.db .select() .from(spotifyTokens) .leftJoin( spotifyApps, eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), ) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (!spotifyToken) { c.status(401); return c.text("Unauthorized"); }
const refreshToken = decrypt( spotifyToken.spotify_tokens.refreshToken, env.SPOTIFY_ENCRYPTION_KEY, );
// get new access token const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: spotifyToken.spotify_apps.spotifyAppId, client_secret: decrypt( spotifyToken.spotify_apps.spotifySecret, env.SPOTIFY_ENCRYPTION_KEY, ), }), });
const { access_token } = (await newAccessToken.json()) as { access_token: string; };
const response = await fetch(`${env.SPOTIFY_API_URL}/me/player/play`, { method: "PUT", headers: { Authorization: `Bearer ${access_token}`, }, });
if (response.status === 403) { c.status(403); return c.text(await response.text()); }
return c.json(await response.json());});
app.post("/next", async (c) => { requestCounter.add(1, { method: "POST", route: "/spotify/next" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const { did } = bearer && bearer !== "null" ? await verifyToken(bearer) : {};
if (!did) { c.status(401); return c.text("Unauthorized"); }
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 spotifyToken = await ctx.db .select() .from(spotifyTokens) .leftJoin( spotifyApps, eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), ) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (!spotifyToken) { c.status(401); return c.text("Unauthorized"); }
const refreshToken = decrypt( spotifyToken.spotify_tokens.refreshToken, env.SPOTIFY_ENCRYPTION_KEY, );
// get new access token const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: spotifyToken.spotify_apps.spotifyAppId, client_secret: decrypt( spotifyToken.spotify_apps.spotifySecret, env.SPOTIFY_ENCRYPTION_KEY, ), }), });
const { access_token } = (await newAccessToken.json()) as { access_token: string; };
const response = await fetch(`${env.SPOTIFY_API_URL}/me/player/next`, { method: "POST", headers: { Authorization: `Bearer ${access_token}`, }, });
if (response.status === 403) { c.status(403); return c.text(await response.text()); }
return c.json(await response.json());});
app.post("/previous", async (c) => { requestCounter.add(1, { method: "POST", route: "/spotify/previous" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const { did } = bearer && bearer !== "null" ? await verifyToken(bearer) : {};
if (!did) { c.status(401); return c.text("Unauthorized"); }
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 spotifyToken = await ctx.db .select() .from(spotifyTokens) .leftJoin( spotifyApps, eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), ) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (!spotifyToken) { c.status(401); return c.text("Unauthorized"); }
const refreshToken = decrypt( spotifyToken.spotify_tokens.refreshToken, env.SPOTIFY_ENCRYPTION_KEY, );
// get new access token const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: spotifyToken.spotify_apps.spotifyAppId, client_secret: decrypt( spotifyToken.spotify_apps.spotifySecret, env.SPOTIFY_ENCRYPTION_KEY, ), }), });
const { access_token } = (await newAccessToken.json()) as { access_token: string; };
const response = await fetch(`${env.SPOTIFY_API_URL}/me/player/previous`, { method: "POST", headers: { Authorization: `Bearer ${access_token}`, }, });
if (response.status === 403) { c.status(403); return c.text(await response.text()); }
return c.json(await response.json());});
app.put("/seek", async (c) => { requestCounter.add(1, { method: "PUT", route: "/spotify/seek" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const { did } = bearer && bearer !== "null" ? await verifyToken(bearer) : {};
if (!did) { c.status(401); return c.text("Unauthorized"); }
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 spotifyToken = await ctx.db .select() .from(spotifyTokens) .leftJoin( spotifyApps, eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), ) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (!spotifyToken) { c.status(401); return c.text("Unauthorized"); }
const refreshToken = decrypt( spotifyToken.spotify_tokens.refreshToken, env.SPOTIFY_ENCRYPTION_KEY, );
// get new access token const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: spotifyToken.spotify_apps.spotifyAppId, client_secret: decrypt( spotifyToken.spotify_apps.spotifySecret, env.SPOTIFY_ENCRYPTION_KEY, ), }), });
const { access_token } = (await newAccessToken.json()) as { access_token: string; };
const position = c.req.query("position_ms"); const response = await fetch( `${env.SPOTIFY_API_URL}/me/player/seek?position_ms=${position}`, { method: "PUT", headers: { Authorization: `Bearer ${access_token}`, }, }, );
if (response.status === 403) { c.status(403); return c.text(await response.text()); }
return c.json(await response.json());});
app.on(["GET", "PUT"], "/volume", async (c) => { requestCounter.add(1, { method: c.req.method, route: "/spotify/volume" }); const rawVolume = c.req.query("volume_percent"); const volume = rawVolume === undefined ? NaN : Number(rawVolume); if ( c.req.method === "PUT" && (!Number.isInteger(volume) || volume < 0 || volume > 100) ) { return c.json({ error: "Volume must be an integer from 0 to 100" }, 400); } const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
const { did } = bearer && bearer !== "null" ? await verifyToken(bearer) : {};
if (!did) { c.status(401); return c.text("Unauthorized"); }
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 spotifyToken = await ctx.db .select() .from(spotifyTokens) .leftJoin( spotifyApps, eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), ) .where(eq(spotifyTokens.userId, user.id)) .limit(1) .then((rows) => rows[0]);
if (!spotifyToken) { c.status(401); return c.text("Unauthorized"); }
const refreshToken = decrypt( spotifyToken.spotify_tokens.refreshToken, env.SPOTIFY_ENCRYPTION_KEY, );
// get new access token const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: spotifyToken.spotify_apps.spotifyAppId, client_secret: decrypt( spotifyToken.spotify_apps.spotifySecret, env.SPOTIFY_ENCRYPTION_KEY, ), }), });
if (!newAccessToken.ok) return c.json({ error: "Spotify authorization failed" }, 401);
const { access_token } = (await newAccessToken.json()) as { access_token: string; };
if (!access_token) return c.json({ error: "Spotify authorization failed" }, 401); const response = await fetch( c.req.method === "GET" ? `${env.SPOTIFY_API_URL}/me/player` : `${env.SPOTIFY_API_URL}/me/player/volume?volume_percent=${volume}`, { method: c.req.method, headers: { Authorization: `Bearer ${access_token}` }, }, ); if (!response.ok) { return new Response(await response.text(), { status: response.status, headers: { "Content-Type": response.headers.get("Content-Type") || "text/plain", }, }); } if (c.req.method === "PUT") return c.body(null, 204); if (response.status === 204) return c.json({ volume: null, supported: false }); const playback = (await response.json()) as { device?: { volume_percent?: number | null; supports_volume?: boolean; is_restricted?: boolean; }; }; const device = playback.device; return c.json({ volume: typeof device?.volume_percent === "number" ? device.volume_percent : null, supported: device?.supports_volume === true && !device.is_restricted, });});
export default app;