v1 feature: added playlists
This commit is contained in:
306
api/services/playlist-service.ts
Normal file
306
api/services/playlist-service.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dump, OnlineUser } from "../model/interfaces.ts";
|
||||
import type { Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
|
||||
|
||||
export interface WsClient {
|
||||
socket: WebSocket;
|
||||
@@ -82,9 +82,50 @@ export function broadcastVoteUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
function sendToPlaylistAudience(
|
||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||
data: unknown,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
if (playlist.isPublic || client.userId === playlist.userId) {
|
||||
send(client.socket, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||
}
|
||||
|
||||
export function broadcastPlaylistUpdated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_updated", playlist });
|
||||
}
|
||||
|
||||
export function broadcastPlaylistDeleted(
|
||||
playlistId: string,
|
||||
userId: string,
|
||||
isPublic: boolean,
|
||||
): void {
|
||||
sendToPlaylistAudience({ isPublic, userId }, {
|
||||
type: "playlist_deleted",
|
||||
playlistId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function broadcastPlaylistDumpsUpdated(
|
||||
playlist: Playlist,
|
||||
dumpIds: string[],
|
||||
): void {
|
||||
sendToPlaylistAudience(playlist, {
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: playlist.id,
|
||||
dumpIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||
const PING_INTERVAL = 30_000;
|
||||
const _PONG_TIMEOUT = 5_000;
|
||||
|
||||
setInterval(() => {
|
||||
for (const client of clients) {
|
||||
|
||||
Reference in New Issue
Block a user