Files
gerbeur/api/services/playlist-service.ts
khannurien 4468438ba7 v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:08:59 +00:00

593 lines
19 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 {
broadcastPlaylistCreated,
broadcastPlaylistDeleted,
broadcastPlaylistDumpsUpdated,
broadcastPlaylistUpdated,
} from "./ws-service.ts";
import {
notifyMentions,
notifyPlaylistFollowersNewDump,
} from "./notification-service.ts";
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { linkAttachments } from "./attachment-service.ts";
import {
isMemberVisibleTo,
type PlaylistMember,
} 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 dump count. Callers append the `WHERE`. The dump count includes
* dumps the viewer can't see.
*/
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 JOIN users u ON u.id = p.user_id`;
/**
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does.
*
* @throws {APIException} 404 when there is no such playlist.
*/
export function getPlaylistById(playlistId: string): Playlist {
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
.get(playlistId);
if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found");
}
return playlistRowToApi(row);
}
/**
* 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`. If it is
* public, it notifies the users its description mentions. It links the
* attachments the description embeds and broadcasts `playlist_created`.
*/
export function createPlaylist(
req: CreatePlaylistRequest,
userId: string,
): Playlist {
const id = crypto.randomUUID();
const createdAt = new Date();
const title = req.title.trim();
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(),
);
});
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);
}
broadcastPlaylistCreated(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.
*
* @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, dumps: visibleDumps };
}
/**
* Returns up to `limit` playlists whose title or description contains
* `query`, newest first, for `GET /api/search`. Private playlists are left
* out, except `requestingUserId`'s own. A blank `query` finds nothing.
*/
export function searchPlaylists(
query: string,
limit: number,
requestingUserId?: string,
): Playlist[] {
if (!query.trim()) return [];
const pattern = likePattern(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 parseRows(rows, isPlaylistRow, playlistRowToApi);
}
/**
* Returns a page of `userId`'s playlists, newest first, for
* `GET /api/users/:key/playlists`. The private ones are listed only when
* `requestingUserId` is `userId`.
*/
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(parseRows(rows, isPlaylistRow, playlistRowToApi), 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. It links the description's attachments and
* broadcasts `playlist_updated`.
*
* @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, 409
* when it 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);
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.slug !== undefined) {
changeSlug("playlist", playlist, req.slug, canModerate);
}
const now = new Date();
db.prepare(
`UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
).run(
newTitle,
newDescription,
newIsPublic ? 1 : 0,
now.toISOString(),
playlist.id,
);
const updated: Playlist = {
...playlist,
title: newTitle,
slug: req.slug ?? playlist.slug,
description: newDescription ?? undefined,
isPublic: newIsPublic,
updatedAt: now,
};
if (newDescription) {
if (newIsPublic) {
notifyMentions(
playlist.userId,
newDescription,
"playlist",
playlist.id,
newTitle,
);
}
linkAttachments(newDescription, "playlist", playlist.id);
}
broadcastPlaylistUpdated(updated, playlist.isPublic);
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);
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
}
/**
* 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);
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);
broadcastPlaylistUpdated(updated, playlist.isPublic);
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,
(SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as 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(dumpId, userId);
return rows.map((row) => {
if (!isPlaylistRow(row)) {
throw new APIException(500, "Malformed playlist data");
}
return {
playlist: playlistRowToApi(row),
hasDump: Boolean(row.has_dump),
};
});
}
/**
* 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(dumpId, requestingUserId ?? "");
return parseRows(rows, isPlaylistRow, playlistRowToApi);
}
/**
* 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,
}));
}