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:
2026-07-27 11:28:28 +00:00
co-authored by Claude Opus 4.8
parent 54fa21dd46
commit 21cb3489a7
6 changed files with 268 additions and 0 deletions
+68
View File
@@ -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) => {