v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* 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 {
|
||||
@@ -33,10 +41,20 @@ import {
|
||||
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)
|
||||
@@ -47,6 +65,10 @@ export function getPlaylistById(idOrSlug: string): Playlist {
|
||||
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,
|
||||
@@ -83,6 +105,12 @@ export function createPlaylist(
|
||||
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,
|
||||
@@ -111,6 +139,10 @@ export function getPlaylist(
|
||||
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,
|
||||
@@ -131,6 +163,10 @@ export function searchPlaylists(
|
||||
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,
|
||||
@@ -148,6 +184,13 @@ export function listPlaylistsByUser(
|
||||
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,
|
||||
@@ -203,6 +246,12 @@ export function updatePlaylist(
|
||||
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,
|
||||
@@ -218,7 +267,11 @@ export function deletePlaylist(
|
||||
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
|
||||
}
|
||||
|
||||
/** The playlist, if the requester may edit it; 404 or 403 otherwise. */
|
||||
/**
|
||||
* 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,
|
||||
@@ -231,7 +284,12 @@ export function getEditablePlaylist(
|
||||
return playlist;
|
||||
}
|
||||
|
||||
// Call once the image file is in place: this is what announces it.
|
||||
/**
|
||||
* 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,
|
||||
@@ -253,6 +311,13 @@ export function setPlaylistImage(
|
||||
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,
|
||||
@@ -313,6 +378,11 @@ export function addDumpToPlaylist(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -332,6 +402,13 @@ export function removeDumpFromPlaylist(
|
||||
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[],
|
||||
@@ -382,6 +459,10 @@ export function reorderPlaylist(
|
||||
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,
|
||||
@@ -410,7 +491,7 @@ export function getPlaylistMembershipsForDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* The playlists a dump appears in — the "in collections" counterpart to
|
||||
* 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.
|
||||
@@ -429,15 +510,31 @@ export function getPlaylistsForDump(
|
||||
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,
|
||||
): { id: string; imageMime: string } | undefined {
|
||||
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 }
|
||||
? {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user