Files
gerbeur/api/services/playlist-service.ts
2026-03-16 16:52:53 +00:00

307 lines
8.4 KiB
TypeScript

import type { SQLOutputValue } from "node:sqlite";
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";
const DUMP_SELECT_COLS =
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
function getPlaylistById(playlistId: string): Playlist {
const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get(
playlistId,
);
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();
db.prepare(
`INSERT INTO playlists (id, user_id, title, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?);`,
).run(
id,
userId,
req.title,
req.description ?? null,
req.isPublic ? 1 : 0,
createdAt.toISOString(),
);
const playlist: Playlist = {
id,
userId,
title: req.title,
description: req.description,
isPublic: req.isPublic,
createdAt,
};
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_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")}
FROM dumps d
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
WHERE pd.playlist_id = ?
ORDER BY pd.position ASC;`,
).all(playlistId);
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
return { ...playlist, dumps };
}
export function listPlaylistsByUser(
userId: string,
requestingUserId: string | null,
): Playlist[] {
const isOwner = requestingUserId === userId;
const sql = isOwner
? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC;`
: `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC;`;
const rows = db.prepare(sql).all(userId);
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
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;
db.prepare(
`UPDATE playlists SET title = ?, description = ?, is_public = ? WHERE id = ?;`,
).run(newTitle, newDescription, newIsPublic ? 1 : 0, playlistId);
const updated: Playlist = {
...playlist,
title: newTitle,
description: newDescription ?? undefined,
isPublic: newIsPublic,
};
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(playlistId);
broadcastPlaylistDeleted(playlistId, 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,
playlistId,
);
const updated = getPlaylistById(playlistId);
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 maxRow = db.prepare(
`SELECT MAX(position) as max_pos FROM playlist_dumps WHERE playlist_id = ?;`,
).get(playlistId) as { max_pos: number | null } | undefined;
const nextPos = (maxRow?.max_pos ?? -1) + 1;
const addedAt = new Date().toISOString();
try {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(playlistId, 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(playlistId);
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
}
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(playlistId, dumpId);
const dumpIds = getCurrentDumpIds(playlistId);
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(playlistId);
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, playlistId, 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) as Array<Record<string, SQLOutputValue>>;
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 getPlaylistImageMime(playlistId: string): string | undefined {
const row = db.prepare(`SELECT image_mime FROM playlists WHERE id = ?;`).get(
playlistId,
) as
| { image_mime: string | null }
| undefined;
return row?.image_mime ?? 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);
}