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
+10
View File
@@ -79,5 +79,15 @@ export {
export { appendChatEvent, getChatEventsSince, pruneChatEventsOlderThan } from './queries/chat-events';
export {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
} from './queries/music';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput } from './queries/music';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,109 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { musicFavorites, musicNowPlaying } from '../schema';
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
/** All of a user's favorites, grouped by kind, newest first within each group. */
export async function getMusicFavorites(userId: number): Promise<GroupedFavorites> {
const rows = await db
.select({ kind: musicFavorites.kind, key: musicFavorites.key })
.from(musicFavorites)
.where(eq(musicFavorites.userId, userId))
.orderBy(desc(musicFavorites.createdAt));
const grouped: GroupedFavorites = { tracks: [], albums: [], artists: [] };
for (const r of rows) {
if (r.kind === 'track') grouped.tracks.push(r.key);
else if (r.kind === 'album') grouped.albums.push(r.key);
else if (r.kind === 'artist') grouped.artists.push(r.key);
}
return grouped;
}
/** Add a favorite (idempotent — a repeat add is a no-op via the unique constraint). */
export async function addMusicFavorite(userId: number, kind: FavoriteKind, key: string): Promise<void> {
await db.insert(musicFavorites).values({ userId, kind, key }).onConflictDoNothing();
}
/** Remove a favorite (no-op if it wasn't set). */
export async function removeMusicFavorite(userId: number, kind: FavoriteKind, key: string): Promise<void> {
await db
.delete(musicFavorites)
.where(and(eq(musicFavorites.userId, userId), eq(musicFavorites.kind, kind), eq(musicFavorites.key, key)));
}
export type NowPlaying = {
homePath: string;
dir: string;
title: string;
artist: string;
album: string;
durationSec: number;
positionSec: number;
updatedAt: Date;
};
export type NowPlayingInput = {
homePath: string;
dir?: string;
title?: string;
artist?: string;
album?: string;
durationSec?: number;
positionSec?: number;
};
/** The user's last "currently playing" snapshot, or null if none. */
export async function getNowPlaying(userId: number): Promise<NowPlaying | null> {
const [row] = await db
.select({
homePath: musicNowPlaying.homePath,
dir: musicNowPlaying.dir,
title: musicNowPlaying.title,
artist: musicNowPlaying.artist,
album: musicNowPlaying.album,
durationSec: musicNowPlaying.durationSec,
positionSec: musicNowPlaying.positionSec,
updatedAt: musicNowPlaying.updatedAt,
})
.from(musicNowPlaying)
.where(eq(musicNowPlaying.userId, userId));
return row ?? null;
}
/** Upsert the user's "currently playing" snapshot (one row per user). */
export async function setNowPlaying(userId: number, np: NowPlayingInput): Promise<void> {
const values = {
userId,
homePath: np.homePath,
dir: np.dir ?? '',
title: np.title ?? '',
artist: np.artist ?? '',
album: np.album ?? '',
durationSec: np.durationSec ?? 0,
positionSec: np.positionSec ?? 0,
updatedAt: new Date(),
};
await db
.insert(musicNowPlaying)
.values(values)
.onConflictDoUpdate({
target: musicNowPlaying.userId,
set: {
homePath: values.homePath,
dir: values.dir,
title: values.title,
artist: values.artist,
album: values.album,
durationSec: values.durationSec,
positionSec: values.positionSec,
updatedAt: values.updatedAt,
},
});
}
/** Clear the user's "currently playing" (on close/stop). */
export async function clearNowPlaying(userId: number): Promise<void> {
await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId));
}
@@ -6,3 +6,4 @@ export * from './server';
export * from './email';
export * from './pipeline-jobs';
export * from './chat-events';
export * from './music';
@@ -0,0 +1,37 @@
import { pgTable, serial, integer, text, real, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
// album → music-rel "Albums/AC-DC/[1980] Back in Black"
// artist → music-rel "Albums/AC-DC"
export const musicFavorites = pgTable(
'music_favorites',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(), // 'track' | 'album' | 'artist'
key: text('key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
unique('uq_music_favorites_user_kind_key').on(t.userId, t.kind, t.key),
index('idx_music_favorites_user_kind').on(t.userId, t.kind),
],
);
// Per-user "currently playing" for resume-across-launch/device: the current track + playback position,
// plus a light metadata snapshot so the resume card renders before the library index has synced on a
// fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album queue). One
// row per user (upserted; the app writes it throttled while playing and on pause/track-change/close).
export const musicNowPlaying = pgTable('music_now_playing', {
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
homePath: text('home_path').notNull(),
dir: text('dir').notNull().default(''),
title: text('title').notNull().default(''),
artist: text('artist').notNull().default(''),
album: text('album').notNull().default(''),
durationSec: real('duration_sec').notNull().default(0),
positionSec: real('position_sec').notNull().default(0),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
+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) => {