import { eq, and, desc, asc, sql } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } 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; }; /** This (user, device)'s last "currently playing" snapshot, or null if none. */ export async function getNowPlaying(userId: number, device: string): 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(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device))); return row ?? null; } /** Upsert this (user, device)'s "currently playing" snapshot (one row per user+device). */ export async function setNowPlaying(userId: number, device: string, np: NowPlayingInput): Promise { const values = { userId, device, 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, musicNowPlaying.device], 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 this (user, device)'s "currently playing" (on close/stop). */ export async function clearNowPlaying(userId: number, device: string): Promise { await db.delete(musicNowPlaying).where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device))); } // ── 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 { return db .select({ id: musicPlaylists.id, name: musicPlaylists.name, count: sql`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 { 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 { 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 { 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 { 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 { 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 { if ((await ownedPlaylist(userId, id)) === null) return null; return db.transaction(async (tx) => { const [{ next } = { next: 0 }] = await tx .select({ next: sql`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`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 { 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; }); }