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:
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user