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:
@@ -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/<rel>/<file>` (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=<kind>&key=<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
|
## Notes
|
||||||
|
|
||||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||||
|
|||||||
@@ -79,5 +79,15 @@ export {
|
|||||||
|
|
||||||
export { appendChatEvent, getChatEventsSince, pruneChatEventsOlderThan } from './queries/chat-events';
|
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 { db } from './db';
|
||||||
export * as schema from './schema';
|
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 './email';
|
||||||
export * from './pipeline-jobs';
|
export * from './pipeline-jobs';
|
||||||
export * from './chat-events';
|
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(),
|
||||||
|
});
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getMusicServerUrl } from './sidecar-server';
|
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
|
// 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,
|
// 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();
|
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';
|
const PREFIX = '/api/music';
|
||||||
|
|
||||||
musicRouter.all('/*', async (ctx) => {
|
musicRouter.all('/*', async (ctx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user