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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063import { GetObjectCommand, PutObjectCommand, S3Client,} from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";import { consola } from "consola";import { ctx } from "context";import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";import { resolveStorageClient } from "storage/app";import { Hono } from "hono";import { createAgent } from "lib/agent";import { env } from "lib/env";import { verifyToken } from "lib/verifyToken";import { parseBuffer } from "music-metadata";import { createHash } from "node:crypto";import tables from "schema";import { saveTrack } from "tracks/tracks.service";import { analyzeUploadAudio } from "uploads/analysis";import { purgeUploads } from "uploads/delete.service";import { ensureReplayGain } from "uploads/replaygain";import { indexLibraryTrack, searchLibraryTracks } from "typesense/library";
// ---------------------------------------------------------------------------// Allowed audio MIME types// ---------------------------------------------------------------------------
const ALLOWED_MIME_TYPES = new Set([ "audio/mpeg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/ogg", "audio/wav", "audio/x-wav", "audio/aiff", "audio/x-aiff",]);
const MIME_TO_EXT: Record<string, string> = { "audio/mpeg": "mp3", "audio/flac": "flac", "audio/mp4": "m4a", "audio/x-m4a": "m4a", "audio/ogg": "ogg", "audio/wav": "wav", "audio/x-wav": "wav", "audio/aiff": "aiff", "audio/x-aiff": "aiff",};
const PICTURE_MIME_TO_EXT: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/bmp": "bmp", "image/tiff": "tiff",};
// ---------------------------------------------------------------------------// Magic-byte signatures for format detection (don't trust Content-Type)// ---------------------------------------------------------------------------
const MAGIC: Array<{ bytes: number[]; mime: string }> = [ { bytes: [0x49, 0x44, 0x33], mime: "audio/mpeg" }, // ID3 tag → MP3 { bytes: [0xff, 0xfb], mime: "audio/mpeg" }, // MPEG frame sync { bytes: [0xff, 0xf3], mime: "audio/mpeg" }, { bytes: [0xff, 0xf2], mime: "audio/mpeg" }, { bytes: [0x66, 0x4c, 0x61, 0x43], mime: "audio/flac" }, // fLaC { bytes: [0x4f, 0x67, 0x67, 0x53], mime: "audio/ogg" }, // OggS { bytes: [0x52, 0x49, 0x46, 0x46], mime: "audio/wav" }, // RIFF → WAV { bytes: [0x46, 0x4f, 0x52, 0x4d], mime: "audio/aiff" }, // FORM → AIFF];
function detectMime(buf: Buffer): string | null { for (const sig of MAGIC) { if (sig.bytes.every((b, i) => buf[i] === b)) return sig.mime; } // M4A / MP4: ftyp box starts at byte 4 if ( buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70 ) { return "audio/mp4"; } return null;}
// ---------------------------------------------------------------------------// SHA-256 — computed from track metadata:// sha256("{title} - {artist} - {album}".to_lowercase())// This ensures the same track is deduplicated across all ingestion sources.// ---------------------------------------------------------------------------
function metaSha256(title: string, artist: string, album: string): string { return createHash("sha256") .update(`${title} - ${artist} - ${album}`.toLowerCase()) .digest("hex");}
// Content hash — used only for the R2 storage key so different files with// the same metadata don't overwrite each other in the bucket.function contentSha256(buf: Buffer): string { return createHash("sha256").update(buf).digest("hex");}
// Album art cover filename: MD5 of "{albumArtist} - {album}".toLowerCase()// Matches the Rust crate convention so covers aren't duplicated.function coverMd5(albumArtist: string, album: string): string { return createHash("md5") .update(`${albumArtist} - ${album}`.toLowerCase()) .digest("hex");}
// ---------------------------------------------------------------------------// S3-compatible client (provider-agnostic via env vars)// ---------------------------------------------------------------------------
function makeS3Client(): S3Client { return new S3Client({ region: env.S3_REGION, endpoint: env.S3_ENDPOINT, credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY, }, });}
// ---------------------------------------------------------------------------// Auth helper — resolves JWT → user row// ---------------------------------------------------------------------------
async function resolveUser(authHeader: string | undefined | null) { const bearer = (authHeader || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") return null; const { did } = (await verifyToken(bearer)) as { did: string }; return ctx.db .select() .from(tables.users) .where(eq(tables.users.did, did)) .limit(1) .then((rows) => rows[0] ?? null);}
// ---------------------------------------------------------------------------// Router// ---------------------------------------------------------------------------
const app = new Hono();
// POST /uploads/track --------------------------------------------------------// Pipeline:// 1. JWT auth// 2. Magic-byte MIME check → reject non-audio// 3. music-metadata parse (no primary tag → reject)// 4. Completeness check: title + artist + album required// 5. Meta-SHA256 dedup// 6. Duplicate-per-user check by trackId// 7. Album art extraction + upload to covers/// 8. Upsert track row (duration in ms, discNumber defaults to 1)// 9. PUT audio file to music/ using content-SHA256 storage key// 10. Insert user_uploads recordapp.post("/track", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
let formData: FormData; try { formData = await c.req.formData(); } catch { c.status(400); return c.text("Invalid multipart form data"); }
const file = formData.get("file") as File | null; if (!file) { c.status(400); return c.json({ error: "NO_FILE", message: "No file provided" }); }
const storageProviderIdParam = (formData.get("storage_provider_id") as string | null) || null;
const buf = Buffer.from(await file.arrayBuffer());
// --- Format validation (magic bytes, not Content-Type) --- const mime = detectMime(buf); if (!mime || !ALLOWED_MIME_TYPES.has(mime)) { c.status(400); return c.json({ error: "INVALID_FORMAT", message: "Only audio files are accepted (MP3, FLAC, M4A, OGG, WAV, AIFF)", }); }
// --- Metadata extraction --- let metadata: Awaited<ReturnType<typeof parseBuffer>>; try { metadata = await parseBuffer(buf, { mimeType: mime, duration: true }); } catch (e) { consola.error("[uploads] metadata parse error", e); c.status(422); return c.json({ error: "METADATA_PARSE_FAILED", message: "Could not read audio tags from this file", }); }
const { common, format } = metadata;
// No primary tag block at all → reject (mirrors lofty primary_tag check) if (!common.title && !common.artist && !common.album) { c.status(422); return c.json({ error: "NO_TAGS", message: "No audio tags found in this file. Please tag your file before uploading.", }); }
// --- Completeness check --- // Validates all fields required by the app.rocksky.song ATProto lexicon: // required: [title, artist, album, albumArtist, duration, createdAt] // albumArtist falls back to artist; createdAt is server-set. // Constraints: title ≤ 512, artist/albumArtist/album ≤ 256, duration ≥ 1. // music-metadata doesn't parse VBRI headers (Fraunhofer VBR) into format.duration, // so fall back to the TLEN ID3 tag (stored in ms) when the stream scan yields nothing. let durationMs = format.duration ? Math.round(format.duration * 1000) : 0; if (durationMs < 1) { const tlen = Object.values(metadata.native) .flat() .find((t) => t.id === "TLEN"); if (tlen) durationMs = parseInt(tlen.value, 10); }
const missing: string[] = []; if (!common.title?.trim()) missing.push("title"); else if (common.title.trim().length > 512) missing.push("title (too long, max 512 chars)"); if (!common.artist?.trim()) missing.push("artist"); else if (common.artist.trim().length > 256) missing.push("artist (too long, max 256 chars)"); if (!common.album?.trim()) missing.push("album"); else if (common.album.trim().length > 256) missing.push("album (too long, max 256 chars)"); if (durationMs < 1) missing.push("duration"); if (!common.picture?.length) missing.push("album art");
// albumArtist falls back to artist, but we still check its length if explicitly set const albumArtistRaw = common.albumartist?.trim() || common.artist?.trim() || ""; if (albumArtistRaw.length > 256) missing.push("albumArtist (too long, max 256 chars)");
if (missing.length > 0) { c.status(422); return c.json({ error: "INCOMPLETE_METADATA", message: `Missing required tags: ${missing.join(", ")}. Please tag your file before uploading.`, missingFields: missing, }); }
const title = common.title!.trim(); const artist = common.artist!.trim(); const album = common.album!.trim(); const albumArtist = common.albumartist?.trim() || artist;
// --- ReplayGain: tag untagged files so players can normalize loudness --- // The rockbox engine (desktop + wasm) only applies ReplayGain from tags in // the stream itself; without this, every untagged file plays at mastered // loudness regardless of the user's ReplayGain setting. const ext = MIME_TO_EXT[mime] ?? "bin"; const hasGainTag = common.replaygain_track_gain?.dB != null; const storedBuf = hasGainTag ? buf : await ensureReplayGain(buf, ext);
// --- Content SHA-256: used for upload dedup and R2 storage key --- // Hash the stored (possibly retagged) bytes so the key matches the object. const fileHash = contentSha256(storedBuf); const storageKey = `music/${user.id}/${fileHash}.${ext}`;
const existingUpload = await ctx.db .select() .from(tables.userUploads) .where( and( eq(tables.userUploads.userId, user.id), eq(tables.userUploads.r2Key, storageKey), ), ) .limit(1) .then((rows) => rows[0] ?? null);
if (existingUpload) { c.status(409); return c.json({ error: "DUPLICATE_FILE", message: "This exact file is already in your library", uploadId: existingUpload.id, }); }
// --- Metadata SHA-256: global track dedup across all ingestion sources --- const sha256 = metaSha256(title, artist, album);
const existingTrack = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.sha256, sha256)) .limit(1) .then((rows) => rows[0] ?? null);
// Track already exists — skip saveTrack (no new ATProto record needed)
// --- Album art: extract embedded picture, upload to covers/ --- let albumArtUrl: string | null = null; const picture = common.picture?.[0]; if (picture && PICTURE_MIME_TO_EXT[picture.format]) { try { const ext = PICTURE_MIME_TO_EXT[picture.format]; const coverKey = `covers/${coverMd5(albumArtist, album)}.${ext}`; const s3 = makeS3Client(); await s3.send( new PutObjectCommand({ Bucket: env.S3_COVERS_BUCKET_NAME, Key: coverKey, Body: picture.data, ContentType: picture.format, }), ); albumArtUrl = `${env.CDN_URL}/${coverKey}`; } catch (e) { consola.warn( "[uploads] album art upload failed, continuing without it", e, ); } }
// --- Disc number: default to 1 when 0 or missing (matches Rust crate) --- const discNumber = (() => { const d = common.disk?.no; if (!d || d === 0) return 1; return d; })();
// --- Release date: only store if it contains "-" (excludes year-only) --- const releaseDate = (() => { const d = common.date?.toString() ?? common.originaldate?.toString(); return d?.includes("-") ? d : null; })();
// --- Publish ATProto record + upsert track (proper at:// URI via agent) --- // Publish the uploaded track: POST to /tracks, // which calls saveTrack → putSongRecord → real at://{did}/app.rocksky.song/{rkey} if (!existingTrack) { const agent = await createAgent(ctx.oauthClient, user.did); if (!agent) { c.status(401); return c.text("Unauthorized"); } await saveTrack( ctx, { title, artist, album, albumArtist, duration: durationMs, trackNumber: common.track?.no ?? null, discNumber, genre: common.genre?.[0] ?? null, genres: common.genre ?? null, composer: common.composer?.[0] ?? null, albumArt: albumArtUrl, releaseDate: releaseDate ? new Date(releaseDate) : null, year: common.year ?? null, lyrics: common.lyrics ?? null, copyrightMessage: common.copyright ?? null, mbId: common.musicbrainz_trackid ?? common.musicbrainz_recordingid ?? null, isrc: common.isrc?.[0]?.trim() || null, // Last.fm uses a canonical URL of the form // https://www.last.fm/music/{artist}/_/{track} // which redirects gracefully if the exact title isn't indexed, so we // can derive it from the upload's metadata without a network call. lastfmLink: `https://www.last.fm/music/${encodeURIComponent(artist)}/_/${encodeURIComponent(title)}`, artists: null, label: common.label?.[0] ?? null, artistPicture: null, spotifyLink: null, youtubeLink: null, tidalLink: null, appleMusicLink: null, deezerLink: null, timestamp: null, }, agent, );
// Respect ATProto PDS rate limits — mirrors the 3-second sleep the // track ingestion uses after creating a new track record. // The frontend processes files sequentially so this delay is applied // per-file, effectively throttling bulk uploads to ~1 track per 3s. await new Promise((resolve) => setTimeout(resolve, 3000)); }
// After saveTrack the track row is guaranteed to exist — look it up by sha256 const track = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.sha256, sha256)) .limit(1) .then((rows) => rows[0]);
// --- Upload audio file to storage (managed or BYO) --- const { client: s3, bucket, storageProviderId, } = await resolveStorageClient(user.id, storageProviderIdParam); await s3.send( new PutObjectCommand({ Bucket: bucket, Key: storageKey, Body: storedBuf, ContentType: mime, ContentLength: storedBuf.length, Metadata: { userId: user.id, trackId: track.id, originalFilename: encodeURIComponent(file.name), }, }), );
// --- Index upload --- const [upload] = await ctx.db .insert(tables.userUploads) .values({ userId: user.id, trackId: track.id, r2Key: storageKey, mimeType: mime, fileSize: storedBuf.length, originalFilename: file.name, storageProviderId, sampleRate: format.sampleRate ?? null, }) .returning();
// Fire-and-forget key/BPM/fingerprint analysis — decodes on the Rust worker // pool and coalesce-fills tracks.key / tracks.bpm / // tracks.acoustid_fingerprint. The response never waits on it and an // analysis failure can never fail the upload. if ( track.key == null || track.bpm == null || track.acoustidFingerprint == null ) { analyzeUploadAudio(storedBuf, ext, track.id).catch((e) => consola.warn("[analysis] unexpected failure:", e), ); }
// Fire-and-forget Typesense indexing indexLibraryTrack({ id: upload.id, user_id: user.id, track_id: track.id, title: track.title, artist: track.artist, album: track.album, album_artist: track.albumArtist ?? track.artist, genre: track.genre ?? undefined, composer: track.composer ?? undefined, year: track.year ?? undefined, duration: track.duration, album_art: track.albumArt ?? undefined, r2_key: storageKey, mime_type: mime, file_size: storedBuf.length, original_filename: file.name, uploaded_at: upload.uploadedAt.getTime(), mb_id: track.mbId ?? undefined, track_number: track.trackNumber ?? undefined, disc_number: track.discNumber ?? undefined, }).catch((e) => consola.warn("[typesense] index failed:", e));
return c.json({ uploadId: upload.id, trackId: track.id, track: { title: track.title, artist: track.artist, album: track.album, duration: track.duration, genre: track.genre, }, });});
// GET /uploads ---------------------------------------------------------------app.get("/", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const size = Math.min(+c.req.query("size") || 50, 200); const offset = +c.req.query("offset") || 0; const q = c.req.query("q")?.trim() || undefined; const albumUri = c.req.query("albumUri")?.trim() || undefined; const albumArtist = c.req.query("albumArtist")?.trim() || undefined; const albumName = c.req.query("albumName")?.trim() || undefined;
// Full-text search via Typesense; album/listing queries stay on Postgres. if (q) { const hits = await searchLibraryTracks({ query: q, userId: user.id, limit: size, offset, }); if (hits.length === 0) return c.json([]);
// Enrich with full DB data so all fields (albumUri, artistUri, sha256, uri…) // are present — Typesense only stores a subset for search purposes. const uploadIds = hits.map((h) => h.upload.id); const dbRows = await ctx.db .select({ upload: tables.userUploads, track: tables.tracks, albumReleaseDate: tables.albums.releaseDate, albumYear: tables.albums.year, }) .from(tables.userUploads) .innerJoin( tables.tracks, eq(tables.userUploads.trackId, tables.tracks.id), ) .leftJoin(tables.albums, eq(tables.albums.uri, tables.tracks.albumUri)) .where( and( eq(tables.userUploads.userId, user.id), inArray(tables.userUploads.id, uploadIds), ), );
// Preserve Typesense relevance order. const rowMap = new Map(dbRows.map((r) => [r.upload.id, r])); const ordered = uploadIds.map((id) => rowMap.get(id)).filter(Boolean); return c.json(ordered); }
const albumFilter = albumUri ? eq(tables.tracks.albumUri, albumUri) : albumArtist && albumName ? and( eq(tables.tracks.albumArtist, albumArtist), eq(tables.tracks.album, albumName), ) : undefined;
const whereClause = albumFilter ? and(eq(tables.userUploads.userId, user.id), albumFilter) : eq(tables.userUploads.userId, user.id);
const orderByClause = albumFilter ? [ asc(tables.tracks.trackNumber), asc(tables.tracks.title), asc(tables.tracks.artist), ] : [asc(tables.tracks.title), asc(tables.tracks.artist)];
const uploads = await ctx.db .select({ upload: tables.userUploads, track: tables.tracks, albumReleaseDate: tables.albums.releaseDate, albumYear: tables.albums.year, }) .from(tables.userUploads) .innerJoin(tables.tracks, eq(tables.userUploads.trackId, tables.tracks.id)) .leftJoin(tables.albums, eq(tables.albums.uri, tables.tracks.albumUri)) .where(whereClause) .orderBy(...orderByClause) .limit(size) .offset(offset);
return c.json(uploads);});
// GET /uploads/count ---------------------------------------------------------// Read-only count scoped to the signed-in user's uploads. Keeps pagination cheap.app.get("/count", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) return c.json({ error: "Unauthorized" }, 401); const [result] = await ctx.db .select({ total: sql<number>`count(*)::int` }) .from(tables.userUploads) .where(eq(tables.userUploads.userId, user.id)); return c.json({ total: result.total });});
// GET /uploads/albums --------------------------------------------------------// Paginated list of distinct albums the caller has uploaded tracks for.app.get("/albums", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const size = Math.min(+c.req.query("size") || 50, 200); const offset = +c.req.query("offset") || 0; const q = c.req.query("q")?.trim() || undefined;
const conditions = [eq(tables.userUploads.userId, user.id)]; if (q) { const like = `%${q.toLowerCase()}%`; conditions.push( sql`(lower(${tables.tracks.album}) like ${like} or lower(${tables.tracks.albumArtist}) like ${like})`, ); }
const rows = await ctx.db .select({ albumArtist: tables.tracks.albumArtist, album: tables.tracks.album, albumArt: sql<string | null>`max(${tables.tracks.albumArt})`, albumUri: sql<string | null>`max(${tables.tracks.albumUri})`, artistUri: sql<string | null>`max(${tables.tracks.artistUri})`, trackCount: sql<number>`count(distinct ${tables.userUploads.id})::int`, }) .from(tables.userUploads) .innerJoin(tables.tracks, eq(tables.userUploads.trackId, tables.tracks.id)) .where(and(...conditions)) .groupBy(tables.tracks.albumArtist, tables.tracks.album) .orderBy(asc(tables.tracks.album), asc(tables.tracks.albumArtist)) .limit(size) .offset(offset);
return c.json(rows);});
// GET /uploads/artists -------------------------------------------------------// Paginated list of distinct album artists the caller has uploaded tracks for.app.get("/artists", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const size = Math.min(+c.req.query("size") || 50, 200); const offset = +c.req.query("offset") || 0; const q = c.req.query("q")?.trim() || undefined;
const conditions = [eq(tables.userUploads.userId, user.id)]; if (q) { const like = `%${q.toLowerCase()}%`; conditions.push(sql`lower(${tables.tracks.albumArtist}) like ${like}`); }
const rows = await ctx.db .select({ name: tables.tracks.albumArtist, artistUri: sql<string | null>`max(${tables.tracks.artistUri})`, trackCount: sql<number>`count(distinct ${tables.userUploads.id})::int`, albumCount: sql<number>`count(distinct ${tables.tracks.album})::int`, }) .from(tables.userUploads) .innerJoin(tables.tracks, eq(tables.userUploads.trackId, tables.tracks.id)) .where(and(...conditions)) .groupBy(tables.tracks.albumArtist) .orderBy(asc(tables.tracks.albumArtist)) .limit(size) .offset(offset);
return c.json(rows);});
// GET /uploads/stream-token --------------------------------------------------// Issues a short-lived opaque token (random hex, stored in Redis with 1 h TTL)// for use as ?token= in stream URLs. WASM audio can't set HTTP headers so we// use this instead of the full user JWT.app.get("/stream-token", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); } const token = Array.from(crypto.getRandomValues(new Uint8Array(20))) .map((b) => b.toString(16).padStart(2, "0")) .join(""); // Cast receivers preload a queue and fetch later tracks while the sender sleeps. // These opaque credentials remain scoped to upload streaming, never account APIs. const ttl = c.req.query("purpose") === "cast" ? 24 * 3600 : 3600; await ctx.redis.setEx(`stream:${token}`, ttl, user.id); return c.json({ token, expiresIn: ttl });});
// GET /uploads/:id/stream ----------------------------------------------------// Streams audio through the API so the internal R2 URL is never exposed.// Auth: Bearer header or ?token= query param (opaque stream token from /stream-token).// Handles Range requests for seeking.app.options("/:id/stream", (c) => { return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Range, Authorization", "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges", "Access-Control-Max-Age": "86400", }, });});app.get("/:id/stream", async (c) => { // Resolve the user from the opaque stream token in ?token= or the full JWT in Bearer let user: Awaited<ReturnType<typeof resolveUser>> = null;
const tokenParam = c.req.query("token"); if (tokenParam) { const userId = await ctx.redis.get(`stream:${tokenParam}`); if (userId) { user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.id, userId)) .limit(1) .then((rows) => rows[0] ?? null); } } else { user = await resolveUser(c.req.header("authorization")); }
if (!user) { c.status(401); return c.text("Unauthorized"); }
const upload = await ctx.db .select() .from(tables.userUploads) .where( and( eq(tables.userUploads.id, c.req.param("id")), eq(tables.userUploads.userId, user.id), ), ) .limit(1) .then((rows) => rows[0] ?? null);
if (!upload) { c.status(404); return c.text("Upload not found"); }
const s3 = makeS3Client(); const rangeHeader = c.req.header("range");
const s3Res = await s3.send( new GetObjectCommand({ Bucket: env.S3_BUCKET_NAME, Key: upload.r2Key, ...(rangeHeader ? { Range: rangeHeader } : {}), }), );
if (!s3Res.Body) { c.status(500); return c.text("Stream unavailable"); }
const status = rangeHeader ? 206 : 200; const headers: Record<string, string> = { "Content-Type": upload.mimeType || s3Res.ContentType || "audio/mpeg", "Accept-Ranges": "bytes", "Cache-Control": "private, max-age=3600", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Range, Authorization", "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges", }; if (s3Res.ContentLength !== undefined) { headers["Content-Length"] = String(s3Res.ContentLength); } if (rangeHeader && s3Res.ContentRange) { headers["Content-Range"] = s3Res.ContentRange; }
return new Response(s3Res.Body.transformToWebStream(), { status, headers });});
// GET /uploads/queue ---------------------------------------------------------// Returns the persisted upload player queue for the authenticated user.app.get("/queue", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const state = await ctx.db .select() .from(tables.uploadQueueState) .where(eq(tables.uploadQueueState.userId, user.id)) .limit(1) .then((rows) => rows[0] ?? null);
if (!state) { return c.json({ queue: [], currentIndex: 0 }); }
const uploadIds: string[] = JSON.parse(state.uploadIds); if (uploadIds.length === 0) { return c.json({ queue: [], currentIndex: 0 }); }
const rows = await ctx.db .select({ uploadId: tables.userUploads.id, title: tables.tracks.title, artist: tables.tracks.artist, albumArtist: tables.tracks.albumArtist, album: tables.tracks.album, albumArt: tables.tracks.albumArt, duration: tables.tracks.duration, sha256: tables.tracks.sha256, songUri: tables.tracks.uri, }) .from(tables.userUploads) .innerJoin(tables.tracks, eq(tables.userUploads.trackId, tables.tracks.id)) .where( and( inArray(tables.userUploads.id, uploadIds), eq(tables.userUploads.userId, user.id), ), );
const byId = new Map(rows.map((r) => [r.uploadId, r])); const queue = uploadIds.flatMap((id) => { const r = byId.get(id); return r ? [ { uploadId: r.uploadId, title: r.title, artist: r.artist, albumArtist: r.albumArtist, album: r.album, albumArt: r.albumArt, duration: r.duration, sha256: r.sha256, songUri: r.songUri ?? "", }, ] : []; });
return c.json({ queue, currentIndex: state.currentIndex });});
// PUT /uploads/queue ---------------------------------------------------------// Persists the current upload player queue for the authenticated user.app.put("/queue", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const body = await c.req.json<{ uploadIds: string[]; currentIndex: number; }>(); if (!Array.isArray(body.uploadIds) || typeof body.currentIndex !== "number") { c.status(400); return c.text("Invalid body"); }
await ctx.db .insert(tables.uploadQueueState) .values({ userId: user.id, uploadIds: JSON.stringify(body.uploadIds), currentIndex: body.currentIndex, }) .onConflictDoUpdate({ target: tables.uploadQueueState.userId, set: { uploadIds: JSON.stringify(body.uploadIds), currentIndex: body.currentIndex, updatedAt: new Date(), }, });
return c.json({ status: "ok" });});
// DELETE /uploads/album ------------------------------------------------------// Deletes every upload owned by the caller that belongs to a given album.// Album is identified the same way GET /uploads filters: ?albumUri=... or// ?albumArtist=...&albumName=...app.delete("/album", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const albumUri = c.req.query("albumUri")?.trim() || undefined; const albumArtist = c.req.query("albumArtist")?.trim() || undefined; const albumName = c.req.query("albumName")?.trim() || undefined;
const albumFilter = albumUri ? eq(tables.tracks.albumUri, albumUri) : albumArtist && albumName ? and( eq(tables.tracks.albumArtist, albumArtist), eq(tables.tracks.album, albumName), ) : null;
if (!albumFilter) { c.status(400); return c.json({ error: "MISSING_ALBUM", message: "albumUri or albumArtist + albumName is required", }); }
const rows = await ctx.db .select({ upload: tables.userUploads }) .from(tables.userUploads) .innerJoin(tables.tracks, eq(tables.userUploads.trackId, tables.tracks.id)) .where(and(eq(tables.userUploads.userId, user.id), albumFilter));
if (rows.length === 0) { return c.json({ status: "ok", deleted: 0 }); }
const deleted = await purgeUploads( ctx, user.id, rows.map((r) => r.upload), );
return c.json({ status: "ok", deleted });});
// DELETE /uploads/by-track/:trackId -----------------------------------------// Deletes the upload for a given track (identified by track xata_id, which is// the ID navidrome exposes as the song ID).app.delete("/by-track/:trackId", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const upload = await ctx.db .select() .from(tables.userUploads) .where( and( eq(tables.userUploads.trackId, c.req.param("trackId")), eq(tables.userUploads.userId, user.id), ), ) .limit(1) .then((rows) => rows[0] ?? null);
if (!upload) { c.status(404); return c.text("Upload not found"); }
await purgeUploads(ctx, user.id, [upload]);
return c.json({ status: "ok" });});
// DELETE /uploads/by-album/:albumId ------------------------------------------// Deletes every upload owned by the caller that belongs to an album identified// by the album's xata_id (the ID navidrome exposes).app.delete("/by-album/:albumId", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const rows = await ctx.db .select({ upload: tables.userUploads }) .from(tables.userUploads) .innerJoin( tables.albumTracks, eq(tables.albumTracks.trackId, tables.userUploads.trackId), ) .where( and( eq(tables.albumTracks.albumId, c.req.param("albumId")), eq(tables.userUploads.userId, user.id), ), );
if (rows.length === 0) { return c.json({ status: "ok", deleted: 0 }); }
const deleted = await purgeUploads( ctx, user.id, rows.map((r) => r.upload), );
return c.json({ status: "ok", deleted });});
// DELETE /uploads/:id --------------------------------------------------------app.delete("/:id", async (c) => { const user = await resolveUser(c.req.header("authorization")); if (!user) { c.status(401); return c.text("Unauthorized"); }
const upload = await ctx.db .select() .from(tables.userUploads) .where( and( eq(tables.userUploads.id, c.req.param("id")), eq(tables.userUploads.userId, user.id), ), ) .limit(1) .then((rows) => rows[0] ?? null);
if (!upload) { c.status(404); return c.text("Upload not found"); }
await purgeUploads(ctx, user.id, [upload]);
return c.json({ status: "ok" });});
export default app;