diff --git a/src/servers/api/music/router.ts b/src/servers/api/music/router.ts index 70cd9e02..bf24be3c 100644 --- a/src/servers/api/music/router.ts +++ b/src/servers/api/music/router.ts @@ -1,172 +1,18 @@ import { createRouter } from '../../create-router'; import { getMusicServerUrl } from './sidecar-server'; -import { - getMusicFavorites, - addMusicFavorite, - removeMusicFavorite, - getNowPlaying, - setNowPlaying, - clearNowPlaying, - getPlaylists, - getPlaylist, - createPlaylist, - renamePlaylist, - deletePlaylist, - addPlaylistItems, - setPlaylistItems, - type FavoriteKind, -} from 'officerdb'; -// Thin reverse-proxy for /api/music/*. Auth is handled upstream by userMiddleware (this router mounts -// under the protected /api tree, so the media `?token=` path works). Everything else — path resolution, -// byte-range streaming, ffprobe duration, indexing — is done by the officer-music sidecar's audio -// server. We only forward the subpath + query + Range and stream the response back. +// Thin reverse-proxy for /api/music/*. The platform's ONLY job here is AUTH + FORWARDING. userMiddleware +// (upstream — this router mounts under the protected /api tree, so the media `?token=` path also works) +// authenticates; we forward the subpath + query + body + Range to the officer-music sidecar, which OWNS +// the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites, now-playing, +// playlists) backed by Postgres. We inject the authenticated user id as `X-Officer-User` so the sidecar +// can serve that per-user state — the sidecar is loopback-only, so it trusts the header. // -// This is a catch-all, so it lists no routes: the full /api/music/* HTTP contract (stream, manifest, -// meta, cover, reindex, reindex/stream + their SSE/response shapes) is documented at the top of the -// sidecar's fetch handler — src/servers/sidecar/music/index.ts. +// This is a catch-all with no routes of its own: the full /api/music/* HTTP contract (paths, methods, +// SSE/response shapes) is documented at the top of the sidecar's fetch handler — src/servers/sidecar/music/index.ts. export const musicRouter = createRouter(); -// ── Per-user music state (favorites + currently-playing) ───────────────────────────────────────── -// These are USER data, not library data, so the platform serves them from Postgres directly — they are -// NOT proxied to the sidecar (which is stateless about users). Registered before the catch-all proxy -// below so they win; still under /api/music, so the music-app account gate permits them. - -const FAVORITE_KINDS = new Set(['track', 'album', 'artist']); -const isKind = (k: unknown): k is FavoriteKind => typeof k === 'string' && FAVORITE_KINDS.has(k as FavoriteKind); - -// GET /favorites → { tracks, albums, artists } (arrays of keys, newest first). -musicRouter.get('/favorites', async (ctx) => { - return ctx.json(await getMusicFavorites(ctx.get('user').id)); -}); - -// POST /favorites { kind, key } → add (idempotent). -musicRouter.post('/favorites', async (ctx) => { - const { kind, key } = (await ctx.req.json().catch(() => ({}))) as { kind?: unknown; key?: unknown }; - if (!isKind(kind) || typeof key !== 'string' || !key) return ctx.json({ error: 'kind and key required' }, 400); - await addMusicFavorite(ctx.get('user').id, kind, key); - return ctx.json({ ok: true }); -}); - -// DELETE /favorites?kind=&key= → remove (query params so any HTTP client can send it). -musicRouter.delete('/favorites', async (ctx) => { - const kind = ctx.req.query('kind'); - const key = ctx.req.query('key'); - if (!isKind(kind) || !key) return ctx.json({ error: 'kind and key required' }, 400); - await removeMusicFavorite(ctx.get('user').id, kind, key); - return ctx.json({ ok: true }); -}); - -// GET /now-playing → the snapshot, or null. -musicRouter.get('/now-playing', async (ctx) => { - return ctx.json(await getNowPlaying(ctx.get('user').id)); -}); - -// PUT /now-playing { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } → upsert. -musicRouter.put('/now-playing', async (ctx) => { - const b = (await ctx.req.json().catch(() => ({}))) as Record; - if (typeof b.homePath !== 'string' || !b.homePath) return ctx.json({ error: 'homePath required' }, 400); - const str = (v: unknown) => (typeof v === 'string' ? v : undefined); - const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); - await setNowPlaying(ctx.get('user').id, { - homePath: b.homePath, - dir: str(b.dir), - title: str(b.title), - artist: str(b.artist), - album: str(b.album), - durationSec: num(b.durationSec), - positionSec: num(b.positionSec), - }); - return ctx.json({ ok: true }); -}); - -// DELETE /now-playing → clear. -musicRouter.delete('/now-playing', async (ctx) => { - await clearNowPlaying(ctx.get('user').id); - return ctx.json({ ok: true }); -}); - -// ── Named playlists ────────────────────────────────────────────────────────────────────────────── -// Playlist item `key`s are opaque track homePaths, same contract as favorites. Every route is scoped to -// the caller's user id (a playlist id that isn't theirs reads/writes as 404). - -const MAX_NAME = 200; -const cleanName = (v: unknown): string | null => { - if (typeof v !== 'string') return null; - const n = v.trim(); - return n && n.length <= MAX_NAME ? n : null; -}; -const asKeys = (v: unknown): string[] | null => - Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null; - -// GET /playlists → [{ id, name, count, createdAt, updatedAt }] (most-recently-updated first). -musicRouter.get('/playlists', async (ctx) => { - return ctx.json(await getPlaylists(ctx.get('user').id)); -}); - -// POST /playlists { name } → create; 409 if the name is already taken. -musicRouter.post('/playlists', async (ctx) => { - const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown }; - const n = cleanName(name); - if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400); - const row = await createPlaylist(ctx.get('user').id, n); - if (!row) return ctx.json({ error: 'a playlist with that name already exists' }, 409); - return ctx.json(row, 201); -}); - -// GET /playlists/:id → { id, name, items: string[], createdAt, updatedAt }; 404 if not the user's. -musicRouter.get('/playlists/:id', async (ctx) => { - const id = Number(ctx.req.param('id')); - if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); - const pl = await getPlaylist(ctx.get('user').id, id); - return pl ? ctx.json(pl) : ctx.json({ error: 'not found' }, 404); -}); - -// PATCH /playlists/:id { name } → rename; 404 if not the user's, 409 on a name collision. -musicRouter.patch('/playlists/:id', async (ctx) => { - const id = Number(ctx.req.param('id')); - if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); - const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown }; - const n = cleanName(name); - if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400); - const userId = ctx.get('user').id; - // Distinguish "not yours" (404) from "name collides" (409): confirm ownership first. - if (!(await getPlaylist(userId, id))) return ctx.json({ error: 'not found' }, 404); - const ok = await renamePlaylist(userId, id, n); - return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'a playlist with that name already exists' }, 409); -}); - -// DELETE /playlists/:id → delete (items cascade); 404 if not the user's. -musicRouter.delete('/playlists/:id', async (ctx) => { - const id = Number(ctx.req.param('id')); - if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); - const ok = await deletePlaylist(ctx.get('user').id, id); - return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'not found' }, 404); -}); - -// POST /playlists/:id/items { keys: string[] } → append to the end. Returns { count }. -musicRouter.post('/playlists/:id/items', async (ctx) => { - const id = Number(ctx.req.param('id')); - if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); - const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown }; - const ks = asKeys(keys); - if (!ks) return ctx.json({ error: 'keys[] required' }, 400); - const count = await addPlaylistItems(ctx.get('user').id, id, ks); - return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count }); -}); - -// PUT /playlists/:id/items { keys: string[] } → replace the whole ordered list (reorder / remove). -musicRouter.put('/playlists/:id/items', async (ctx) => { - const id = Number(ctx.req.param('id')); - if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); - const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown }; - const ks = asKeys(keys); - if (!ks) return ctx.json({ error: 'keys[] required' }, 400); - const count = await setPlaylistItems(ctx.get('user').id, id, ks); - return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count }); -}); - const PREFIX = '/api/music'; musicRouter.all('/*', async (ctx) => { @@ -189,12 +35,26 @@ musicRouter.all('/*', async (ctx) => { } } + const method = ctx.req.method; + const headers: Record = {}; const range = ctx.req.header('range'); + if (range) headers['Range'] = range; + const contentType = ctx.req.header('content-type'); + if (contentType) headers['Content-Type'] = contentType; + // Forward the authenticated user id so the sidecar can serve its per-user state routes (favorites / + // now-playing / playlists). The sidecar binds loopback only, so this header is trusted. + headers['X-Officer-User'] = String(ctx.get('user').id); + + // Forward the request body for mutating methods (favorites/now-playing/playlist writes). Streaming + + // reindex are GET/bodyless POST, so this is a no-op there. + const hasBody = method !== 'GET' && method !== 'HEAD'; + let upstream: Response; try { upstream = await fetch(target, { - method: ctx.req.method, - headers: range ? { Range: range } : {}, + method, + headers, + body: hasBody ? await ctx.req.arrayBuffer() : undefined, }); } catch (err) { console.error('[music] proxy fetch failed', { target, error: String(err) }); diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index 8454c358..5118110b 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -21,9 +21,43 @@ import { onIndexProgress, buildReport, } from './indexer'; +import { + getMusicFavorites, + addMusicFavorite, + removeMusicFavorite, + getNowPlaying, + setNowPlaying, + clearNowPlaying, + getPlaylists, + getPlaylist, + createPlaylist, + renamePlaylist, + deletePlaylist, + addPlaylistItems, + setPlaylistItems, + type FavoriteKind, +} from 'officerdb'; const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +// ── Per-user state validation ── +// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're +// loopback-only so we trust it). Favorite/playlist `key`s are opaque paths we never interpret. +const userIdOf = (req: Request): number | null => { + const n = Number(req.headers.get('x-officer-user')); + return Number.isInteger(n) && n > 0 ? n : null; +}; +const FAVORITE_KINDS = new Set(['track', 'album', 'artist']); +const isKind = (k: unknown): k is FavoriteKind => typeof k === 'string' && FAVORITE_KINDS.has(k as FavoriteKind); +const PLAYLIST_NAME_MAX = 200; +const cleanName = (v: unknown): string | null => { + if (typeof v !== 'string') return null; + const n = v.trim(); + return n && n.length <= PLAYLIST_NAME_MAX ? n : null; +}; +const asKeys = (v: unknown): string[] | null => + Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null; + // The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton // process that registers with the API server. It OWNS an audio-streaming HTTP server (all path // resolution + streaming + ffprobe duration happens here); the platform API is just a thin proxy that @@ -59,6 +93,22 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); // `event: done` (IndexReport) and the stream closes. // GET /health "ok". // +// ── Per-user state (USER data in Postgres, not library data). User id in X-Officer-User, injected by +// the platform proxy after auth; `key`s are opaque paths (track homePath / album|artist rel). ── +// GET /favorites { tracks[], albums[], artists[] } (keys, newest first). +// POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist. +// DELETE /favorites?kind=&key= remove. +// GET /now-playing last snapshot, or null. +// PUT /now-playing { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert. +// DELETE /now-playing clear. +// GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first). +// POST /playlists { name } create → 201 row; 409 if name taken. +// GET /playlists/:id { id, name, items:[keys], … }; 404 if not the user's. +// PATCH /playlists/:id { name } rename; 404 / 409. +// DELETE /playlists/:id delete (items cascade); 404. +// POST /playlists/:id/items { keys[] } append → { count }; 404. +// PUT /playlists/:id/items { keys[] } replace whole list (reorder/remove) → { count }; 404. +// // `` = album folder path relative to the Music root (e.g. "Albums/AC-DC/[1980] Back in Black"). // `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading. // ───────────────────────────────────────────────────────────────────────────────────────────────── @@ -101,7 +151,10 @@ const server = Bun.serve({ server.timeout(req, 1800); } const json = (data: unknown, init?: ResponseInit) => - new Response(JSON.stringify(data), { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } }); + new Response(JSON.stringify(data), { + ...init, + headers: { 'Content-Type': 'application/json', ...init?.headers }, + }); if (url.pathname === '/health') return new Response('ok'); @@ -191,7 +244,9 @@ const server = Bun.serve({ if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 }); const v = await albumVersion(rel); if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 }); - return new Response(Bun.file(posterPath), { headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) } }); + return new Response(Bun.file(posterPath), { + headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) }, + }); } // Track lyrics (external .lrc/.txt sidecar or embedded, cached during indexing). Try synced first. @@ -247,6 +302,107 @@ const server = Bun.serve({ }); } + // ── Per-user state (favorites / now-playing / playlists) ────────────────────────────────────── + // USER data backed by Postgres (NOT library data). The user id comes from X-Officer-User (see above). + const P = url.pathname; + if (P === '/favorites' || P === '/now-playing' || P === '/playlists' || P.startsWith('/playlists/')) { + const uid = userIdOf(req); + if (uid === null) return json({ error: 'unauthenticated' }, { status: 401 }); + const m = req.method; + + if (P === '/favorites') { + if (m === 'GET') return json(await getMusicFavorites(uid)); + if (m === 'POST') { + const { kind, key } = (await req.json().catch(() => ({}))) as { kind?: unknown; key?: unknown }; + if (!isKind(kind) || typeof key !== 'string' || !key) + return json({ error: 'kind and key required' }, { status: 400 }); + await addMusicFavorite(uid, kind, key); + return json({ ok: true }); + } + if (m === 'DELETE') { + const kind = url.searchParams.get('kind'); + const key = url.searchParams.get('key'); + if (!isKind(kind) || !key) return json({ error: 'kind and key required' }, { status: 400 }); + await removeMusicFavorite(uid, kind, key); + return json({ ok: true }); + } + return new Response('Method not allowed', { status: 405 }); + } + + if (P === '/now-playing') { + if (m === 'GET') return json(await getNowPlaying(uid)); + if (m === 'PUT') { + const b = (await req.json().catch(() => ({}))) as Record; + if (typeof b.homePath !== 'string' || !b.homePath) + return json({ error: 'homePath required' }, { status: 400 }); + const str = (v: unknown) => (typeof v === 'string' ? v : undefined); + const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); + await setNowPlaying(uid, { + homePath: b.homePath, + dir: str(b.dir), + title: str(b.title), + artist: str(b.artist), + album: str(b.album), + durationSec: num(b.durationSec), + positionSec: num(b.positionSec), + }); + return json({ ok: true }); + } + if (m === 'DELETE') { + await clearNowPlaying(uid); + return json({ ok: true }); + } + return new Response('Method not allowed', { status: 405 }); + } + + if (P === '/playlists') { + if (m === 'GET') return json(await getPlaylists(uid)); + if (m === 'POST') { + const { name } = (await req.json().catch(() => ({}))) as { name?: unknown }; + const n = cleanName(name); + if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 }); + const row = await createPlaylist(uid, n); + return row + ? json(row, { status: 201 }) + : json({ error: 'a playlist with that name already exists' }, { status: 409 }); + } + return new Response('Method not allowed', { status: 405 }); + } + + // /playlists/:id and /playlists/:id/items + const match = P.match(/^\/playlists\/(\d+)(\/items)?$/); + if (!match) return json({ error: 'not found' }, { status: 404 }); + const id = Number(match[1]); + + if (match[2]) { + // /playlists/:id/items — POST append, PUT replace (reorder/remove) + if (m !== 'POST' && m !== 'PUT') return new Response('Method not allowed', { status: 405 }); + const { keys } = (await req.json().catch(() => ({}))) as { keys?: unknown }; + const ks = asKeys(keys); + if (!ks) return json({ error: 'keys[] required' }, { status: 400 }); + const count = await (m === 'POST' ? addPlaylistItems(uid, id, ks) : setPlaylistItems(uid, id, ks)); + return count === null ? json({ error: 'not found' }, { status: 404 }) : json({ count }); + } + + if (m === 'GET') { + const pl = await getPlaylist(uid, id); + return pl ? json(pl) : json({ error: 'not found' }, { status: 404 }); + } + if (m === 'PATCH') { + const { name } = (await req.json().catch(() => ({}))) as { name?: unknown }; + const n = cleanName(name); + if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 }); + if (!(await getPlaylist(uid, id))) return json({ error: 'not found' }, { status: 404 }); + const ok = await renamePlaylist(uid, id, n); + return ok ? json({ ok: true }) : json({ error: 'a playlist with that name already exists' }, { status: 409 }); + } + if (m === 'DELETE') { + const ok = await deletePlaylist(uid, id); + return ok ? json({ ok: true }) : json({ error: 'not found' }, { status: 404 }); + } + return new Response('Method not allowed', { status: 405 }); + } + return new Response('Not found', { status: 404 }); }, });