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:
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE "music_favorites" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "uq_music_favorites_user_kind_key" UNIQUE("user_id","kind","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music_now_playing" (
|
||||
"user_id" integer PRIMARY KEY NOT NULL,
|
||||
"home_path" text NOT NULL,
|
||||
"dir" text DEFAULT '' NOT NULL,
|
||||
"title" text DEFAULT '' NOT NULL,
|
||||
"artist" text DEFAULT '' NOT NULL,
|
||||
"album" text DEFAULT '' NOT NULL,
|
||||
"duration_sec" real DEFAULT 0 NOT NULL,
|
||||
"position_sec" real DEFAULT 0 NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music_playlist_items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"playlist_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "music_playlists" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "uq_music_playlists_user_name" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint
|
||||
CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1785110579165,
|
||||
"tag": "0001_omniscient_photon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785198942654,
|
||||
"tag": "0002_gray_killmonger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -64,7 +64,6 @@ export {
|
||||
upsertDefaults,
|
||||
} from './queries/dashboards';
|
||||
|
||||
|
||||
export {
|
||||
createPipelineJob,
|
||||
getPipelineJob,
|
||||
@@ -86,8 +85,22 @@ export {
|
||||
getNowPlaying,
|
||||
setNowPlaying,
|
||||
clearNowPlaying,
|
||||
getPlaylists,
|
||||
getPlaylist,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
deletePlaylist,
|
||||
addPlaylistItems,
|
||||
setPlaylistItems,
|
||||
} from './queries/music';
|
||||
export type {
|
||||
FavoriteKind,
|
||||
GroupedFavorites,
|
||||
NowPlaying,
|
||||
NowPlayingInput,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
} from './queries/music';
|
||||
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput } from './queries/music';
|
||||
|
||||
export { db } from './db';
|
||||
export * as schema from './schema';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { musicFavorites, musicNowPlaying } from '../schema';
|
||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from '../schema';
|
||||
|
||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
||||
@@ -107,3 +107,126 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis
|
||||
export async function clearNowPlaying(userId: number): Promise<void> {
|
||||
await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId));
|
||||
}
|
||||
|
||||
// ── Named playlists ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlaylistSummary = { id: number; name: string; count: number; createdAt: Date; updatedAt: Date };
|
||||
export type Playlist = { id: number; name: string; items: string[]; createdAt: Date; updatedAt: Date };
|
||||
|
||||
/** All of a user's playlists with item counts, most-recently-updated first. */
|
||||
export async function getPlaylists(userId: number): Promise<PlaylistSummary[]> {
|
||||
return db
|
||||
.select({
|
||||
id: musicPlaylists.id,
|
||||
name: musicPlaylists.name,
|
||||
count: sql<number>`count(${musicPlaylistItems.id})::int`,
|
||||
createdAt: musicPlaylists.createdAt,
|
||||
updatedAt: musicPlaylists.updatedAt,
|
||||
})
|
||||
.from(musicPlaylists)
|
||||
.leftJoin(musicPlaylistItems, eq(musicPlaylistItems.playlistId, musicPlaylists.id))
|
||||
.where(eq(musicPlaylists.userId, userId))
|
||||
.groupBy(musicPlaylists.id)
|
||||
.orderBy(desc(musicPlaylists.updatedAt));
|
||||
}
|
||||
|
||||
/** One playlist with its ordered item keys, or null if it doesn't exist or isn't this user's. */
|
||||
export async function getPlaylist(userId: number, id: number): Promise<Playlist | null> {
|
||||
const [header] = await db
|
||||
.select({
|
||||
id: musicPlaylists.id,
|
||||
name: musicPlaylists.name,
|
||||
createdAt: musicPlaylists.createdAt,
|
||||
updatedAt: musicPlaylists.updatedAt,
|
||||
})
|
||||
.from(musicPlaylists)
|
||||
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)));
|
||||
if (!header) return null;
|
||||
const rows = await db
|
||||
.select({ key: musicPlaylistItems.key })
|
||||
.from(musicPlaylistItems)
|
||||
.where(eq(musicPlaylistItems.playlistId, id))
|
||||
.orderBy(asc(musicPlaylistItems.position));
|
||||
return { ...header, items: rows.map((r) => r.key) };
|
||||
}
|
||||
|
||||
/** Create a named playlist. Returns the new row, or null if the name is already taken for this user. */
|
||||
export async function createPlaylist(userId: number, name: string): Promise<PlaylistSummary | null> {
|
||||
const [row] = await db
|
||||
.insert(musicPlaylists)
|
||||
.values({ userId, name })
|
||||
.onConflictDoNothing({ target: [musicPlaylists.userId, musicPlaylists.name] })
|
||||
.returning({
|
||||
id: musicPlaylists.id,
|
||||
name: musicPlaylists.name,
|
||||
createdAt: musicPlaylists.createdAt,
|
||||
updatedAt: musicPlaylists.updatedAt,
|
||||
});
|
||||
return row ? { ...row, count: 0 } : null;
|
||||
}
|
||||
|
||||
/** Rename a playlist. Returns false if it isn't this user's, or the new name collides. */
|
||||
export async function renamePlaylist(userId: number, id: number, name: string): Promise<boolean> {
|
||||
const existing = await db
|
||||
.select({ id: musicPlaylists.id })
|
||||
.from(musicPlaylists)
|
||||
.where(and(eq(musicPlaylists.userId, userId), eq(musicPlaylists.name, name)));
|
||||
if (existing.some((r) => r.id !== id)) return false; // name taken by a different playlist
|
||||
const updated = await db
|
||||
.update(musicPlaylists)
|
||||
.set({ name, updatedAt: new Date() })
|
||||
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)))
|
||||
.returning({ id: musicPlaylists.id });
|
||||
return updated.length > 0;
|
||||
}
|
||||
|
||||
/** Delete a playlist (items cascade). Returns false if it wasn't this user's. */
|
||||
export async function deletePlaylist(userId: number, id: number): Promise<boolean> {
|
||||
const deleted = await db
|
||||
.delete(musicPlaylists)
|
||||
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)))
|
||||
.returning({ id: musicPlaylists.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
/** Verify a playlist belongs to the user; returns its id or null. */
|
||||
async function ownedPlaylist(userId: number, id: number): Promise<number | null> {
|
||||
const [row] = await db
|
||||
.select({ id: musicPlaylists.id })
|
||||
.from(musicPlaylists)
|
||||
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)));
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/** Append keys to the end of a playlist, preserving order. Returns the new count, or null if not the user's. */
|
||||
export async function addPlaylistItems(userId: number, id: number, keys: string[]): Promise<number | null> {
|
||||
if ((await ownedPlaylist(userId, id)) === null) return null;
|
||||
return db.transaction(async (tx) => {
|
||||
const [{ next } = { next: 0 }] = await tx
|
||||
.select({ next: sql<number>`coalesce(max(${musicPlaylistItems.position}) + 1, 0)::int` })
|
||||
.from(musicPlaylistItems)
|
||||
.where(eq(musicPlaylistItems.playlistId, id));
|
||||
if (keys.length) {
|
||||
await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: next + i })));
|
||||
}
|
||||
await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id));
|
||||
const [{ total } = { total: 0 }] = await tx
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(musicPlaylistItems)
|
||||
.where(eq(musicPlaylistItems.playlistId, id));
|
||||
return total;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace a playlist's entire ordered item list (covers reorder + remove). Returns the new count, or null. */
|
||||
export async function setPlaylistItems(userId: number, id: number, keys: string[]): Promise<number | null> {
|
||||
if ((await ownedPlaylist(userId, id)) === null) return null;
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.delete(musicPlaylistItems).where(eq(musicPlaylistItems.playlistId, id));
|
||||
if (keys.length) {
|
||||
await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: i })));
|
||||
}
|
||||
await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id));
|
||||
return keys.length;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ export const musicFavorites = pgTable(
|
||||
'music_favorites',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
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(),
|
||||
@@ -20,12 +22,46 @@ export const musicFavorites = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Per-user named playlists (header) + their ordered items (below). Like favorites, an item `key` is the
|
||||
// opaque track homePath "Music/<rel>/<file>" the app supplies — the server never interprets it. A playlist
|
||||
// name is unique per user; items are position-ordered and MAY repeat (a track can appear twice).
|
||||
export const musicPlaylists = pgTable(
|
||||
'music_playlists',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique('uq_music_playlists_user_name').on(t.userId, t.name)],
|
||||
);
|
||||
|
||||
// Ordered track entries of a playlist. `position` is 0-based; deletes cascade from the playlist.
|
||||
export const musicPlaylistItems = pgTable(
|
||||
'music_playlist_items',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
playlistId: integer('playlist_id')
|
||||
.notNull()
|
||||
.references(() => musicPlaylists.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(), // track homePath "Music/<rel>/<file>"
|
||||
position: integer('position').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('idx_music_playlist_items_playlist').on(t.playlistId, t.position)],
|
||||
);
|
||||
|
||||
// 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' }),
|
||||
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(''),
|
||||
|
||||
@@ -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