move all music per-user state into the sidecar; platform = auth + proxy only
Favorites / now-playing / playlists were being served by the platform router straight from Postgres, which violated the intended split (officer = auth + proxy; officer-music = the whole /api/music/* contract). Move them into the sidecar so it owns ALL music endpoints — library AND user state. - sidecar (index.ts): serves /favorites, /now-playing, /playlists[/:id[/items]] backed by Postgres (the same officerdb queries other sidecars already use). The authenticated user id arrives in X-Officer-User; the sidecar is loopback- only, so it trusts the header (401 if absent). HTTP-contract comment updated. - platform (router.ts): reduced to a pure auth+proxy catch-all — it now injects X-Officer-User from the authenticated ctx user and forwards the request body (favorites/now-playing/playlist writes carry JSON) in addition to Range/query. No schema change — the tables are unchanged, only WHERE they're served moves. Verified live: booted the real sidecar against the live DB and exercised the endpoints with the X-Officer-User header — 401-without-header, favorites round- trip, and full playlist CRUD with ownership scoping all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+24
-164
@@ -1,172 +1,18 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getMusicServerUrl } from './sidecar-server';
|
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
|
// Thin reverse-proxy for /api/music/*. The platform's ONLY job here is AUTH + FORWARDING. userMiddleware
|
||||||
// under the protected /api tree, so the media `?token=` path works). Everything else — path resolution,
|
// (upstream — this router mounts under the protected /api tree, so the media `?token=` path also works)
|
||||||
// byte-range streaming, ffprobe duration, indexing — is done by the officer-music sidecar's audio
|
// authenticates; we forward the subpath + query + body + Range to the officer-music sidecar, which OWNS
|
||||||
// server. We only forward the subpath + query + Range and stream the response back.
|
// 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,
|
// This is a catch-all with no routes of its own: the full /api/music/* HTTP contract (paths, methods,
|
||||||
// meta, cover, reindex, reindex/stream + their SSE/response shapes) is documented at the top of the
|
// SSE/response shapes) is documented at the top of the sidecar's fetch handler — src/servers/sidecar/music/index.ts.
|
||||||
// sidecar's fetch handler — src/servers/sidecar/music/index.ts.
|
|
||||||
|
|
||||||
export const musicRouter = createRouter();
|
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<FavoriteKind>(['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<string, unknown>;
|
|
||||||
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';
|
const PREFIX = '/api/music';
|
||||||
|
|
||||||
musicRouter.all('/*', async (ctx) => {
|
musicRouter.all('/*', async (ctx) => {
|
||||||
@@ -189,12 +35,26 @@ musicRouter.all('/*', async (ctx) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const method = ctx.req.method;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
const range = ctx.req.header('range');
|
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;
|
let upstream: Response;
|
||||||
try {
|
try {
|
||||||
upstream = await fetch(target, {
|
upstream = await fetch(target, {
|
||||||
method: ctx.req.method,
|
method,
|
||||||
headers: range ? { Range: range } : {},
|
headers,
|
||||||
|
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[music] proxy fetch failed', { target, error: String(err) });
|
console.error('[music] proxy fetch failed', { target, error: String(err) });
|
||||||
|
|||||||
@@ -21,9 +21,43 @@ import {
|
|||||||
onIndexProgress,
|
onIndexProgress,
|
||||||
buildReport,
|
buildReport,
|
||||||
} from './indexer';
|
} 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');
|
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<FavoriteKind>(['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
|
// 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
|
// 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
|
// 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.
|
// `event: done` (IndexReport) and the stream closes.
|
||||||
// GET /health "ok".
|
// 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.
|
||||||
|
//
|
||||||
// `<rel>` = album folder path relative to the Music root (e.g. "Albums/AC-DC/[1980] Back in Black").
|
// `<rel>` = 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.
|
// `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);
|
server.timeout(req, 1800);
|
||||||
}
|
}
|
||||||
const json = (data: unknown, init?: ResponseInit) =>
|
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');
|
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 });
|
if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
|
||||||
const v = await albumVersion(rel);
|
const v = await albumVersion(rel);
|
||||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
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.
|
// 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<string, unknown>;
|
||||||
|
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 });
|
return new Response('Not found', { status: 404 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user