/** * Stores playlists and the ordered dumps in them. The playlist routes call it, * and so do the search, users and dumps routes to list playlists. Every write * is broadcast over the WebSocket, and a viewer is only ever sent the dumps * {@link isMemberVisibleTo} lets them see. * * @module */ import { APIException } from "../lib/api-exception.ts"; import type { CreatePlaylistRequest, Playlist, PlaylistMembership, PlaylistSort, PlaylistWithDumps, UpdatePlaylistRequest, } from "../../shared/playlist.ts"; import type { Dump } from "../../shared/dump.ts"; import { db, transaction } from "../db/connection.ts"; import { DUMP_SELECT_COLUMNS_ALIASED, dumpRowToApi, isDumpRow, } from "../model/dump.ts"; import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { broadcastPlaylistChange, broadcastPlaylistDumpsUpdated, } from "./ws-service.ts"; import { notifyMentions, notifyPlaylistFollowersNewDump, notifyPlaylistFollowersNowPublic, } from "./notification-service.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts"; import { linkAttachments } from "./attachment-service.ts"; import { assertCategoriesExist, attachCategoryIds, categoryJoin, setCategories, } from "./category-service.ts"; import { type DatedPlaylistMember, isMemberVisibleTo, VISIBLE_DUMP_FIGURES, } from "../lib/playlist-members.ts"; import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; /** * The select list and `FROM` clause of a playlist query, adding the owner's * name, the count of the dumps the viewer may see and when the latest was * added. Callers append the `WHERE`, and pass the viewer's id first, as * {@link viewerParam} gives it. */ const PLAYLIST_SELECT = `p.*, u.username as owner_username, ${VISIBLE_DUMP_FIGURES} FROM playlists p JOIN users u ON u.id = p.user_id`; /** * The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}: * `comparePlaylists` (`shared/playlist.ts`) in SQL, which a test holds to it. */ const PLAYLIST_ORDER: Record = { updated: "COALESCE(last_dump_at, p.created_at) DESC, p.created_at DESC", new: "p.created_at DESC", dumps: "dump_count DESC, p.created_at DESC", }; /** * Returns what {@link VISIBLE_DUMP_FIGURES}'s parameter takes for `viewerId`: * a signed-out viewer matches no user. */ function viewerParam(viewerId: string | null | undefined): string { return viewerId ?? ""; } /** * Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their * categories filled in. */ function playlistsOf(rows: unknown[]): Playlist[] { const playlists = parseRows(rows, isPlaylistRow, playlistRowToApi); attachCategoryIds("playlist", playlists); return playlists; } /** * Returns playlist `playlistId`, private or not. A caller that shows it to * someone must check `isPublic` first, as the OG middleware does. Its * `dumpCount` and `lastDumpAt` are `viewerId`'s, or a signed-out viewer's * without one. * * @throws {APIException} 404 when there is no such playlist. */ export function getPlaylistById( playlistId: string, viewerId: string | null = null, ): Playlist { const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`) .get(viewerParam(viewerId), playlistId); if (!row || !isPlaylistRow(row)) { throw new APIException(404, "Playlist not found"); } const playlist = playlistRowToApi(row); attachCategoryIds("playlist", [playlist]); return playlist; } /** * Returns the playlist of `userId`'s whose slug is or was `slug`, private or * not, or throws a 404, as {@link getPlaylistById} does, counting its dumps * for `viewerId` likewise. Reached by a former slug, it comes with its current * one. */ export function getPlaylistByPath( userId: string, slug: string, viewerId: string | null = null, ): Playlist { const playlistId = resolveSlug("playlist", userId, slug); if (!playlistId) throw new APIException(404, "Playlist not found"); return getPlaylistById(playlistId, viewerId); } /** * Creates a playlist owned by `userId`, for `POST /api/playlists`, filed under * the playlist categories `req` names. If it is public, it notifies the users * its description mentions. It links the attachments the description embeds * and broadcasts `playlist_created`. * * @throws {APIException} 400 when a category id names no playlist category. */ export function createPlaylist( req: CreatePlaylistRequest, userId: string, ): Playlist { const id = crypto.randomUUID(); const createdAt = new Date(); const title = req.title.trim(); assertCategoriesExist("playlist", req.categoryIds ?? []); transaction(() => { db.prepare( `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( id, userId, title, newSlug("playlist", userId, title), req.description ?? null, req.isPublic ? 1 : 0, createdAt.toISOString(), ); if (req.categoryIds?.length) { setCategories("playlist", id, req.categoryIds); } }); const playlist = getPlaylistById(id); if (req.description) { // A private playlist's mentions are notified if it goes public. if (req.isPublic) { notifyMentions(userId, req.description, "playlist", id, title); } linkAttachments(req.description, "playlist", id); } broadcastPlaylistChange(null, playlist, []); return playlist; } /** * Returns a playlist with the dumps `requestingUserId` may see in it, in * order, for `GET /api/playlists/:playlistId`, and for * `GET /api/users/:key/playlists/:slug` once it has found the playlist by its * path, for the same viewer. A null `requestingUserId` is a signed-out viewer. * Its `dumpCount` is the number of those dumps. * * @throws {APIException} 404 when there is no such playlist, or it is private * and not theirs. */ export function getPlaylist( playlistId: string | Playlist, requestingUserId: string | null, ): PlaylistWithDumps { const playlist = typeof playlistId === "string" ? getPlaylistById(playlistId, requestingUserId) : playlistId; if (!playlist.isPublic && requestingUserId !== playlist.userId) { throw new APIException(404, "Playlist not found"); } const rows = db.prepare( `SELECT ${DUMP_SELECT_COLUMNS_ALIASED} 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[] = parseRows(rows, isDumpRow, dumpRowToApi); const visibleDumps = dumps.filter((d) => isMemberVisibleTo(d, playlist, requestingUserId) ); return { ...playlist, dumpCount: visibleDumps.length, dumps: visibleDumps }; } /** * Returns up to `limit` playlists whose title or description contains * `query`, newest first, for `GET /api/search`, optionally only those in the * playlist category `categoryId`. Private playlists are left out, except * `requestingUserId`'s own. A blank `query` finds nothing. */ export function searchPlaylists( query: string, limit: number, requestingUserId?: string, categoryId?: string, ): Playlist[] { if (!query.trim()) return []; const pattern = likePattern(query); const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`; const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId); const rows = requestingUserId ? db.prepare( `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, ).all( requestingUserId, ...catParams, requestingUserId, pattern, pattern, limit, ) : db.prepare( `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, ).all(viewerParam(null), ...catParams, pattern, pattern, limit); return playlistsOf(rows); } /** * Returns a page of every public playlist, in the order `sort` names, for * `GET /api/playlists`, optionally only those in the playlist category * `categoryId`. The client's `/playlists` page lists them. Their dumps are * counted, and so ordered, for `requestingUserId`, null being a signed-out * viewer. */ export function listPublicPlaylists( requestingUserId: string | null, page: number, limit: number, sort: PlaylistSort, categoryId?: string, ): Page { const offset = (page - 1) * limit; const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId); const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`, ).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset); return pageOf(playlistsOf(rows), limit); } /** * Returns the public playlists created from `from` to `to`, the fullest first * and the newer on a tie. The community service gathers them for the * `/community` page and the digests, which show every reader the same * figures: only their public dumps are counted. */ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] { const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ? ORDER BY ${PLAYLIST_ORDER.dumps};`, ).all(viewerParam(null), from.toISOString(), to.toISOString()); return playlistsOf(rows); } /** * Returns a page of `userId`'s playlists, in the order `sort` names, for * `GET /api/users/:key/playlists`, optionally only those in the playlist * category `categoryId`. The private ones are listed only when * `requestingUserId` is `userId`. */ export function listPlaylistsByUser( userId: string, requestingUserId: string | null, page: number, limit: number, sort: PlaylistSort, categoryId?: string, ): Page { const isOwner = requestingUserId === userId; const offset = (page - 1) * limit; const privacyFilter = isOwner ? "" : " AND p.is_public = 1"; const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId); const rows = db.prepare( `SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.user_id = ?${privacyFilter} ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`, ).all( viewerParam(requestingUserId), ...catParams, userId, limit + 1, offset, ); return pageOf(playlistsOf(rows), limit); } /** * Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns * it as it now is. A new `slug` goes through `changeSlug`, first, and a * moderator is exempt from its rate limit; a new title leaves the slug as it * is. `canModerate` lets someone other than the owner edit it. If the * playlist is public, it notifies, in the owner's name, the users the * description mentions. When it goes public, its followers are notified of * its public dumps they weren't yet told of. It links the description's * attachments and tells clients of the change (`broadcastPlaylistChange`). * * @throws {APIException} 404 when there is no such playlist, 403 when the user * is neither its owner nor a moderator, 400 when the slug isn't valid or a * category id names no playlist category, 409 when the slug is taken, 429 * when it changed too often lately. */ export function updatePlaylist( playlistId: string, req: UpdatePlaylistRequest, requestingUserId: string, canModerate: boolean, ): Playlist { const playlist = getPlaylistById(playlistId, requestingUserId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(403, "Only its owner or a moderator may do that"); } const newTitle = req.title?.trim() ?? playlist.title; const newDescription = "description" in req ? (req.description ?? null) : (playlist.description ?? null); const newIsPublic = req.isPublic !== undefined ? req.isPublic : playlist.isPublic; if (req.categoryIds !== undefined) { assertCategoriesExist("playlist", req.categoryIds); } if (req.slug !== undefined) { changeSlug("playlist", playlist, req.slug, canModerate); } const now = new Date(); transaction(() => { db.prepare( `UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, ).run( newTitle, newDescription, newIsPublic ? 1 : 0, now.toISOString(), playlist.id, ); if (req.categoryIds !== undefined) { setCategories("playlist", playlist.id, req.categoryIds); } }); const updated: Playlist = { ...playlist, title: newTitle, slug: req.slug ?? playlist.slug, description: newDescription ?? undefined, isPublic: newIsPublic, updatedAt: now, categoryIds: req.categoryIds !== undefined ? [...new Set(req.categoryIds)] : playlist.categoryIds, }; if (newDescription) { if (newIsPublic) { notifyMentions( playlist.userId, newDescription, "playlist", playlist.id, newTitle, ); } linkAttachments(newDescription, "playlist", playlist.id); } broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id)); // Its followers weren't told of the dumps added while it was private. if (!playlist.isPublic && newIsPublic) { notifyPlaylistFollowersNowPublic({ playlistId: playlist.id }); } return updated; } /** * Deletes a playlist for `DELETE /api/playlists/:playlistId`, and broadcasts * `playlist_deleted`. `canModerate` lets someone other than the owner do 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(403, "Only its owner or a moderator may do that"); } db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); broadcastPlaylistChange(playlist, null, []); } /** * Returns the playlist if `requestingUserId` may edit it. The cover upload * route checks this before it writes the file, then calls * {@link setPlaylistImage}. * * @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, requestingUserId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(403, "Only its owner or a moderator may do that"); } return playlist; } /** * Records the type of a playlist's new cover and broadcasts `playlist_updated`. * The route must have written the file already, since clients fetch the cover * as soon as they get the broadcast. * * @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, ); // updated_at is the `?v=` the client puts on the cover URL, so bumping it // keeps browsers from showing the cached old cover. db.prepare( `UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`, ).run(imageMime, new Date().toISOString(), playlist.id); const updated = getPlaylistById(playlist.id, requestingUserId); broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id)); return updated; } /** * Puts a dump at the top of a playlist, for * `POST /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts * `playlist_dumps_updated`. The followers of a public playlist are notified * when the dump is public. * * @throws {APIException} 404 when there is no such playlist, or when the dump * doesn't exist or is someone else's private dump, 403 when the user is * neither the playlist's owner nor a moderator, 409 when the dump is already * in it. */ export function addDumpToPlaylist( playlistId: string, dumpId: string, requestingUserId: string, canModerate: boolean, ): void { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(403, "Only its owner or a moderator may do that"); } // Else someone else's private dump would land in the playlist, and its title // in the 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(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(409, "Dump already in playlist"); } throw err; } broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); // Followers can't open a private dump, even one of 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, for * `DELETE /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts * `playlist_dumps_updated`. A dump that isn't in it is no error. * * @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(403, "Only its owner or a moderator may do that"); } db.prepare( `DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`, ).run(playlist.id, dumpId); broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); } /** * Puts the dumps of a playlist that the requester can see in the order of * `dumpIds`, for `PUT /api/playlists/:playlistId/order`, and broadcasts * `playlist_dumps_updated`. * * @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(403, "Only its owner or a moderator may do that"); } // The requester can only name the dumps GET showed them. A hidden one // (someone else's dump that went private, or the owner's private dump for a // moderator) 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( 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`, for `GET /api/me/playlists?contains=`. The client's * add-to-playlist modal shows it. * * @throws {APIException} 500 when a row doesn't parse as a playlist. */ export function getPlaylistMembershipsForDump( dumpId: string, userId: string, ): PlaylistMembership[] { const rows = db.prepare( `SELECT p.*, u.username as owner_username, ${VISIBLE_DUMP_FIGURES}, EXISTS (SELECT 1 FROM playlist_dumps pd WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump FROM playlists p JOIN users u ON u.id = p.user_id WHERE p.user_id = ? ORDER BY p.created_at DESC;`, ).all(userId, dumpId, userId); const memberships = rows.map((row) => { if (!isPlaylistRow(row)) { throw new APIException(500, "Malformed playlist data"); } return { playlist: playlistRowToApi(row), hasDump: Boolean(row.has_dump), }; }); attachCategoryIds("playlist", memberships.map((m) => m.playlist)); return memberships; } /** * Returns up to 20 playlists that hold dump `dumpId`, newest first, for * `GET /api/dumps/:dumpId/playlists`. Private playlists are left out, except * `requestingUserId`'s own. The route checks first that the caller may see the * dump. */ 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(viewerParam(requestingUserId), dumpId, requestingUserId ?? ""); return playlistsOf(rows); } /** * Returns what `GET /api/playlists/:playlistId/cover` needs to serve a * playlist's cover, or undefined when it has none. The route serves a private * playlist's cover as private, so no shared cache keeps it. * * @throws {APIException} 404 when there is no such playlist, or it is private * and `requestingUserId` isn't its owner. */ export function getPlaylistImageInfo( playlistId: string, requestingUserId: string | null, ): { id: string; imageMime: string; isPublic: boolean } | undefined { const playlist = getPlaylistById(playlistId); if (!playlist.isPublic && requestingUserId !== playlist.userId) { throw new APIException(404, "Playlist not found"); } return playlist.imageMime ? { id: playlist.id, imageMime: playlist.imageMime, isPublic: playlist.isPublic, } : undefined; } /** * Returns a playlist's dumps in order, all of them, for the callers to filter * per viewer. */ function getCurrentMembers(playlistId: string): DatedPlaylistMember[] { const rows = db.prepare( `SELECT pd.dump_id, pd.added_at, 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; added_at: string; is_private: number; user_id: string } >; return rows.map((r) => ({ id: r.dump_id, isPrivate: Boolean(r.is_private), userId: r.user_id, addedAt: new Date(r.added_at), })); } /** * A playlist holding a dump, as {@link playlistsHolding} returns it: what * `broadcastPlaylistDumpsUpdated` needs to choose its audience. */ export type PlaylistHolder = Pick; /** * Returns the playlists holding dump `dumpId`. The dump service reads them * before it deletes the dump or changes its visibility, and passes them to * {@link broadcastHoldersDumps} after. */ export function playlistsHolding(dumpId: string): PlaylistHolder[] { const rows = db.prepare( `SELECT p.id, p.is_public, p.user_id FROM playlist_dumps pd INNER JOIN playlists p ON p.id = pd.playlist_id WHERE pd.dump_id = ?;`, ).all(dumpId) as Array<{ id: string; is_public: number; user_id: string }>; return rows.map((r) => ({ id: r.id, isPublic: Boolean(r.is_public), userId: r.user_id, })); } /** * Sends each of `holders` its dumps again, after one of them was deleted or * changed visibility, which changes what each viewer may see of them. */ export function broadcastHoldersDumps(holders: PlaylistHolder[]): void { for (const holder of holders) { broadcastPlaylistDumpsUpdated(holder, getCurrentMembers(holder.id)); } }