import type { SQLOutputValue } from "node:sqlite"; import { APIErrorCode, APIException, type CreatePlaylistRequest, type Dump, type Playlist, type PlaylistMembership, type PlaylistWithDumps, type UpdatePlaylistRequest, } from "../model/interfaces.ts"; import { db, dumpRowToApi, isDumpRow, isPlaylistRow, playlistRowToApi, } from "../model/db.ts"; import { broadcastPlaylistCreated, broadcastPlaylistDeleted, broadcastPlaylistDumpsUpdated, broadcastPlaylistUpdated, } from "./ws-service.ts"; import { notifyMentions, notifyPlaylistFollowersNewDump, } from "./notification-service.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts"; const DUMP_SELECT_COLS = "id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; const PLAYLIST_SELECT = `p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count FROM playlists p LEFT JOIN users u ON u.id = p.user_id`; function getPlaylistById(idOrSlug: string): Playlist { const row = UUID_RE.test(idOrSlug) ? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) : db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug); if (!row || !isPlaylistRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); } return playlistRowToApi(row); } export function createPlaylist( req: CreatePlaylistRequest, userId: string, ): Playlist { const id = crypto.randomUUID(); const createdAt = new Date(); const slug = makeSlug(req.title, id); db.prepare( `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( id, userId, req.title, slug, req.description ?? null, req.isPublic ? 1 : 0, createdAt.toISOString(), ); const playlist: Playlist = { id, userId, title: req.title, slug, description: req.description, isPublic: req.isPublic, createdAt, }; if (req.description) { notifyMentions(userId, req.description, "playlist", id, req.title); } broadcastPlaylistCreated(playlist); return playlist; } export function getPlaylist( playlistId: string, requestingUserId: string | null, ): PlaylistWithDumps { const playlist = getPlaylistById(playlistId); if (!playlist.isPublic && requestingUserId !== playlist.userId) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); } const dumpCols = DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", "); const isOwner = requestingUserId === playlist.userId; // For public playlists (or when viewed by non-owner), filter out private dumps const rows = db.prepare( `SELECT ${dumpCols}, (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count FROM dumps d INNER JOIN playlist_dumps pd ON d.id = pd.dump_id WHERE pd.playlist_id = ? AND (d.is_private = 0 OR d.user_id = ?) ORDER BY pd.position ASC;`, ).all(playlist.id, requestingUserId ?? ""); const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi); // Owners always see their own private dumps; strip them for non-owners regardless const visibleDumps = isOwner ? dumps : dumps.filter((d) => !d.isPrivate); return { ...playlist, dumps: visibleDumps }; } export function listPlaylistsByUser( userId: string, requestingUserId: string | null, page: number, limit: number, ): { items: Playlist[]; total: number } { const isOwner = requestingUserId === userId; const offset = (page - 1) * limit; const countSql = isOwner ? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;` : `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`; const sql = isOwner ? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;` : `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`; const totalRow = db.prepare(countSql).get(userId) as | { count: number } | undefined; const rows = db.prepare(sql).all(userId, limit, offset); return { items: rows.filter(isPlaylistRow).map(playlistRowToApi), total: totalRow?.count ?? 0, }; } export function updatePlaylist( playlistId: string, req: UpdatePlaylistRequest, requestingUserId: string, ): Playlist { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } const newTitle = req.title ?? playlist.title; const newDescription = "description" in req ? (req.description ?? null) : (playlist.description ?? null); const newIsPublic = req.isPublic !== undefined ? req.isPublic : playlist.isPublic; const now = new Date(); const newSlug = makeSlug(newTitle, playlist.id); db.prepare( `UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, ).run( newTitle, newSlug, newDescription, newIsPublic ? 1 : 0, now.toISOString(), playlist.id, ); const updated: Playlist = { ...playlist, title: newTitle, slug: newSlug, description: newDescription ?? undefined, isPublic: newIsPublic, updatedAt: now, }; if (newDescription) { notifyMentions( requestingUserId, newDescription, "playlist", playlist.id, newTitle, ); } broadcastPlaylistUpdated(updated); return updated; } export function deletePlaylist( playlistId: string, requestingUserId: string, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic); } export function setPlaylistImage( playlistId: string, imageMime: string, requestingUserId: string, ): Playlist { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run( imageMime, playlist.id, ); const updated = getPlaylistById(playlist.id); broadcastPlaylistUpdated(updated); return updated; } export function addDumpToPlaylist( playlistId: string, dumpId: string, requestingUserId: string, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } const minRow = db.prepare( `SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`, ).get(playlist.id) as { min_pos: number | null } | undefined; const nextPos = (minRow?.min_pos ?? 1) - 1; const addedAt = new Date().toISOString(); try { db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, ?, ?);`, ).run(playlist.id, dumpId, nextPos, addedAt); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("UNIQUE") || msg.includes("unique")) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 409, "Dump already in playlist", ); } throw err; } const dumpIds = getCurrentDumpIds(playlist.id); broadcastPlaylistDumpsUpdated(playlist, dumpIds); if (playlist.isPublic) { const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( dumpId, ) as { title: string } | undefined; if (dumpRow) { notifyPlaylistFollowersNewDump( playlist.id, playlist.title, dumpId, dumpRow.title, ); } } } export function removeDumpFromPlaylist( playlistId: string, dumpId: string, requestingUserId: string, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } db.prepare( `DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`, ).run(playlist.id, dumpId); const dumpIds = getCurrentDumpIds(playlist.id); broadcastPlaylistDumpsUpdated(playlist, dumpIds); } export function reorderPlaylist( playlistId: string, dumpIds: string[], requestingUserId: string, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } const currentIds = getCurrentDumpIds(playlist.id); const currentSet = new Set(currentIds); const newSet = new Set(dumpIds); if ( currentSet.size !== newSet.size || !currentIds.every((id) => newSet.has(id)) ) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "dumpIds must match current playlist members exactly", ); } const update = db.prepare( `UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`, ); for (let i = 0; i < dumpIds.length; i++) { update.run(i, playlist.id, dumpIds[i]); } broadcastPlaylistDumpsUpdated(playlist, dumpIds); } export function getPlaylistMembershipsForDump( dumpId: string, userId: string, ): PlaylistMembership[] { const rows = db.prepare( `SELECT p.*, pd.dump_id IS NOT NULL as has_dump FROM playlists p LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ? WHERE p.user_id = ? ORDER BY p.created_at DESC;`, ).all(dumpId, userId) as Array>; return rows.map((row) => { if (!isPlaylistRow(row)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed playlist data", ); } return { playlist: playlistRowToApi(row), hasDump: Boolean(row.has_dump), }; }); } export function getPlaylistImageInfo( idOrSlug: string, ): { id: string; imageMime: string } | undefined { const playlist = getPlaylistById(idOrSlug); return playlist.imageMime ? { id: playlist.id, imageMime: playlist.imageMime } : undefined; } function getCurrentDumpIds(playlistId: string): string[] { const rows = db.prepare( `SELECT dump_id FROM playlist_dumps WHERE playlist_id = ? ORDER BY position ASC;`, ).all(playlistId) as Array<{ dump_id: string }>; return rows.map((r) => r.dump_id); }