tubearr/src/db/repositories/content-repository.ts
jlightner c057b6a286 feat(S01+S04): server-side pagination, search/filter, download engine hardening
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
2026-04-03 02:29:49 +00:00

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,
};
}