v4: a playlist's dump count is of the dumps the viewer may see — it counted every dump in the playlist, so a card said "3 dumps" over a page listing two, and told everyone how many private dumps a playlist held. The lists (/playlists, a profile's playlists and followed ones, the search, a dump's playlists, the add-to-playlist panel) now count by isMemberVisibleTo's rule, in SQL as VISIBLE_DUMP_COUNT (lib/playlist-members.ts): the public dumps, and for the owner their own private ones. GET /api/playlists and /api/users/:key/follow/playlists read the optional sign-in for it. The community page and the digests count the public dumps, their figures being the same for every reader. Live, playlist_created and playlist_updated carry each socket's own count: broadcastChange builds the owner's message and the others' apart. playlist_dumps_updated loses its dumpCount, a client counting the dumps it is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 08:25:24 +00:00
parent ffc071af4c
commit 28f910b00e
18 changed files with 273 additions and 94 deletions

View File

@@ -23,6 +23,7 @@ import {
} from "../model/dump.ts";
import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { VISIBLE_DUMP_COUNT } from "../lib/playlist-members.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
import { sendToUser } from "./ws-service.ts";
@@ -232,12 +233,14 @@ export function getFollowedPlaylistsDumpFeed(
/**
* Returns a page of the public playlists `userId` follows, most recently
* followed first, for their profile and playlists pages.
* followed first, for their profile and playlists pages. Their dumps are
* counted for `requestingUserId`, null being a signed-out viewer.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/
export function getFollowedPlaylistsByUser(
userId: string,
requestingUserId: string | null,
page: number,
limit: number,
): Page<Playlist> {
@@ -245,7 +248,7 @@ export function getFollowedPlaylistsByUser(
const rawRows = db.prepare(
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
${VISIBLE_DUMP_COUNT}
FROM playlists p
LEFT JOIN users u ON u.id = p.user_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
@@ -253,7 +256,7 @@ export function getFollowedPlaylistsByUser(
AND p.is_public = 1
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit + 1, offset);
).all(requestingUserId ?? "", userId, limit + 1, offset);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(500, "Malformed playlist data");

View File

@@ -44,19 +44,28 @@ import {
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 dump count. Callers append the `WHERE`. The dump count includes
* dumps the viewer can't see.
* 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,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
${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.
@@ -69,13 +78,17 @@ function playlistsOf(rows: unknown[]): Playlist[] {
/**
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does.
* 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): Playlist {
export function getPlaylistById(
playlistId: string,
viewerId: string | null = null,
): Playlist {
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
.get(playlistId);
.get(viewerParam(viewerId), playlistId);
if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found");
}
@@ -136,7 +149,7 @@ export function createPlaylist(
}
linkAttachments(req.description, "playlist", id);
}
broadcastPlaylistChange(null, playlist);
broadcastPlaylistChange(null, playlist, []);
return playlist;
}
@@ -144,7 +157,8 @@ export function createPlaylist(
* 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.
* 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.
@@ -175,7 +189,7 @@ export function getPlaylist(
isMemberVisibleTo(d, playlist, requestingUserId)
);
return { ...playlist, dumps: visibleDumps };
return { ...playlist, dumpCount: visibleDumps.length, dumps: visibleDumps };
}
/**
@@ -198,10 +212,17 @@ export function searchPlaylists(
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(...catParams, requestingUserId, pattern, pattern, 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(...catParams, pattern, pattern, limit);
).all(viewerParam(null), ...catParams, pattern, pattern, limit);
return playlistsOf(rows);
}
@@ -209,9 +230,11 @@ export function searchPlaylists(
/**
* 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.
* `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,
@@ -222,21 +245,22 @@ export function listPublicPlaylists(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, limit + 1, 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.
* `/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(from.toISOString(), to.toISOString());
).all(viewerParam(null), from.toISOString(), to.toISOString());
return playlistsOf(rows);
}
@@ -262,7 +286,13 @@ export function listPlaylistsByUser(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.user_id = ?${privacyFilter}
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, userId, limit + 1, offset);
).all(
viewerParam(requestingUserId),
...catParams,
userId,
limit + 1,
offset,
);
return pageOf(playlistsOf(rows), limit);
}
@@ -287,7 +317,7 @@ export function updatePlaylist(
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
const playlist = getPlaylistById(playlistId, requestingUserId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Only its owner or a moderator may do that");
@@ -347,7 +377,7 @@ export function updatePlaylist(
}
linkAttachments(newDescription, "playlist", playlist.id);
}
broadcastPlaylistChange(playlist, updated);
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 });
@@ -374,7 +404,7 @@ export function deletePlaylist(
}
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
broadcastPlaylistChange(playlist, null);
broadcastPlaylistChange(playlist, null, []);
}
/**
@@ -390,7 +420,7 @@ export function getEditablePlaylist(
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
const playlist = getPlaylistById(playlistId, requestingUserId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Only its owner or a moderator may do that");
}
@@ -421,8 +451,8 @@ export function setPlaylistImage(
db.prepare(
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
).run(imageMime, new Date().toISOString(), playlist.id);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistChange(playlist, updated);
const updated = getPlaylistById(playlist.id, requestingUserId);
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
return updated;
}
@@ -590,13 +620,13 @@ export function getPlaylistMembershipsForDump(
): 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,
${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(dumpId, userId);
).all(userId, dumpId, userId);
const memberships = rows.map((row) => {
if (!isPlaylistRow(row)) {
@@ -626,7 +656,7 @@ export function getPlaylistsForDump(
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 ?? "");
).all(viewerParam(requestingUserId), dumpId, requestingUserId ?? "");
return playlistsOf(rows);
}
@@ -701,7 +731,7 @@ export function playlistsHolding(dumpId: string): PlaylistHolder[] {
/**
* Sends each of `holders` its dumps again, after one of them was deleted or
* changed visibility: the count moved, or what each viewer may see of them.
* changed visibility, which changes what each viewer may see of them.
*/
export function broadcastHoldersDumps(holders: PlaylistHolder[]): void {
for (const holder of holders) {

View File

@@ -18,6 +18,7 @@ import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser, Role } from "../../shared/user.ts";
import {
countVisibleTo,
isMemberVisibleTo,
type PlaylistMember,
} from "../lib/playlist-members.ts";
@@ -179,35 +180,43 @@ export function broadcastPresence(): void {
* `before` is null for a new item, `after` for a deleted one. A private item's
* changes reach its owner's sockets alone. When it goes private, everyone else
* is told it was deleted, and gets nothing of the change; when it goes public,
* they are told it was created, since to them it is new.
* they are told it was created, since to them it is new. The owner's sockets
* and the others' each get a message of their own, built with `toOwner` set
* for the former, for what the two don't see alike.
*/
function broadcastChange<T extends { userId: string }>(
before: T | null,
after: T | null,
isPrivate: (item: T) => boolean,
messages: {
created: (item: T) => ServerToClientMessage;
updated: (item: T) => ServerToClientMessage;
created: (item: T, toOwner: boolean) => ServerToClientMessage;
updated: (item: T, toOwner: boolean) => ServerToClientMessage;
deleted: (item: T) => ServerToClientMessage;
},
): void {
const item = after ?? before;
if (!item) return;
const toOwner = (client: WsClient) => client.userId === item.userId;
const toOthers = (client: WsClient) => client.userId !== item.userId;
const wasPublic = !!before && !isPrivate(before);
const isPublic = !!after && !isPrivate(after);
if (!after) {
broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined);
} else if (!before) {
broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined);
} else if (isPrivate(after)) {
broadcast(messages.updated(after), toOwner);
if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers);
} else if (isPrivate(before)) {
broadcast(messages.updated(after), toOwner);
broadcast(messages.created(after), toOthers);
} else {
broadcast(messages.updated(after));
broadcast(
!after
? messages.deleted(item)
: before
? messages.updated(after, true)
: messages.created(after, true),
(client) => client.userId === item.userId,
);
const toOthers = (client: WsClient) => client.userId !== item.userId;
if (after && isPublic) {
broadcast(
wasPublic
? messages.updated(after, false)
: messages.created(after, false),
toOthers,
);
} else if (wasPublic) {
broadcast(messages.deleted(item), toOthers);
}
}
@@ -265,15 +274,32 @@ export function broadcastCommentLikeUpdate(
* Tells the clients that may see a playlist how it changed, as
* {@link broadcastChange} says, for the playlist service after every write to
* its details. A playlist turned private thus leaves the others' views without
* their learning what it became.
* their learning what it became. `members` are the dumps of `after`: each
* socket gets the playlist with the count of those its user may see, the
* owner's private dumps being theirs alone to count.
*/
export function broadcastPlaylistChange(
before: Playlist | null,
after: Playlist | null,
members: PlaylistMember[],
): void {
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
...playlist,
dumpCount: countVisibleTo(
members,
playlist,
toOwner ? playlist.userId : null,
),
});
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
created: (playlist) => ({ type: "playlist_created", playlist }),
updated: (playlist) => ({ type: "playlist_updated", playlist }),
created: (playlist, toOwner) => ({
type: "playlist_created",
playlist: counted(playlist, toOwner),
}),
updated: (playlist, toOwner) => ({
type: "playlist_updated",
playlist: counted(playlist, toOwner),
}),
deleted: (p) => ({
type: "playlist_deleted",
playlistId: p.id,
@@ -285,7 +311,8 @@ export function broadcastPlaylistChange(
/**
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
* only the dumps its user may see, as `GET` would list them. The private dumps
* in a public playlist stay hidden from everyone but their posters.
* in a public playlist stay hidden from everyone but their posters, and so
* does their number: a client counts the dumps it is sent.
*/
export function broadcastPlaylistDumpsUpdated(
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
@@ -299,7 +326,6 @@ export function broadcastPlaylistDumpsUpdated(
dumpIds: members
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
.map((m) => m.id),
dumpCount: members.length,
});
}
}