v3: added site-wide categories, added admin category management, various visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
This commit is contained in:
269
api/services/category-service.ts
Normal file
269
api/services/category-service.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type Category,
|
||||
type CreateCategoryRequest,
|
||||
type Dump,
|
||||
type UpdateCategoryRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
categoryRowToApi,
|
||||
db,
|
||||
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
||||
dumpRowToApi,
|
||||
isCategoryRow,
|
||||
isDumpRow,
|
||||
} from "../model/db.ts";
|
||||
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
||||
import { VALIDATION } from "../config.ts";
|
||||
|
||||
function validateSlug(slug: string): void {
|
||||
if (!isValidSlugFormat(slug)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Slug must be lowercase letters, numbers and hyphens",
|
||||
);
|
||||
}
|
||||
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
|
||||
);
|
||||
}
|
||||
if (isReservedSlug(slug)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`Slug "${slug}" is reserved`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Name is required");
|
||||
}
|
||||
if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`,
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
|
||||
function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("categories.name")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
`Name "${name}" is already in use`,
|
||||
);
|
||||
}
|
||||
if (msg.includes("UNIQUE") || msg.includes("unique")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
`Slug "${slug}" is already in use`,
|
||||
);
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
|
||||
export function getCategoryById(id: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
|
||||
}
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
export function getCategoryBySlug(slug: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
|
||||
}
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/** Resolve a slug to its category id, or undefined if no such category. */
|
||||
export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
|
||||
return row && typeof (row as { id?: unknown }).id === "string"
|
||||
? (row as { id: string }).id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function listCategories(): Category[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
||||
).all();
|
||||
return rows.filter(isCategoryRow).map(categoryRowToApi);
|
||||
}
|
||||
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateSlug(req.slug);
|
||||
const name = requireName(req.name);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO categories (id, slug, name, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
req.slug,
|
||||
name,
|
||||
req.position ?? 0,
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
} catch (err) {
|
||||
uniqueConflict(err, req.slug, name);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
slug: req.slug,
|
||||
name,
|
||||
position: req.position ?? 0,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
id: string,
|
||||
req: UpdateCategoryRequest,
|
||||
): Category {
|
||||
const existing = getCategoryById(id);
|
||||
|
||||
const newSlug = req.slug ?? existing.slug;
|
||||
if (req.slug !== undefined && req.slug !== existing.slug) {
|
||||
validateSlug(req.slug);
|
||||
}
|
||||
const newName = req.name !== undefined ? requireName(req.name) : existing.name;
|
||||
const newPosition = req.position ?? existing.position;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`UPDATE categories SET slug = ?, name = ?, position = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(
|
||||
newSlug,
|
||||
newName,
|
||||
newPosition,
|
||||
now.toISOString(),
|
||||
id,
|
||||
);
|
||||
} catch (err) {
|
||||
uniqueConflict(err, newSlug, newName);
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
slug: newSlug,
|
||||
name: newName,
|
||||
position: newPosition,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string): void {
|
||||
const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id);
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the set of categories a dump belongs to. Unknown category ids are
|
||||
* rejected. An empty list clears all category assignments.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
categoryIds: string[],
|
||||
): void {
|
||||
const unique = [...new Set(categoryIds)];
|
||||
|
||||
if (unique.length > 0) {
|
||||
const placeholders = unique.map(() => "?").join(", ");
|
||||
const found = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`,
|
||||
).get(...unique) as { count: number } | undefined;
|
||||
if ((found?.count ?? 0) !== unique.length) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"One or more categories do not exist",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
);
|
||||
for (const categoryId of unique) {
|
||||
insert.run(dumpId, categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill `categoryIds` on each dump with a single batched lookup. */
|
||||
export function attachCategoryIds(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
const rows = db.prepare(
|
||||
`SELECT dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`,
|
||||
).all(...ids) as Array<{ dump_id: string; category_id: string }>;
|
||||
|
||||
const byDump = new Map<string, string[]>();
|
||||
for (const { dump_id, category_id } of rows) {
|
||||
const list = byDump.get(dump_id) ?? [];
|
||||
list.push(category_id);
|
||||
byDump.set(dump_id, list);
|
||||
}
|
||||
for (const dump of dumps) {
|
||||
dump.categoryIds = byDump.get(dump.id) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listDumpsByCategory(
|
||||
slug: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
const category = getCategoryBySlug(slug);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const privacyClause = requestingUserId
|
||||
? "(d.is_private = 0 OR d.user_id = ?)"
|
||||
: "d.is_private = 0";
|
||||
const privacyParams = requestingUserId ? [requestingUserId] : [];
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
FROM dumps d
|
||||
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
||||
WHERE dc.category_id = ? AND ${privacyClause}
|
||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(category.id, ...privacyParams, limit, offset);
|
||||
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM dumps d
|
||||
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
||||
WHERE dc.category_id = ? AND ${privacyClause};`,
|
||||
).get(category.id, ...privacyParams) as { count: number } | undefined;
|
||||
|
||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "../config.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { relinkBacklinks } from "./backlink-service.ts";
|
||||
import { attachCategoryIds, setDumpCategories } from "./category-service.ts";
|
||||
|
||||
function isAllowedMime(mime: string): boolean {
|
||||
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||
@@ -78,6 +79,9 @@ export async function createUrlDump(
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
|
||||
const categoryIds = [...new Set(request.categoryIds ?? [])];
|
||||
setDumpCategories(dumpId, categoryIds);
|
||||
|
||||
const dump: Dump = {
|
||||
id: dumpId,
|
||||
kind: "url",
|
||||
@@ -91,6 +95,7 @@ export async function createUrlDump(
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
isPrivate,
|
||||
categoryIds,
|
||||
};
|
||||
if (!isPrivate) {
|
||||
broadcastNewDump(dump);
|
||||
@@ -110,6 +115,7 @@ export async function createFileDump(
|
||||
userId: string,
|
||||
isPrivate = false,
|
||||
title?: string,
|
||||
categoryIds: string[] = [],
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
@@ -130,6 +136,7 @@ export async function createFileDump(
|
||||
const createdAt = new Date();
|
||||
const finalTitle = title?.trim() || file.name;
|
||||
const slug = makeSlug(finalTitle, dumpId);
|
||||
const dedupedCategoryIds = [...new Set(categoryIds)];
|
||||
|
||||
await Deno.mkdir(DUMPS_DIR, { recursive: true });
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
@@ -153,6 +160,7 @@ export async function createFileDump(
|
||||
file.size,
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
setDumpCategories(dumpId, dedupedCategoryIds);
|
||||
} catch (err) {
|
||||
// Roll back the file if DB insert fails
|
||||
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
|
||||
@@ -173,6 +181,7 @@ export async function createFileDump(
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
isPrivate,
|
||||
categoryIds: dedupedCategoryIds,
|
||||
};
|
||||
if (!isPrivate) {
|
||||
broadcastNewDump(dump);
|
||||
@@ -205,6 +214,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
if (dump.isPrivate && dump.userId !== requestingUserId) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
attachCategoryIds([dump]);
|
||||
return dump;
|
||||
}
|
||||
|
||||
@@ -229,10 +239,9 @@ export function searchDumps(
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
|
||||
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
|
||||
return {
|
||||
items: rows.filter(isDumpRow).map(dumpRowToApi),
|
||||
total: totalRow?.count ?? 0,
|
||||
};
|
||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
} else {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
@@ -240,10 +249,9 @@ export function searchDumps(
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
|
||||
).get(...searchParams) as { count: number } | undefined;
|
||||
return {
|
||||
items: rows.filter(isDumpRow).map(dumpRowToApi),
|
||||
total: totalRow?.count ?? 0,
|
||||
};
|
||||
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +285,9 @@ export function listDumps(
|
||||
);
|
||||
}
|
||||
|
||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
export async function updateDump(
|
||||
@@ -314,6 +324,13 @@ export async function updateDump(
|
||||
now.toISOString(),
|
||||
dumpId,
|
||||
);
|
||||
if (request.categoryIds !== undefined) {
|
||||
const ids = [...new Set(request.categoryIds)];
|
||||
setDumpCategories(dumpId, ids);
|
||||
updatedDump.categoryIds = ids;
|
||||
} else {
|
||||
attachCategoryIds([updatedDump]);
|
||||
}
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
if (updatedDump.comment) {
|
||||
@@ -382,6 +399,14 @@ export async function updateDump(
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
if (request.categoryIds !== undefined) {
|
||||
const ids = [...new Set(request.categoryIds)];
|
||||
setDumpCategories(dumpId, ids);
|
||||
updatedDump.categoryIds = ids;
|
||||
} else {
|
||||
attachCategoryIds([updatedDump]);
|
||||
}
|
||||
|
||||
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
if (updatedDump.comment) {
|
||||
@@ -403,6 +428,7 @@ export async function replaceFileDump(
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
title?: string,
|
||||
categoryIds?: string[],
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
@@ -455,6 +481,14 @@ export async function replaceFileDump(
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
const ids = [...new Set(categoryIds)];
|
||||
setDumpCategories(dumpId, ids);
|
||||
dump.categoryIds = ids;
|
||||
} else {
|
||||
attachCategoryIds([dump]);
|
||||
}
|
||||
|
||||
if (comment) {
|
||||
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
|
||||
linkAttachments(comment, dumpId);
|
||||
@@ -493,7 +527,9 @@ export function getDumpsByUser(
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
export function getVotedDumpsByUser(
|
||||
@@ -544,7 +580,9 @@ export function getVotedDumpsByUser(
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
playlistRowToApi,
|
||||
userRowToApi,
|
||||
} from "../model/db.ts";
|
||||
import { attachCategoryIds } from "./category-service.ts";
|
||||
|
||||
// ── Follow / unfollow a user ──────────────────────────────────────────────────
|
||||
|
||||
@@ -139,26 +140,35 @@ export function getFollowedUsersDumpFeed(
|
||||
followerId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
categoryId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Optional category filter: join the membership table and constrain to it.
|
||||
const catJoin = categoryId
|
||||
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
|
||||
: "";
|
||||
const catParams = categoryId ? [categoryId] : [];
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
FROM dumps d
|
||||
INNER JOIN follows f ON f.followed_user_id = d.user_id
|
||||
${catJoin}
|
||||
WHERE f.follower_id = ?
|
||||
AND d.is_private = 0
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT ? OFFSET ?;`,
|
||||
).all(followerId, limit, offset);
|
||||
).all(...catParams, followerId, limit, offset);
|
||||
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM dumps d
|
||||
INNER JOIN follows f ON f.followed_user_id = d.user_id
|
||||
${catJoin}
|
||||
WHERE f.follower_id = ?
|
||||
AND d.is_private = 0;`,
|
||||
).get(followerId) as { count: number } | undefined;
|
||||
).get(...catParams, followerId) as { count: number } | undefined;
|
||||
|
||||
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||
if (!userFeedRows.every(isDumpRow)) {
|
||||
@@ -168,7 +178,9 @@ export function getFollowedUsersDumpFeed(
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return { items: userFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
const items = userFeedRows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
// ── Followed-playlists dump feed ──────────────────────────────────────────────
|
||||
@@ -177,22 +189,29 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
followerId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
categoryId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const catJoin = categoryId
|
||||
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
|
||||
: "";
|
||||
const catParams = categoryId ? [categoryId] : [];
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
FROM dumps d
|
||||
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
|
||||
INNER JOIN playlists p ON p.id = pd.playlist_id
|
||||
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||
${catJoin}
|
||||
WHERE f.follower_id = ?
|
||||
AND p.is_public = 1
|
||||
AND d.is_private = 0
|
||||
GROUP BY d.id
|
||||
ORDER BY MAX(pd.added_at) DESC
|
||||
LIMIT ? OFFSET ?;`,
|
||||
).all(followerId, limit, offset);
|
||||
).all(...catParams, followerId, limit, offset);
|
||||
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(DISTINCT d.id) as count
|
||||
@@ -200,10 +219,11 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
|
||||
INNER JOIN playlists p ON p.id = pd.playlist_id
|
||||
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||
${catJoin}
|
||||
WHERE f.follower_id = ?
|
||||
AND p.is_public = 1
|
||||
AND d.is_private = 0;`,
|
||||
).get(followerId) as { count: number } | undefined;
|
||||
).get(...catParams, followerId) as { count: number } | undefined;
|
||||
|
||||
if (!rawRows.every(isDumpRow)) {
|
||||
throw new APIException(
|
||||
@@ -212,10 +232,9 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return {
|
||||
items: rawRows.map(dumpRowToApi),
|
||||
total: totalRow?.count ?? 0,
|
||||
};
|
||||
const items = rawRows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
// ── Followed playlists (as playlist objects) ──────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user