music: per-user Favorites + Currently-playing (platform/Postgres)
User-level state for the music app, served by the platform from Postgres (not the sidecar, which is stateless about users) under the same /api/music prefix so the music-app account gate permits it: - music_favorites (userId, kind, key) — kind ∈ track|album|artist, opaque path key the server never interprets; unique per (user,kind,key), newest-first. GET /favorites (grouped), POST /favorites (idempotent), DELETE /favorites. - music_now_playing (one row/user) — current track + position snapshot for resume-across-launch/device, with a light title/artist/album cache so the resume card renders before the library index syncs. GET/PUT/DELETE /now-playing (upsert). Routes registered before the catch-all proxy. Tables created via direct DDL; query layer smoke-tested against the live DB. MUSIC_API.md documents the contract for the app. App-side wiring (heart toggles, Favorites view, player persistence) follows next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getMusicServerUrl } from './sidecar-server';
|
||||
import {
|
||||
getMusicFavorites,
|
||||
addMusicFavorite,
|
||||
removeMusicFavorite,
|
||||
getNowPlaying,
|
||||
setNowPlaying,
|
||||
clearNowPlaying,
|
||||
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,
|
||||
@@ -12,6 +21,65 @@ import { getMusicServerUrl } from './sidecar-server';
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
const PREFIX = '/api/music';
|
||||
|
||||
musicRouter.all('/*', async (ctx) => {
|
||||
|
||||
Reference in New Issue
Block a user