/** * Playlists: managing them and their dumps, and reading them as each viewer * may see them. A playlist is public or private to its owner; its members are * filtered per viewer (see {@link isMemberVisibleTo}). * * @module */ import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { CreatePlaylistRequest, Playlist, PlaylistMembership, PlaylistWithDumps, UpdatePlaylistRequest, } from "../../shared/playlist.ts"; import type { Dump } from "../../shared/dump.ts"; import { db, transaction } from "../db/connection.ts"; import { DUMP_COLUMNS_ALIASED, dumpRowToApi, isDumpRow, } from "../model/dump.ts"; import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.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"; import { linkAttachments } from "./attachment-service.ts"; import { isMemberVisibleTo, type PlaylistMember, } from "../lib/playlist-members.ts"; /** * The columns of a `PlaylistRow` with its owner's name and dump count, * and the `FROM playlists p` they come from. */ 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`; /** * Returns the playlist with id or slug `idOrSlug`, private or not: callers * that show it check its visibility. * * @throws {APIException} 404 when there is no such playlist. */ export 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); } /** * Creates a playlist owned by `userId`, notifies anyone @mentioned in its * description, and announces it. */ 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); linkAttachments(req.description, id); } broadcastPlaylistCreated(playlist); return playlist; } /** * Returns a playlist with the dumps `requestingUserId` may see in it, in * order. * * @throws {APIException} 404 when there is no such playlist, or it is private and not theirs. */ 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 rows = db.prepare( `SELECT ${DUMP_COLUMNS_ALIASED}, (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); const visibleDumps = dumps.filter((d) => isMemberVisibleTo(d, playlist, requestingUserId) ); return { ...playlist, dumps: visibleDumps }; } /** * Returns up to `limit` playlists whose title or description contains * `query`, newest first: the public ones, and the requesting user's own. */ export function searchPlaylists( query: string, limit: number, requestingUserId?: string, ): Playlist[] { if (!query.trim()) return []; const pattern = `%${query}%`; const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`; const rows = requestingUserId ? db.prepare( `SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, ).all(requestingUserId, pattern, pattern, limit) : db.prepare( `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, ).all(pattern, pattern, limit); return rows.filter(isPlaylistRow).map(playlistRowToApi); } /** * Returns a page of `userId`'s playlists, newest first — the private ones only * to `userId` themselves. */ export function listPlaylistsByUser( userId: string, requestingUserId: string | null, page: number, limit: number, ): Page { const isOwner = requestingUserId === userId; const offset = (page - 1) * limit; 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 rows = db.prepare(sql).all(userId, limit + 1, offset); return pageOf(rows.filter(isPlaylistRow).map(playlistRowToApi), limit); } /** * Applies `req` to a playlist (its slug follows the title), as its owner or a * moderator (`canModerate`); notifies anyone @mentioned in the description, * and announces the change. * * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. */ export function updatePlaylist( playlistId: string, req: UpdatePlaylistRequest, requestingUserId: string, canModerate: boolean, ): Playlist { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { 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, ); linkAttachments(newDescription, playlist.id); } broadcastPlaylistUpdated(updated, playlist.isPublic); return updated; } /** * Deletes a playlist, as its owner or a moderator (`canModerate`), and * announces it. * * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. */ export function deletePlaylist( playlistId: string, requestingUserId: string, canModerate: boolean, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic); } /** * Returns the playlist, if the requester may edit it. * * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. */ export function getEditablePlaylist( playlistId: string, requestingUserId: string, canModerate: boolean, ): Playlist { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } return playlist; } /** * Records a playlist's new cover image and announces it. Call once the image * file is in place: this is what announces it. * * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. */ export function setPlaylistImage( playlistId: string, imageMime: string, requestingUserId: string, canModerate: boolean, ): Playlist { const playlist = getEditablePlaylist( playlistId, requestingUserId, canModerate, ); // A new cover is an edit: bumping updated_at also moves the `?v=` the // client puts on the image URL, so a cached old cover isn't shown. db.prepare( `UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`, ).run(imageMime, new Date().toISOString(), playlist.id); const updated = getPlaylistById(playlist.id); broadcastPlaylistUpdated(updated, playlist.isPublic); return updated; } /** * Puts a dump at the top of a playlist, announces the change, and notifies the * followers of a public playlist when the dump is public. * * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 404 when the dump doesn't exist or is * someone else's private dump, 409 when it is already in the playlist. */ export function addDumpToPlaylist( playlistId: string, dumpId: string, requestingUserId: string, canModerate: boolean, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } // Only a dump the requester can see — someone else's private dump would // otherwise land in the playlist and its title in followers' notifications. const dump = db.prepare( `SELECT title, is_private, user_id FROM dumps WHERE id = ?;`, ).get(dumpId) as | { title: string; is_private: number; user_id: string } | undefined; if (!dump || (dump.is_private && dump.user_id !== requestingUserId)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } 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; } broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); // Followers can't open a private dump, even the owner's own. if (playlist.isPublic && !dump.is_private) { notifyPlaylistFollowersNewDump( playlist.id, playlist.title, dumpId, dump.title, ); } } /** * Takes a dump out of a playlist, if it is in it, and announces the change. * * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. */ export function removeDumpFromPlaylist( playlistId: string, dumpId: string, requestingUserId: string, canModerate: boolean, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } db.prepare( `DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`, ).run(playlist.id, dumpId); broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); } /** * Puts the playlist's members the requester can see in the order of * `dumpIds`, and announces the change. * * @throws {APIException} 404 when there is no such playlist, 403 when the user * is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the * members they can see. */ export function reorderPlaylist( playlistId: string, dumpIds: string[], requestingUserId: string, canModerate: boolean, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } // The requester orders what they can see — the playlist as GET gives it to // them. A member hidden from them (someone else's dump that went private, // or the owner's private dumps for a moderator) can't be named, so it // keeps its slot and the visible ones are reordered around it. const members = getCurrentMembers(playlist.id); const visibleIds = members .filter((m) => isMemberVisibleTo(m, playlist, requestingUserId)) .map((m) => m.id); const newSet = new Set(dumpIds); if ( dumpIds.length !== newSet.size || visibleIds.length !== newSet.size || !visibleIds.every((id) => newSet.has(id)) ) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "dumpIds must match current playlist members exactly", ); } let next = 0; const order = members.map((m) => isMemberVisibleTo(m, playlist, requestingUserId) ? dumpIds[next++] : m.id ); const update = db.prepare( `UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`, ); transaction(() => { for (let i = 0; i < order.length; i++) { update.run(i, playlist.id, order[i]); } }); broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); } /** * Returns each of `userId`'s playlists, newest first, and whether it holds * dump `dumpId`. */ 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); 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), }; }); } /** * Returns the playlists a dump appears in — the "in collections" counterpart to * backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for * everyone, plus the requesting user's own private ones. Capped at 20 — a * small, bounded list of supporting context. */ export function getPlaylistsForDump( dumpId: string, requestingUserId?: string, ): Playlist[] { const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?) ORDER BY p.created_at DESC LIMIT 20;`, ).all(dumpId, requestingUserId ?? ""); return rows.filter(isPlaylistRow).map(playlistRowToApi); } /** * Returns a playlist's id, cover image type and visibility, or undefined * without a cover. A private playlist's cover exists only for its owner. * * @throws {APIException} 404 when there is no such playlist, or it is private * and `requestingUserId` isn't its owner. */ export function getPlaylistImageInfo( idOrSlug: string, requestingUserId: string | null, ): { id: string; imageMime: string; isPublic: boolean } | undefined { const playlist = getPlaylistById(idOrSlug); if (!playlist.isPublic && requestingUserId !== playlist.userId) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); } return playlist.imageMime ? { id: playlist.id, imageMime: playlist.imageMime, isPublic: playlist.isPublic, } : undefined; } /** Returns a playlist's members, in order, with what their visibility takes. */ function getCurrentMembers(playlistId: string): PlaylistMember[] { const rows = db.prepare( `SELECT pd.dump_id, d.is_private, d.user_id FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`, ).all(playlistId) as Array< { dump_id: string; is_private: number; user_id: string } >; return rows.map((r) => ({ id: r.dump_id, isPrivate: Boolean(r.is_private), userId: r.user_id, })); }