add per-user named music playlists (server-side infra)
Mirrors the existing per-user music state (favorites / now-playing): Postgres tables + query layer + REST endpoints on the music router, all scoped to the caller's user id and served directly by the platform (not proxied to the user-stateless sidecar). Item `key`s are opaque track homePaths, same contract as favorites — the server never interprets them. - schema: music_playlists (name unique per user) + music_playlist_items (0-based position, dupes allowed, cascade delete). - queries: get/create/rename/delete playlists; add (append) / set (replace, covers reorder+remove) items; every mutation ownership-checked; item ops in a transaction that also bumps the playlist updatedAt. - router (/api/music, before the catch-all proxy): GET/POST /playlists, GET/PATCH/DELETE /playlists/:id, POST/PUT /playlists/:id/items. 409 on name collision, 404 on a playlist that isn't the caller's. - migration 0002 (also backfills music_favorites/now_playing into the snapshot, which were originally applied via a direct db:push). Applied to the DB. Verified end-to-end against the live DB: create, dupes, ordering, append, replace/reorder, ownership scoping, rename, counts, delete — all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,13 @@ import {
|
||||
getNowPlaying,
|
||||
setNowPlaying,
|
||||
clearNowPlaying,
|
||||
getPlaylists,
|
||||
getPlaylist,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
deletePlaylist,
|
||||
addPlaylistItems,
|
||||
setPlaylistItems,
|
||||
type FavoriteKind,
|
||||
} from 'officerdb';
|
||||
|
||||
@@ -80,6 +87,86 @@ musicRouter.delete('/now-playing', async (ctx) => {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user