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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user