374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
import {
|
|
APIErrorCode,
|
|
APIException,
|
|
type CreatePlaylistRequest,
|
|
type Dump,
|
|
type Playlist,
|
|
type PlaylistMembership,
|
|
type PlaylistWithDumps,
|
|
type UpdatePlaylistRequest,
|
|
} from "../model/interfaces.ts";
|
|
import {
|
|
db,
|
|
dumpRowToApi,
|
|
isDumpRow,
|
|
isPlaylistRow,
|
|
playlistRowToApi,
|
|
} from "../model/db.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";
|
|
|
|
const DUMP_SELECT_COLS =
|
|
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
|
|
|
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);
|
|
}
|
|
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 dumpCols = DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ");
|
|
const isOwner = requestingUserId === playlist.userId;
|
|
|
|
// For public playlists (or when viewed by non-owner), filter out private dumps
|
|
const rows = db.prepare(
|
|
`SELECT ${dumpCols},
|
|
(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);
|
|
// Owners always see their own private dumps; strip them for non-owners regardless
|
|
const visibleDumps = isOwner ? dumps : dumps.filter((d) => !d.isPrivate);
|
|
|
|
return { ...playlist, dumps: visibleDumps };
|
|
}
|
|
|
|
export function listPlaylistsByUser(
|
|
userId: string,
|
|
requestingUserId: string | null,
|
|
page: number,
|
|
limit: number,
|
|
): { items: Playlist[]; total: number } {
|
|
const isOwner = requestingUserId === userId;
|
|
const offset = (page - 1) * limit;
|
|
|
|
const countSql = isOwner
|
|
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
|
|
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
|
|
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 totalRow = db.prepare(countSql).get(userId) as
|
|
| { count: number }
|
|
| undefined;
|
|
const rows = db.prepare(sql).all(userId, limit, offset);
|
|
return {
|
|
items: rows.filter(isPlaylistRow).map(playlistRowToApi),
|
|
total: totalRow?.count ?? 0,
|
|
};
|
|
}
|
|
|
|
export function updatePlaylist(
|
|
playlistId: string,
|
|
req: UpdatePlaylistRequest,
|
|
requestingUserId: string,
|
|
): Playlist {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId) {
|
|
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,
|
|
);
|
|
}
|
|
broadcastPlaylistUpdated(updated);
|
|
return updated;
|
|
}
|
|
|
|
export function deletePlaylist(
|
|
playlistId: string,
|
|
requestingUserId: string,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId) {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
|
}
|
|
|
|
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
|
|
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
|
|
}
|
|
|
|
export function setPlaylistImage(
|
|
playlistId: string,
|
|
imageMime: string,
|
|
requestingUserId: string,
|
|
): Playlist {
|
|
const playlist = getPlaylistById(playlistId);
|
|
if (playlist.userId !== requestingUserId) {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
|
}
|
|
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
|
|
imageMime,
|
|
playlist.id,
|
|
);
|
|
const updated = getPlaylistById(playlist.id);
|
|
broadcastPlaylistUpdated(updated);
|
|
return updated;
|
|
}
|
|
|
|
export function addDumpToPlaylist(
|
|
playlistId: string,
|
|
dumpId: string,
|
|
requestingUserId: string,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId) {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const dumpIds = getCurrentDumpIds(playlist.id);
|
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
|
|
|
if (playlist.isPublic) {
|
|
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
|
dumpId,
|
|
) as { title: string } | undefined;
|
|
if (dumpRow) {
|
|
notifyPlaylistFollowersNewDump(
|
|
playlist.id,
|
|
playlist.title,
|
|
dumpId,
|
|
dumpRow.title,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function removeDumpFromPlaylist(
|
|
playlistId: string,
|
|
dumpId: string,
|
|
requestingUserId: string,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId) {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
|
}
|
|
|
|
db.prepare(
|
|
`DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`,
|
|
).run(playlist.id, dumpId);
|
|
|
|
const dumpIds = getCurrentDumpIds(playlist.id);
|
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
|
}
|
|
|
|
export function reorderPlaylist(
|
|
playlistId: string,
|
|
dumpIds: string[],
|
|
requestingUserId: string,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId) {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
|
}
|
|
|
|
const currentIds = getCurrentDumpIds(playlist.id);
|
|
const currentSet = new Set(currentIds);
|
|
const newSet = new Set(dumpIds);
|
|
|
|
if (
|
|
currentSet.size !== newSet.size ||
|
|
!currentIds.every((id) => newSet.has(id))
|
|
) {
|
|
throw new APIException(
|
|
APIErrorCode.BAD_REQUEST,
|
|
400,
|
|
"dumpIds must match current playlist members exactly",
|
|
);
|
|
}
|
|
|
|
const update = db.prepare(
|
|
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
|
|
);
|
|
for (let i = 0; i < dumpIds.length; i++) {
|
|
update.run(i, playlist.id, dumpIds[i]);
|
|
}
|
|
|
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
|
}
|
|
|
|
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),
|
|
};
|
|
});
|
|
}
|
|
|
|
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 getCurrentDumpIds(playlistId: string): string[] {
|
|
const rows = db.prepare(
|
|
`SELECT dump_id FROM playlist_dumps WHERE playlist_id = ? ORDER BY position ASC;`,
|
|
).all(playlistId) as Array<{ dump_id: string }>;
|
|
return rows.map((r) => r.dump_id);
|
|
}
|