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; // ── 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 { // 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 { 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 { 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`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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const conditions = buildContentFilterConditions(filters); const offset = (page - 1) * pageSize; // Count total matching records const countResult = await db .select({ count: sql`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> { if (channelIds.length === 0) return new Map(); const rows = await db .select({ channelId: contentItems.channelId, total: sql`count(*)`, monitored: sql`sum(case when ${contentItems.monitored} = 1 then 1 else 0 end)`, downloaded: sql`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(); 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 { 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, }; }