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

553 lines
17 KiB
TypeScript

/**
* 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<Playlist> {
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,
}));
}