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,
|
"when": 1785110579165,
|
||||||
"tag": "0001_omniscient_photon",
|
"tag": "0001_omniscient_photon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785198942654,
|
||||||
|
"tag": "0002_gray_killmonger",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,6 @@ export {
|
|||||||
upsertDefaults,
|
upsertDefaults,
|
||||||
} from './queries/dashboards';
|
} from './queries/dashboards';
|
||||||
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createPipelineJob,
|
createPipelineJob,
|
||||||
getPipelineJob,
|
getPipelineJob,
|
||||||
@@ -86,8 +85,22 @@ export {
|
|||||||
getNowPlaying,
|
getNowPlaying,
|
||||||
setNowPlaying,
|
setNowPlaying,
|
||||||
clearNowPlaying,
|
clearNowPlaying,
|
||||||
|
getPlaylists,
|
||||||
|
getPlaylist,
|
||||||
|
createPlaylist,
|
||||||
|
renamePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItems,
|
||||||
|
setPlaylistItems,
|
||||||
|
} from './queries/music';
|
||||||
|
export type {
|
||||||
|
FavoriteKind,
|
||||||
|
GroupedFavorites,
|
||||||
|
NowPlaying,
|
||||||
|
NowPlayingInput,
|
||||||
|
PlaylistSummary,
|
||||||
|
Playlist,
|
||||||
} from './queries/music';
|
} 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';
|
||||||
|
|||||||
@@ -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 { db } from '../db';
|
||||||
import { musicFavorites, musicNowPlaying } from '../schema';
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from '../schema';
|
||||||
|
|
||||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
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> {
|
export async function clearNowPlaying(userId: number): Promise<void> {
|
||||||
await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId));
|
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',
|
'music_favorites',
|
||||||
{
|
{
|
||||||
id: serial('id').primaryKey(),
|
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'
|
kind: text('kind').notNull(), // 'track' | 'album' | 'artist'
|
||||||
key: text('key').notNull(),
|
key: text('key').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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,
|
// 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
|
// 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
|
// 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).
|
// row per user (upserted; the app writes it throttled while playing and on pause/track-change/close).
|
||||||
export const musicNowPlaying = pgTable('music_now_playing', {
|
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(),
|
homePath: text('home_path').notNull(),
|
||||||
dir: text('dir').notNull().default(''),
|
dir: text('dir').notNull().default(''),
|
||||||
title: text('title').notNull().default(''),
|
title: text('title').notNull().default(''),
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import {
|
|||||||
getNowPlaying,
|
getNowPlaying,
|
||||||
setNowPlaying,
|
setNowPlaying,
|
||||||
clearNowPlaying,
|
clearNowPlaying,
|
||||||
|
getPlaylists,
|
||||||
|
getPlaylist,
|
||||||
|
createPlaylist,
|
||||||
|
renamePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItems,
|
||||||
|
setPlaylistItems,
|
||||||
type FavoriteKind,
|
type FavoriteKind,
|
||||||
} from 'officerdb';
|
} from 'officerdb';
|
||||||
|
|
||||||
@@ -80,6 +87,86 @@ musicRouter.delete('/now-playing', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
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';
|
const PREFIX = '/api/music';
|
||||||
|
|
||||||
musicRouter.all('/*', async (ctx) => {
|
musicRouter.all('/*', async (ctx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user