Files
gerbeur/api/services/playlist-service.ts
khannurien d392be7cb8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:19:48 +00:00

455 lines
14 KiB
TypeScript

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";
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`;
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);
}
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;
}
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 };
}
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);
}
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);
}
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;
}
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);
}
/** The playlist, if the requester may edit it; 404 or 403 otherwise. */
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;
}
// Call once the image file is in place: this is what announces it.
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;
}
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,
);
}
}
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));
}
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));
}
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),
};
});
}
/**
* 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);
}
export function getPlaylistImageInfo(
idOrSlug: string,
): { id: string; imageMime: string } | undefined {
const playlist = getPlaylistById(idOrSlug);
return playlist.imageMime
? { id: playlist.id, imageMime: playlist.imageMime }
: undefined;
}
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,
}));
}