diff --git a/MUSIC_API.md b/MUSIC_API.md index e9f70c32..d2c2b120 100644 --- a/MUSIC_API.md +++ b/MUSIC_API.md @@ -212,6 +212,49 @@ Result: a resync after adding one album = 1 manifest fetch + that one album's `m --- +## Per-user state — Favorites & Currently-playing + +Unlike everything above (library data served by the sidecar), these are **per-user** and served by the +platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app +supplies; the server never interprets them: + +| kind | key | +|---|---| +| `track` | home-path — `Music//` (also the `/stream` path & queue id) | +| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` | +| `artist` | music-rel — `Albums/AC-DC` | + +### Favorites + +- **`GET /api/music/favorites`** → grouped keys, newest first: + ```json + { "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] } + ``` +- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent + (a repeat add is a no-op). +- **`DELETE /api/music/favorites?kind=&key=`** → `{ ok: true }` (no-op if not set). Key passed as a + query param (URL-encode it). +- `400 { error: "kind and key required" }` on a bad/missing kind or empty key. + +### Currently-playing (resume) + +One snapshot per user — persist while playing (throttled) and on pause / track-change / close; read it on +launch to offer "resume". + +- **`GET /api/music/now-playing`** → the snapshot or `null`: + ```json + { "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black", + "title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black", + "durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" } + ``` + `dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track). +- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }` + → `{ ok: true }` (upsert). Omitted fields default to `""`/`0`. +- **`DELETE /api/music/now-playing`** → `{ ok: true }` (clear, e.g. on stop). +- `400 { error: "homePath required" }` if `homePath` is missing/empty. + +--- + ## Notes - **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed. diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index bf1b656a..8d1d88d4 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/queries/music.ts b/src/databases/officer_db/src/queries/music.ts new file mode 100644 index 00000000..619b69fc --- /dev/null +++ b/src/databases/officer_db/src/queries/music.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId)); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 2600f010..640b86ca 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -6,3 +6,4 @@ export * from './server'; export * from './email'; export * from './pipeline-jobs'; export * from './chat-events'; +export * from './music'; diff --git a/src/databases/officer_db/src/schema/music.ts b/src/databases/officer_db/src/schema/music.ts new file mode 100644 index 00000000..4734158e --- /dev/null +++ b/src/databases/officer_db/src/schema/music.ts @@ -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//" (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(), +}); diff --git a/src/servers/api/music/router.ts b/src/servers/api/music/router.ts index c9966555..c8ddf916 100644 --- a/src/servers/api/music/router.ts +++ b/src/servers/api/music/router.ts @@ -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(['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; + 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) => {