593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
/**
|
|
* Stores playlists and the ordered dumps in them. The playlist routes call it,
|
|
* and so do the search, users and dumps routes to list playlists. Every write
|
|
* is broadcast over the WebSocket, and a viewer is only ever sent the dumps
|
|
* {@link isMemberVisibleTo} lets them see.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import type {
|
|
CreatePlaylistRequest,
|
|
Playlist,
|
|
PlaylistMembership,
|
|
PlaylistWithDumps,
|
|
UpdatePlaylistRequest,
|
|
} from "../../shared/playlist.ts";
|
|
import type { Dump } from "../../shared/dump.ts";
|
|
import { db, transaction } from "../db/connection.ts";
|
|
import {
|
|
DUMP_SELECT_COLUMNS_ALIASED,
|
|
dumpRowToApi,
|
|
isDumpRow,
|
|
} from "../model/dump.ts";
|
|
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
|
import {
|
|
broadcastPlaylistCreated,
|
|
broadcastPlaylistDeleted,
|
|
broadcastPlaylistDumpsUpdated,
|
|
broadcastPlaylistUpdated,
|
|
} from "./ws-service.ts";
|
|
import {
|
|
notifyMentions,
|
|
notifyPlaylistFollowersNewDump,
|
|
} from "./notification-service.ts";
|
|
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
|
import { linkAttachments } from "./attachment-service.ts";
|
|
import {
|
|
isMemberVisibleTo,
|
|
type PlaylistMember,
|
|
} 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.
|
|
*/
|
|
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 JOIN users u ON u.id = p.user_id`;
|
|
|
|
/**
|
|
* Returns playlist `playlistId`, private or not. A caller that shows it to
|
|
* someone must check `isPublic` first, as the OG middleware does.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist.
|
|
*/
|
|
export function getPlaylistById(playlistId: string): Playlist {
|
|
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
|
|
.get(playlistId);
|
|
if (!row || !isPlaylistRow(row)) {
|
|
throw new APIException(404, "Playlist not found");
|
|
}
|
|
return playlistRowToApi(row);
|
|
}
|
|
|
|
/**
|
|
* Returns the playlist of `userId`'s whose slug is or was `slug`, private or
|
|
* not, or throws a 404, as {@link getPlaylistById} does. Reached by a former
|
|
* slug, it comes with its current one.
|
|
*/
|
|
export function getPlaylistByPath(userId: string, slug: string): Playlist {
|
|
const playlistId = resolveSlug("playlist", userId, slug);
|
|
if (!playlistId) throw new APIException(404, "Playlist not found");
|
|
return getPlaylistById(playlistId);
|
|
}
|
|
|
|
/**
|
|
* Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is
|
|
* public, it notifies the users its description mentions. It links the
|
|
* attachments the description embeds and broadcasts `playlist_created`.
|
|
*/
|
|
export function createPlaylist(
|
|
req: CreatePlaylistRequest,
|
|
userId: string,
|
|
): Playlist {
|
|
const id = crypto.randomUUID();
|
|
const createdAt = new Date();
|
|
const title = req.title.trim();
|
|
transaction(() => {
|
|
db.prepare(
|
|
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
id,
|
|
userId,
|
|
title,
|
|
newSlug("playlist", userId, title),
|
|
req.description ?? null,
|
|
req.isPublic ? 1 : 0,
|
|
createdAt.toISOString(),
|
|
);
|
|
});
|
|
const playlist = getPlaylistById(id);
|
|
if (req.description) {
|
|
// A private playlist's mentions are notified if it goes public.
|
|
if (req.isPublic) {
|
|
notifyMentions(userId, req.description, "playlist", id, title);
|
|
}
|
|
linkAttachments(req.description, "playlist", id);
|
|
}
|
|
broadcastPlaylistCreated(playlist);
|
|
return playlist;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, or it is private
|
|
* and not theirs.
|
|
*/
|
|
export function getPlaylist(
|
|
playlistId: string | Playlist,
|
|
requestingUserId: string | null,
|
|
): PlaylistWithDumps {
|
|
const playlist = typeof playlistId === "string"
|
|
? getPlaylistById(playlistId)
|
|
: playlistId;
|
|
|
|
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
|
|
throw new APIException(404, "Playlist not found");
|
|
}
|
|
|
|
const rows = db.prepare(
|
|
`SELECT ${DUMP_SELECT_COLUMNS_ALIASED}
|
|
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[] = parseRows(rows, isDumpRow, dumpRowToApi);
|
|
const visibleDumps = dumps.filter((d) =>
|
|
isMemberVisibleTo(d, playlist, requestingUserId)
|
|
);
|
|
|
|
return { ...playlist, dumps: visibleDumps };
|
|
}
|
|
|
|
/**
|
|
* Returns up to `limit` playlists whose title or description contains
|
|
* `query`, newest first, for `GET /api/search`. Private playlists are left
|
|
* out, except `requestingUserId`'s own. A blank `query` finds nothing.
|
|
*/
|
|
export function searchPlaylists(
|
|
query: string,
|
|
limit: number,
|
|
requestingUserId?: string,
|
|
): Playlist[] {
|
|
if (!query.trim()) return [];
|
|
const pattern = likePattern(query);
|
|
const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`;
|
|
|
|
const rows = requestingUserId
|
|
? db.prepare(
|
|
`SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
|
).all(requestingUserId, pattern, pattern, limit)
|
|
: db.prepare(
|
|
`SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
|
).all(pattern, pattern, limit);
|
|
|
|
return parseRows(rows, isPlaylistRow, playlistRowToApi);
|
|
}
|
|
|
|
/**
|
|
* Returns a page of `userId`'s playlists, newest first, for
|
|
* `GET /api/users/:key/playlists`. The private ones are listed only when
|
|
* `requestingUserId` is `userId`.
|
|
*/
|
|
export function listPlaylistsByUser(
|
|
userId: string,
|
|
requestingUserId: string | null,
|
|
page: number,
|
|
limit: number,
|
|
): Page<Playlist> {
|
|
const isOwner = requestingUserId === userId;
|
|
const offset = (page - 1) * limit;
|
|
|
|
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 rows = db.prepare(sql).all(userId, limit + 1, offset);
|
|
return pageOf(parseRows(rows, isPlaylistRow, playlistRowToApi), limit);
|
|
}
|
|
|
|
/**
|
|
* Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns
|
|
* it as it now is. A new `slug` goes through `changeSlug`, first, and a
|
|
* moderator is exempt from its rate limit; a new title leaves the slug as it
|
|
* is. `canModerate` lets someone other than the owner edit it. If the
|
|
* playlist is public, it notifies, in the owner's name, the users the
|
|
* description mentions. It links the description's attachments and
|
|
* broadcasts `playlist_updated`.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
|
* is neither its owner nor a moderator, 400 when the slug isn't valid, 409
|
|
* when it is taken, 429 when it changed too often lately.
|
|
*/
|
|
export function updatePlaylist(
|
|
playlistId: string,
|
|
req: UpdatePlaylistRequest,
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): Playlist {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
|
}
|
|
|
|
const newTitle = req.title?.trim() ?? playlist.title;
|
|
const newDescription = "description" in req
|
|
? (req.description ?? null)
|
|
: (playlist.description ?? null);
|
|
const newIsPublic = req.isPublic !== undefined
|
|
? req.isPublic
|
|
: playlist.isPublic;
|
|
|
|
if (req.slug !== undefined) {
|
|
changeSlug("playlist", playlist, req.slug, canModerate);
|
|
}
|
|
|
|
const now = new Date();
|
|
db.prepare(
|
|
`UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
|
|
).run(
|
|
newTitle,
|
|
newDescription,
|
|
newIsPublic ? 1 : 0,
|
|
now.toISOString(),
|
|
playlist.id,
|
|
);
|
|
|
|
const updated: Playlist = {
|
|
...playlist,
|
|
title: newTitle,
|
|
slug: req.slug ?? playlist.slug,
|
|
description: newDescription ?? undefined,
|
|
isPublic: newIsPublic,
|
|
updatedAt: now,
|
|
};
|
|
if (newDescription) {
|
|
if (newIsPublic) {
|
|
notifyMentions(
|
|
playlist.userId,
|
|
newDescription,
|
|
"playlist",
|
|
playlist.id,
|
|
newTitle,
|
|
);
|
|
}
|
|
linkAttachments(newDescription, "playlist", playlist.id);
|
|
}
|
|
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Deletes a playlist for `DELETE /api/playlists/:playlistId`, and broadcasts
|
|
* `playlist_deleted`. `canModerate` lets someone other than the owner do it.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
|
* is neither its owner nor a moderator.
|
|
*/
|
|
export function deletePlaylist(
|
|
playlistId: string,
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
|
}
|
|
|
|
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
|
|
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
|
|
}
|
|
|
|
/**
|
|
* Returns the playlist if `requestingUserId` may edit it. The cover upload
|
|
* route checks this before it writes the file, then calls
|
|
* {@link setPlaylistImage}.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
|
* is neither its owner nor a moderator.
|
|
*/
|
|
export function getEditablePlaylist(
|
|
playlistId: string,
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): Playlist {
|
|
const playlist = getPlaylistById(playlistId);
|
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
|
}
|
|
return playlist;
|
|
}
|
|
|
|
/**
|
|
* Records the type of a playlist's new cover and broadcasts `playlist_updated`.
|
|
* The route must have written the file already, since clients fetch the cover
|
|
* as soon as they get the broadcast.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
|
* is neither its owner nor a moderator.
|
|
*/
|
|
export function setPlaylistImage(
|
|
playlistId: string,
|
|
imageMime: string,
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): Playlist {
|
|
const playlist = getEditablePlaylist(
|
|
playlistId,
|
|
requestingUserId,
|
|
canModerate,
|
|
);
|
|
// updated_at is the `?v=` the client puts on the cover URL, so bumping it
|
|
// keeps browsers from showing the cached old cover.
|
|
db.prepare(
|
|
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
|
|
).run(imageMime, new Date().toISOString(), playlist.id);
|
|
const updated = getPlaylistById(playlist.id);
|
|
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Puts a dump at the top of a playlist, for
|
|
* `POST /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts
|
|
* `playlist_dumps_updated`. The followers of a public playlist are notified
|
|
* when the dump is public.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, or when the dump
|
|
* doesn't exist or is someone else's private dump, 403 when the user is
|
|
* neither the playlist's owner nor a moderator, 409 when the dump is already
|
|
* in it.
|
|
*/
|
|
export function addDumpToPlaylist(
|
|
playlistId: string,
|
|
dumpId: string,
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
|
}
|
|
|
|
// Else someone else's private dump would land in the playlist, and its title
|
|
// in the followers' notifications.
|
|
const dump = db.prepare(
|
|
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
|
|
).get(dumpId) as
|
|
| { title: string; is_private: number; user_id: string }
|
|
| undefined;
|
|
if (!dump || (dump.is_private && dump.user_id !== requestingUserId)) {
|
|
throw new APIException(404, "Dump not found");
|
|
}
|
|
|
|
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(409, "Dump already in playlist");
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
|
|
|
|
// Followers can't open a private dump, even one of the owner's own.
|
|
if (playlist.isPublic && !dump.is_private) {
|
|
notifyPlaylistFollowersNewDump(
|
|
playlist.id,
|
|
playlist.title,
|
|
dumpId,
|
|
dump.title,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes a dump out of a playlist, for
|
|
* `DELETE /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts
|
|
* `playlist_dumps_updated`. A dump that isn't in it is no error.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
|
* is neither its owner nor a moderator.
|
|
*/
|
|
export function removeDumpFromPlaylist(
|
|
playlistId: string,
|
|
dumpId: string,
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
|
}
|
|
|
|
db.prepare(
|
|
`DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`,
|
|
).run(playlist.id, dumpId);
|
|
|
|
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
|
|
}
|
|
|
|
/**
|
|
* Puts the dumps of a playlist that the requester can see in the order of
|
|
* `dumpIds`, for `PUT /api/playlists/:playlistId/order`, and broadcasts
|
|
* `playlist_dumps_updated`.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
|
* is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the
|
|
* members they can see.
|
|
*/
|
|
export function reorderPlaylist(
|
|
playlistId: string,
|
|
dumpIds: string[],
|
|
requestingUserId: string,
|
|
canModerate: boolean,
|
|
): void {
|
|
const playlist = getPlaylistById(playlistId);
|
|
|
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
|
}
|
|
|
|
// The requester can only name the dumps GET showed them. A hidden one
|
|
// (someone else's dump that went private, or the owner's private dump for a
|
|
// moderator) keeps its slot, and the visible ones are reordered around it.
|
|
const members = getCurrentMembers(playlist.id);
|
|
const visibleIds = members
|
|
.filter((m) => isMemberVisibleTo(m, playlist, requestingUserId))
|
|
.map((m) => m.id);
|
|
const newSet = new Set(dumpIds);
|
|
|
|
if (
|
|
dumpIds.length !== newSet.size ||
|
|
visibleIds.length !== newSet.size ||
|
|
!visibleIds.every((id) => newSet.has(id))
|
|
) {
|
|
throw new APIException(
|
|
400,
|
|
"dumpIds must match current playlist members exactly",
|
|
);
|
|
}
|
|
|
|
let next = 0;
|
|
const order = members.map((m) =>
|
|
isMemberVisibleTo(m, playlist, requestingUserId) ? dumpIds[next++] : m.id
|
|
);
|
|
const update = db.prepare(
|
|
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
|
|
);
|
|
transaction(() => {
|
|
for (let i = 0; i < order.length; i++) {
|
|
update.run(i, playlist.id, order[i]);
|
|
}
|
|
});
|
|
|
|
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
|
|
}
|
|
|
|
/**
|
|
* Returns each of `userId`'s playlists, newest first, and whether it holds
|
|
* dump `dumpId`, for `GET /api/me/playlists?contains=`. The client's
|
|
* add-to-playlist modal shows it.
|
|
*
|
|
* @throws {APIException} 500 when a row doesn't parse as a playlist.
|
|
*/
|
|
export function getPlaylistMembershipsForDump(
|
|
dumpId: string,
|
|
userId: string,
|
|
): 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,
|
|
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);
|
|
|
|
return rows.map((row) => {
|
|
if (!isPlaylistRow(row)) {
|
|
throw new APIException(500, "Malformed playlist data");
|
|
}
|
|
return {
|
|
playlist: playlistRowToApi(row),
|
|
hasDump: Boolean(row.has_dump),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns up to 20 playlists that hold dump `dumpId`, newest first, for
|
|
* `GET /api/dumps/:dumpId/playlists`. Private playlists are left out, except
|
|
* `requestingUserId`'s own. The route checks first that the caller may see the
|
|
* dump.
|
|
*/
|
|
export function getPlaylistsForDump(
|
|
dumpId: string,
|
|
requestingUserId?: string,
|
|
): Playlist[] {
|
|
const rows = db.prepare(
|
|
`SELECT ${PLAYLIST_SELECT}
|
|
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 ?? "");
|
|
|
|
return parseRows(rows, isPlaylistRow, playlistRowToApi);
|
|
}
|
|
|
|
/**
|
|
* Returns what `GET /api/playlists/:playlistId/cover` needs to serve a
|
|
* playlist's cover, or undefined when it has none. The route serves a private
|
|
* playlist's cover as private, so no shared cache keeps it.
|
|
*
|
|
* @throws {APIException} 404 when there is no such playlist, or it is private
|
|
* and `requestingUserId` isn't its owner.
|
|
*/
|
|
export function getPlaylistImageInfo(
|
|
playlistId: string,
|
|
requestingUserId: string | null,
|
|
): { id: string; imageMime: string; isPublic: boolean } | undefined {
|
|
const playlist = getPlaylistById(playlistId);
|
|
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
|
|
throw new APIException(404, "Playlist not found");
|
|
}
|
|
return playlist.imageMime
|
|
? {
|
|
id: playlist.id,
|
|
imageMime: playlist.imageMime,
|
|
isPublic: playlist.isPublic,
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns a playlist's dumps in order, all of them, for the callers to filter
|
|
* per viewer.
|
|
*/
|
|
function getCurrentMembers(playlistId: string): PlaylistMember[] {
|
|
const rows = db.prepare(
|
|
`SELECT pd.dump_id, d.is_private, d.user_id
|
|
FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
|
|
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
|
|
).all(playlistId) as Array<
|
|
{ dump_id: string; is_private: number; user_id: string }
|
|
>;
|
|
return rows.map((r) => ({
|
|
id: r.dump_id,
|
|
isPrivate: Boolean(r.is_private),
|
|
userId: r.user_id,
|
|
}));
|
|
}
|