S01 — Server-Side Pagination: - Added getChannelContentPaginated() to content repository with search, filter, sort - Channel content API now supports ?page, ?pageSize, ?search, ?status, ?contentType, ?sortBy, ?sortDirection - Backwards-compatible: no params returns all items (legacy mode) - Frontend useChannelContentPaginated hook with keepPreviousData - ChannelDetail page: search bar, status/type filter dropdowns, pagination controls - Sorting delegated to server (removed client-side sortedContent) - Item count shown in Content header (e.g. '121 items') S04 — Download Engine Hardening: - yt-dlp auto-update on production startup (native -U with pip fallback) - Error classification: rate_limit, format_unavailable, geo_blocked, age_restricted, private, network - Format fallback chains: preferred res → best under res → single best → any - Improved parseFinalPath: explicit non-path prefix detection, extension validation - Error category included in download:failed events - classifyYtDlpError() exported from yt-dlp module for downstream use
468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
import { eq, and, desc, like, sql, inArray, notInArray } from 'drizzle-orm';
|
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
|
import type * as schema from '../schema/index';
|
|
import { contentItems } from '../schema/index';
|
|
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
|
|
import type { ContentCounts } from '../../types/api';
|
|
|
|
// ── Types ──
|
|
|
|
/** Fields needed to create a new content item. */
|
|
export interface CreateContentItemData {
|
|
channelId: number;
|
|
title: string;
|
|
platformContentId: string;
|
|
url: string;
|
|
contentType: ContentType;
|
|
duration: number | null;
|
|
thumbnailUrl?: string | null;
|
|
status?: ContentStatus;
|
|
publishedAt?: string | null;
|
|
monitored?: boolean;
|
|
}
|
|
|
|
/** Fields that can be updated on an existing content item (post-download). */
|
|
export interface UpdateContentItemData {
|
|
filePath?: string | null;
|
|
fileSize?: number | null;
|
|
format?: string | null;
|
|
qualityMetadata?: QualityInfo | null;
|
|
status?: ContentStatus;
|
|
downloadedAt?: string | null;
|
|
}
|
|
|
|
type Db = LibSQLDatabase<typeof schema>;
|
|
|
|
// ── Repository Functions ──
|
|
|
|
/**
|
|
* Insert a new content item with dedup on (channelId, platformContentId).
|
|
* Returns the created row, or null if the item already exists.
|
|
*/
|
|
export async function createContentItem(
|
|
db: Db,
|
|
data: CreateContentItemData
|
|
): Promise<ContentItem | null> {
|
|
// Check for existing item first — dedup by (channelId, platformContentId)
|
|
const existing = await db
|
|
.select({ id: contentItems.id })
|
|
.from(contentItems)
|
|
.where(
|
|
and(
|
|
eq(contentItems.channelId, data.channelId),
|
|
eq(contentItems.platformContentId, data.platformContentId)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
return null; // Already exists — skip
|
|
}
|
|
|
|
const result = await db
|
|
.insert(contentItems)
|
|
.values({
|
|
channelId: data.channelId,
|
|
title: data.title,
|
|
platformContentId: data.platformContentId,
|
|
url: data.url,
|
|
contentType: data.contentType,
|
|
duration: data.duration,
|
|
thumbnailUrl: data.thumbnailUrl ?? null,
|
|
status: data.status ?? 'monitored',
|
|
publishedAt: data.publishedAt ?? null,
|
|
monitored: data.monitored ?? true,
|
|
})
|
|
.returning();
|
|
|
|
return mapRow(result[0]);
|
|
}
|
|
|
|
/** Get all content items for a channel, ordered by creation date (newest first). */
|
|
export async function getContentByChannelId(
|
|
db: Db,
|
|
channelId: number
|
|
): Promise<ContentItem[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(eq(contentItems.channelId, channelId))
|
|
.orderBy(desc(contentItems.createdAt));
|
|
|
|
return rows.map(mapRow);
|
|
}
|
|
|
|
/** Optional filters for channel content queries. */
|
|
export interface ChannelContentFilters {
|
|
search?: string;
|
|
status?: ContentStatus;
|
|
contentType?: ContentType;
|
|
sortBy?: 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt';
|
|
sortDirection?: 'asc' | 'desc';
|
|
}
|
|
|
|
/**
|
|
* Get paginated content items for a channel with optional search, filter, and sort.
|
|
* Returns items and total count for pagination.
|
|
*/
|
|
export async function getChannelContentPaginated(
|
|
db: Db,
|
|
channelId: number,
|
|
filters?: ChannelContentFilters,
|
|
page = 1,
|
|
pageSize = 50
|
|
): Promise<PaginatedContentResult> {
|
|
const conditions = [eq(contentItems.channelId, channelId)];
|
|
|
|
if (filters?.search) {
|
|
conditions.push(like(contentItems.title, `%${filters.search}%`));
|
|
}
|
|
if (filters?.status) {
|
|
conditions.push(eq(contentItems.status, filters.status));
|
|
}
|
|
if (filters?.contentType) {
|
|
conditions.push(eq(contentItems.contentType, filters.contentType));
|
|
}
|
|
|
|
const whereClause = and(...conditions);
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
// Count total matching records
|
|
const countResult = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(contentItems)
|
|
.where(whereClause);
|
|
|
|
const total = Number(countResult[0].count);
|
|
|
|
// Build sort order
|
|
const sortCol = resolveSortColumn(filters?.sortBy);
|
|
const sortDir = filters?.sortDirection === 'asc' ? sortCol : desc(sortCol);
|
|
|
|
// Fetch paginated results
|
|
const rows = await db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(whereClause)
|
|
.orderBy(sortDir, desc(contentItems.id))
|
|
.limit(pageSize)
|
|
.offset(offset);
|
|
|
|
return {
|
|
items: rows.map(mapRow),
|
|
total,
|
|
};
|
|
}
|
|
|
|
/** Resolve sort column name to Drizzle column reference. */
|
|
function resolveSortColumn(sortBy?: string) {
|
|
switch (sortBy) {
|
|
case 'title': return contentItems.title;
|
|
case 'publishedAt': return contentItems.publishedAt;
|
|
case 'status': return contentItems.status;
|
|
case 'duration': return contentItems.duration;
|
|
case 'fileSize': return contentItems.fileSize;
|
|
case 'downloadedAt': return contentItems.downloadedAt;
|
|
default: return contentItems.createdAt;
|
|
}
|
|
}
|
|
|
|
/** Check if a specific content item exists for a channel. Returns the item or null. */
|
|
export async function getContentByPlatformContentId(
|
|
db: Db,
|
|
channelId: number,
|
|
platformContentId: string
|
|
): Promise<ContentItem | null> {
|
|
const rows = await db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(
|
|
and(
|
|
eq(contentItems.channelId, channelId),
|
|
eq(contentItems.platformContentId, platformContentId)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
return rows.length > 0 ? mapRow(rows[0]) : null;
|
|
}
|
|
|
|
/**
|
|
* Get recent platformContentIds for a channel (for fast dedup checking during monitoring).
|
|
* Returns just the IDs, not full rows, to minimize memory usage.
|
|
*/
|
|
export async function getRecentContentIds(
|
|
db: Db,
|
|
channelId: number,
|
|
limit = 200
|
|
): Promise<string[]> {
|
|
const rows = await db
|
|
.select({ platformContentId: contentItems.platformContentId })
|
|
.from(contentItems)
|
|
.where(eq(contentItems.channelId, channelId))
|
|
.orderBy(desc(contentItems.createdAt))
|
|
.limit(limit);
|
|
|
|
return rows.map((r) => r.platformContentId);
|
|
}
|
|
|
|
// ── Single-Item Access ──
|
|
|
|
/** Get a content item by ID. Returns null if not found. */
|
|
export async function getContentItemById(
|
|
db: Db,
|
|
id: number
|
|
): Promise<ContentItem | null> {
|
|
const rows = await db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(eq(contentItems.id, id))
|
|
.limit(1);
|
|
|
|
return rows.length > 0 ? mapRow(rows[0]) : null;
|
|
}
|
|
|
|
/**
|
|
* Update a content item with partial data. Sets updatedAt to now.
|
|
* Returns updated item or null if not found.
|
|
*/
|
|
export async function updateContentItem(
|
|
db: Db,
|
|
id: number,
|
|
data: UpdateContentItemData
|
|
): Promise<ContentItem | null> {
|
|
const result = await db
|
|
.update(contentItems)
|
|
.set({
|
|
...data,
|
|
updatedAt: sql`(datetime('now'))`,
|
|
})
|
|
.where(eq(contentItems.id, id))
|
|
.returning();
|
|
|
|
return result.length > 0 ? mapRow(result[0]) : null;
|
|
}
|
|
|
|
/**
|
|
* Set the `monitored` flag on a single content item.
|
|
* Returns updated item or null if not found.
|
|
*/
|
|
export async function setMonitored(
|
|
db: Db,
|
|
id: number,
|
|
monitored: boolean
|
|
): Promise<ContentItem | null> {
|
|
const result = await db
|
|
.update(contentItems)
|
|
.set({
|
|
monitored,
|
|
updatedAt: sql`(datetime('now'))`,
|
|
})
|
|
.where(eq(contentItems.id, id))
|
|
.returning();
|
|
|
|
return result.length > 0 ? mapRow(result[0]) : null;
|
|
}
|
|
|
|
/**
|
|
* Set the `monitored` flag on multiple content items at once.
|
|
* Returns the number of items actually updated.
|
|
*/
|
|
export async function bulkSetMonitored(
|
|
db: Db,
|
|
ids: number[],
|
|
monitored: boolean
|
|
): Promise<number> {
|
|
if (ids.length === 0) return 0;
|
|
|
|
const result = await db
|
|
.update(contentItems)
|
|
.set({
|
|
monitored,
|
|
updatedAt: sql`(datetime('now'))`,
|
|
})
|
|
.where(inArray(contentItems.id, ids))
|
|
.returning({ id: contentItems.id });
|
|
|
|
return result.length;
|
|
}
|
|
|
|
/** Get content items by status, ordered by creation date (oldest first). */
|
|
export async function getContentItemsByStatus(
|
|
db: Db,
|
|
status: ContentStatus,
|
|
limit?: number
|
|
): Promise<ContentItem[]> {
|
|
let query = db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(eq(contentItems.status, status))
|
|
.orderBy(contentItems.createdAt);
|
|
|
|
if (limit !== undefined) {
|
|
query = query.limit(limit) as typeof query;
|
|
}
|
|
|
|
const rows = await query;
|
|
return rows.map(mapRow);
|
|
}
|
|
|
|
// ── Paginated Listing ──
|
|
|
|
/** Optional filters for querying content items. */
|
|
export interface ContentItemFilters {
|
|
status?: ContentStatus;
|
|
contentType?: ContentType;
|
|
channelId?: number;
|
|
search?: string;
|
|
}
|
|
|
|
/** Paginated result of content items. */
|
|
export interface PaginatedContentResult {
|
|
items: ContentItem[];
|
|
total: number;
|
|
}
|
|
|
|
/**
|
|
* Get content items with optional filters and pagination.
|
|
* Returns items ordered by id DESC (newest first) and total count for pagination.
|
|
* Uses id DESC as tiebreaker per K001 (datetime granularity).
|
|
*/
|
|
export async function getAllContentItems(
|
|
db: Db,
|
|
filters?: ContentItemFilters,
|
|
page = 1,
|
|
pageSize = 25
|
|
): Promise<PaginatedContentResult> {
|
|
const conditions = buildContentFilterConditions(filters);
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
// Count total matching records
|
|
const countResult = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(contentItems)
|
|
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
|
|
|
const total = Number(countResult[0].count);
|
|
|
|
// Fetch paginated results
|
|
const rows = await db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
.orderBy(desc(contentItems.id))
|
|
.limit(pageSize)
|
|
.offset(offset);
|
|
|
|
return {
|
|
items: rows.map(mapRow),
|
|
total,
|
|
};
|
|
}
|
|
|
|
function buildContentFilterConditions(filters?: ContentItemFilters) {
|
|
const conditions = [];
|
|
|
|
if (filters?.status) {
|
|
conditions.push(eq(contentItems.status, filters.status));
|
|
}
|
|
if (filters?.contentType) {
|
|
conditions.push(eq(contentItems.contentType, filters.contentType));
|
|
}
|
|
if (filters?.channelId !== undefined) {
|
|
conditions.push(eq(contentItems.channelId, filters.channelId));
|
|
}
|
|
if (filters?.search) {
|
|
conditions.push(like(contentItems.title, `%${filters.search}%`));
|
|
}
|
|
|
|
return conditions;
|
|
}
|
|
|
|
// ── Content Counts by Channel ──
|
|
|
|
/**
|
|
* Aggregate content counts (total, monitored, downloaded) grouped by channel ID.
|
|
* Returns a Map so callers can merge counts into channel objects efficiently.
|
|
*/
|
|
export async function getContentCountsByChannelIds(
|
|
db: Db,
|
|
channelIds: number[]
|
|
): Promise<Map<number, ContentCounts>> {
|
|
if (channelIds.length === 0) return new Map();
|
|
|
|
const rows = await db
|
|
.select({
|
|
channelId: contentItems.channelId,
|
|
total: sql<number>`count(*)`,
|
|
monitored: sql<number>`sum(case when ${contentItems.monitored} = 1 then 1 else 0 end)`,
|
|
downloaded: sql<number>`sum(case when ${contentItems.status} = 'downloaded' then 1 else 0 end)`,
|
|
})
|
|
.from(contentItems)
|
|
.where(inArray(contentItems.channelId, channelIds))
|
|
.groupBy(contentItems.channelId);
|
|
|
|
const map = new Map<number, ContentCounts>();
|
|
for (const row of rows) {
|
|
map.set(row.channelId, {
|
|
total: Number(row.total),
|
|
monitored: Number(row.monitored),
|
|
downloaded: Number(row.downloaded),
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ── Collectible Items ──
|
|
|
|
/**
|
|
* Get content items eligible for collection (download enqueueing).
|
|
* Returns items where `monitored = true` AND status is NOT 'downloaded',
|
|
* 'queued', or 'downloading'. Optionally filtered by channelId.
|
|
*/
|
|
export async function getCollectibleItems(
|
|
db: Db,
|
|
channelId?: number
|
|
): Promise<ContentItem[]> {
|
|
const conditions = [
|
|
eq(contentItems.monitored, true),
|
|
notInArray(contentItems.status, ['downloaded', 'queued', 'downloading']),
|
|
];
|
|
|
|
if (channelId !== undefined) {
|
|
conditions.push(eq(contentItems.channelId, channelId));
|
|
}
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(and(...conditions))
|
|
.orderBy(contentItems.id);
|
|
|
|
return rows.map(mapRow);
|
|
}
|
|
|
|
// ── Row Mapping ──
|
|
|
|
function mapRow(row: typeof contentItems.$inferSelect): ContentItem {
|
|
return {
|
|
id: row.id,
|
|
channelId: row.channelId,
|
|
title: row.title,
|
|
platformContentId: row.platformContentId,
|
|
url: row.url,
|
|
contentType: row.contentType as ContentType,
|
|
duration: row.duration,
|
|
filePath: row.filePath,
|
|
fileSize: row.fileSize,
|
|
format: row.format,
|
|
qualityMetadata: row.qualityMetadata as ContentItem['qualityMetadata'],
|
|
status: row.status as ContentStatus,
|
|
thumbnailUrl: row.thumbnailUrl ?? null,
|
|
publishedAt: row.publishedAt ?? null,
|
|
downloadedAt: row.downloadedAt ?? null,
|
|
monitored: row.monitored,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|