Files
gerbeur/api/services/playlist-service.ts

741 lines
24 KiB
TypeScript

/**
* 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,
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 {
isMemberVisibleTo,
type PlaylistMember,
VISIBLE_DUMP_COUNT,
} 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 and the count of the dumps the viewer may see. 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_COUNT}
FROM playlists p JOIN users u ON u.id = p.user_id`;
/**
* Returns what {@link VISIBLE_DUMP_COUNT}'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` is `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. Reached by a former
* slug, it comes with its current one.
*/
export function getPlaylistByPath(userId: string, slug: string): Playlist {
const playlistId = resolveSlug("playlist", userId, slug);
if (!playlistId) throw new APIException(404, "Playlist not found");
return getPlaylistById(playlistId);
}
/**
* 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. 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)
: 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, newest first, for
* `GET /api/playlists`, optionally only those in the playlist category
* `categoryId`. The client's `/playlists` page lists them. Their dumps are
* counted for `requestingUserId`, null being a signed-out viewer.
*/
export function listPublicPlaylists(
requestingUserId: string | null,
page: number,
limit: number,
categoryId?: string,
): Page<Playlist> {
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 p.created_at DESC 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 dump_count DESC, p.created_at DESC;`,
).all(viewerParam(null), from.toISOString(), to.toISOString());
return playlistsOf(rows);
}
/**
* Returns a page of `userId`'s playlists, newest first, 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,
categoryId?: string,
): Page<Playlist> {
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 p.created_at DESC 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_COUNT},
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): 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,
}));
}
/**
* A playlist holding a dump, as {@link playlistsHolding} returns it: what
* `broadcastPlaylistDumpsUpdated` needs to choose its audience.
*/
export type PlaylistHolder = Pick<Playlist, "id" | "isPublic" | "userId">;
/**
* 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));
}
}