diff --git a/docker-compose.yml b/docker-compose.yml index 4fd08fe..fa093b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: ports: - "8989:8989" volumes: - - ./config:/config + - tubearr-config:/config - ./media:/media environment: - NODE_ENV=production @@ -18,3 +18,6 @@ services: retries: 3 start_period: 15s restart: unless-stopped + +volumes: + tubearr-config: diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index 260946e..346fa69 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -92,6 +92,81 @@ export async function getContentByChannelId( 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, diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index d9f0a4e..e11739b 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -1,8 +1,8 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { apiClient } from '../client'; import { queueKeys } from './useQueue'; import type { ContentItem } from '@shared/types/index'; -import type { ApiResponse } from '@shared/types/api'; +import type { ApiResponse, PaginatedResponse } from '@shared/types/api'; // ── Collect Types ── @@ -13,15 +13,29 @@ export interface CollectResult { items: Array<{ contentItemId: number; status: string }>; } +// ── Channel Content Filter Types ── + +export interface ChannelContentFilters { + page?: number; + pageSize?: number; + search?: string; + status?: string; + contentType?: string; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; +} + // ── Query Keys ── export const contentKeys = { byChannel: (channelId: number) => ['content', 'channel', channelId] as const, + byChannelPaginated: (channelId: number, filters: ChannelContentFilters) => + ['content', 'channel', channelId, 'paginated', filters] as const, }; // ── Queries ── -/** Fetch content items for a specific channel. */ +/** Fetch content items for a specific channel (legacy — all items). */ export function useChannelContent(channelId: number) { return useQuery({ queryKey: contentKeys.byChannel(channelId), @@ -35,6 +49,30 @@ export function useChannelContent(channelId: number) { }); } +/** Fetch paginated content items for a channel with search/filter/sort. */ +export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) { + return useQuery({ + queryKey: contentKeys.byChannelPaginated(channelId, filters), + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.page) params.set('page', String(filters.page)); + if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); + if (filters.search) params.set('search', filters.search); + if (filters.status) params.set('status', filters.status); + if (filters.contentType) params.set('contentType', filters.contentType); + if (filters.sortBy) params.set('sortBy', filters.sortBy); + if (filters.sortDirection) params.set('sortDirection', filters.sortDirection); + + const response = await apiClient.get>( + `/api/v1/channel/${channelId}/content?${params.toString()}`, + ); + return response; + }, + enabled: channelId > 0, + placeholderData: keepPreviousData, + }); +} + // ── Mutations ── /** Enqueue a content item for download. Returns 202 with queue item. */ diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 6c85291..406e3e1 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -19,7 +19,7 @@ import { Trash2, } from 'lucide-react'; import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; -import { useChannelContent, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored } from '../api/hooks/useContent'; +import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; import { apiClient } from '../api/client'; import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; @@ -28,6 +28,7 @@ import { PlatformBadge } from '../components/PlatformBadge'; import { StatusBadge } from '../components/StatusBadge'; import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; +import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; import type { ContentItem, MonitoringMode } from '@shared/types/index'; @@ -96,10 +97,31 @@ export function ChannelDetail() { // ── Data hooks ── const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId); - const { data: content, isLoading: contentLoading, error: contentError, refetch: refetchContent } = useChannelContent(channelId); const { data: formatProfiles } = useFormatProfiles(); const { data: playlistData } = useChannelPlaylists(channelId); + // ── Content pagination state ── + const [contentPage, setContentPage] = useState(1); + const [contentSearch, setContentSearch] = useState(''); + const [contentStatusFilter, setContentStatusFilter] = useState(''); + const [contentTypeFilter, setContentTypeFilter] = useState(''); + const [sortKey, setSortKey] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const contentFilters: ChannelContentFilters = useMemo(() => ({ + page: contentPage, + pageSize: 50, + search: contentSearch || undefined, + status: contentStatusFilter || undefined, + contentType: contentTypeFilter || undefined, + sortBy: sortKey ?? undefined, + sortDirection: sortDirection, + }), [contentPage, contentSearch, contentStatusFilter, contentTypeFilter, sortKey, sortDirection]); + + const { data: contentResponse, isLoading: contentLoading, error: contentError, refetch: refetchContent } = useChannelContentPaginated(channelId, contentFilters); + const content = contentResponse?.data ?? []; + const contentPagination = contentResponse?.pagination; + // ── Mutation hooks ── const updateChannel = useUpdateChannel(channelId); const deleteChannel = useDeleteChannel(); @@ -115,8 +137,6 @@ export function ChannelDetail() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null); const [scanInProgress, setScanInProgress] = useState(false); - const [sortKey, setSortKey] = useState(null); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); @@ -256,6 +276,7 @@ export function ChannelDetail() { const handleSort = useCallback((key: string, direction: 'asc' | 'desc') => { setSortKey(key); setSortDirection(direction); + setContentPage(1); }, []); const togglePlaylist = useCallback((id: number | 'uncategorized') => { @@ -316,55 +337,10 @@ export function ChannelDetail() { clearSelection(); }, [selectedIds, downloadContent, clearSelection]); - // ── Sorted content ── + // ── Sorted content (server-side — just use content directly) ── - const sortedContent = useMemo(() => { - const items = content ?? []; - if (!sortKey) return items; - const sorted = [...items]; - sorted.sort((a, b) => { - let cmp = 0; - switch (sortKey) { - case 'title': - cmp = a.title.localeCompare(b.title); - break; - case 'publishedAt': { - const aDate = a.publishedAt ? Date.parse(a.publishedAt) : -Infinity; - const bDate = b.publishedAt ? Date.parse(b.publishedAt) : -Infinity; - cmp = aDate - bDate; - break; - } - case 'status': - cmp = a.status.localeCompare(b.status); - break; - case 'duration': { - const aDur = a.duration ?? -Infinity; - const bDur = b.duration ?? -Infinity; - cmp = aDur - bDur; - break; - } - case 'fileSize': - cmp = (a.fileSize ?? -Infinity) - (b.fileSize ?? -Infinity); - break; - case 'downloadedAt': { - const aDate2 = a.downloadedAt ? Date.parse(a.downloadedAt) : -Infinity; - const bDate2 = b.downloadedAt ? Date.parse(b.downloadedAt) : -Infinity; - cmp = aDate2 - bDate2; - break; - } - case 'quality': { - const aQ = a.qualityMetadata?.actualResolution ?? ''; - const bQ = b.qualityMetadata?.actualResolution ?? ''; - cmp = aQ.localeCompare(bQ); - break; - } - default: - return 0; - } - return sortDirection === 'desc' ? -cmp : cmp; - }); - return sorted; - }, [content, sortKey, sortDirection]); + // Sort is handled server-side via contentFilters.sortBy/sortDirection. + // playlistGroups still needs client-side grouping for YouTube channels. // ── Playlist grouping (YouTube only) ── @@ -379,7 +355,7 @@ export function ChannelDetail() { // Build a Map from content ID to content item for O(1) lookups (js-index-maps) const contentById = new Map(); - for (const item of sortedContent) { + for (const item of content) { contentById.set(item.id, item); } @@ -399,13 +375,13 @@ export function ChannelDetail() { } // Uncategorized: items not in any playlist - const uncategorized = sortedContent.filter((item) => !categorizedIds.has(item.id)); + const uncategorized = content.filter((item) => !categorizedIds.has(item.id)); if (uncategorized.length > 0) { groups.push({ id: 'uncategorized', title: 'Uncategorized', items: uncategorized }); } return groups.length > 0 ? groups : null; - }, [channel, playlistData, sortedContent]); + }, [channel, playlistData, content]); // ── Content table columns ── @@ -1066,6 +1042,7 @@ export function ChannelDetail() { display: 'flex', alignItems: 'center', gap: 'var(--space-3)', + flexWrap: 'wrap', }} >

Content

+ {contentPagination ? ( + + {contentPagination.totalItems} items + + ) : null} +
+ {/* Search */} + { setContentSearch(e.target.value); setContentPage(1); }} + style={{ + padding: 'var(--space-2) var(--space-3)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-input)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + width: 200, + }} + /> + {/* Status filter */} + + {/* Type filter */} +
{contentError ? (
1 ? ( +
+ +
+ ) : null}
{/* Floating bulk action bar */} diff --git a/src/index.ts b/src/index.ts index db4d5af..854899a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { PlatformRegistry } from './sources/platform-source'; import { YouTubeSource } from './sources/youtube'; import { SoundCloudSource } from './sources/soundcloud'; import { Platform } from './types/index'; +import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp'; import type { ViteDevServer } from 'vite'; const APP_NAME = 'Tubearr'; @@ -44,6 +45,25 @@ async function main(): Promise { await seedAppDefaults(db); console.log(`[${APP_NAME}] App settings seeded`); + // 2d. Check yt-dlp version and auto-update if configured + try { + const version = await getYtDlpVersion(); + if (version) { + console.log(`[${APP_NAME}] yt-dlp version: ${version}`); + // Auto-update on startup (non-blocking — continue if it fails) + if (appConfig.nodeEnv === 'production') { + const result = await updateYtDlp(); + if (result.updated) { + console.log(`[${APP_NAME}] yt-dlp updated: ${result.previousVersion} → ${result.version}`); + } + } + } else { + console.warn(`[${APP_NAME}] yt-dlp not found on PATH — downloads will fail`); + } + } catch (err) { + console.warn(`[${APP_NAME}] yt-dlp check failed: ${err instanceof Error ? err.message : String(err)}`); + } + // 3. Build and configure Fastify server // In dev mode, embed Vite for HMR — single port, no separate frontend process let vite: ViteDevServer | undefined; diff --git a/src/server/routes/content.ts b/src/server/routes/content.ts index b2bc480..8edd4d7 100644 --- a/src/server/routes/content.ts +++ b/src/server/routes/content.ts @@ -2,6 +2,7 @@ import { type FastifyInstance } from 'fastify'; import { getAllContentItems, getContentByChannelId, + getChannelContentPaginated, setMonitored, bulkSetMonitored, } from '../../db/repositories/content-repository'; @@ -202,6 +203,15 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { fastify.get<{ Params: { id: string }; + Querystring: { + page?: string; + pageSize?: string; + search?: string; + status?: string; + contentType?: string; + sortBy?: string; + sortDirection?: string; + }; }>('/api/v1/channel/:id/content', async (request, reply) => { const channelId = parseInt(request.params.id, 10); @@ -213,12 +223,50 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { }); } - try { - const items = await getContentByChannelId(fastify.db, channelId); + const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1); + const pageSize = Math.min( + 200, + Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50) + ); - const response: ApiResponse = { + // If no pagination params provided, return all items (backwards-compatible) + const hasPaginationParams = request.query.page || request.query.pageSize || request.query.search || request.query.status || request.query.contentType || request.query.sortBy; + + try { + if (!hasPaginationParams) { + // Legacy mode: return all items as flat array (backwards-compatible) + const items = await getContentByChannelId(fastify.db, channelId); + const response: ApiResponse = { + success: true, + data: items, + }; + return response; + } + + // Paginated mode with filters + const result = await getChannelContentPaginated( + fastify.db, + channelId, + { + search: request.query.search || undefined, + status: (request.query.status as ContentStatus) || undefined, + contentType: (request.query.contentType as ContentType) || undefined, + sortBy: request.query.sortBy as 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt' | undefined, + sortDirection: (request.query.sortDirection as 'asc' | 'desc') || undefined, + }, + page, + pageSize + ); + + const response: PaginatedResponse = { success: true, - data: items, + data: result.items, + pagination: { + page, + pageSize, + totalItems: result.total, + totalPages: Math.ceil(result.total / pageSize), + }, }; return response; diff --git a/src/services/download.ts b/src/services/download.ts index e0642f4..0b7c0f6 100644 --- a/src/services/download.ts +++ b/src/services/download.ts @@ -3,7 +3,7 @@ import { extname } from 'node:path'; import { createInterface } from 'node:readline'; import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; -import { execYtDlp, spawnYtDlp, YtDlpError } from '../sources/yt-dlp'; +import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp'; import { updateContentItem } from '../db/repositories/content-repository'; import { parseProgressLine } from './progress-parser'; import type { DownloadEventBus } from './event-bus'; @@ -137,16 +137,22 @@ export class DownloadService { // Report error to rate limiter this.rateLimiter.reportError(channel.platform as Platform); + // Classify the error for better retry decisions + const errorMsg = err instanceof Error ? err.message : String(err); + const stderr = err instanceof YtDlpError ? err.stderr : ''; + const errorCategory = classifyYtDlpError(stderr || errorMsg); + // Update status to failed await updateContentItem(this.db, contentItem.id, { status: 'failed' }); - const errorMsg = err instanceof Error ? err.message : String(err); - console.log(`${logPrefix} status=failed error="${errorMsg.slice(0, 200)}"`); + console.log( + `${logPrefix} status=failed category=${errorCategory} error="${errorMsg.slice(0, 200)}"` + ); - // Emit download:failed event + // Emit download:failed event with error category this.eventBus?.emitDownload('download:failed', { contentItemId: contentItem.id, - error: errorMsg.slice(0, 200), + error: `[${errorCategory}] ${errorMsg.slice(0, 200)}`, }); throw err; @@ -288,32 +294,32 @@ export class DownloadService { /** * Build format args for video content. + * Uses a fallback chain: preferred resolution → best available → any. + * yt-dlp supports `/` as a fallback separator: `format1/format2/format3`. */ private buildVideoArgs(formatProfile?: FormatProfile): string[] { const args: string[] = []; + const container = formatProfile?.containerFormat ?? 'mp4'; if (formatProfile?.videoResolution === 'Best') { - // "Best" selects separate best-quality video + audio streams, merged together. - // This is higher quality than `-f best` which picks a single combined format. - args.push('-f', 'bestvideo+bestaudio/best'); - const container = formatProfile.containerFormat ?? 'mp4'; + // Best quality: separate streams merged + args.push('-f', 'bestvideo+bestaudio/bestvideo*+bestaudio/best'); args.push('--merge-output-format', container); } else if (formatProfile?.videoResolution) { const height = parseResolutionHeight(formatProfile.videoResolution); if (height) { + // Fallback chain: exact res → best under res → single best stream → any args.push( '-f', - `bestvideo[height<=${height}]+bestaudio/best[height<=${height}]` + `bestvideo[height<=${height}]+bestaudio/bestvideo[height<=${height}]*+bestaudio/best[height<=${height}]/bestvideo+bestaudio/best` ); } else { - args.push('-f', 'best'); + args.push('-f', 'bestvideo+bestaudio/best'); } - - // Container format for merge - const container = formatProfile.containerFormat ?? 'mp4'; args.push('--merge-output-format', container); } else { - args.push('-f', 'best'); + args.push('-f', 'bestvideo+bestaudio/best'); + args.push('--merge-output-format', container); } return args; @@ -367,18 +373,33 @@ export class DownloadService { /** * Parse the final file path from yt-dlp stdout. - * The `--print after_move:filepath` flag makes yt-dlp output the final path - * as the last line of stdout. + * The `--print after_move:filepath` flag makes yt-dlp output the final path. + * + * Strategy: walk backwards through lines, skipping known yt-dlp output prefixes + * (e.g. [download], [Merger], [ExtractAudio], Deleting). + * A valid path line should be an absolute path or at least contain a file extension. */ private parseFinalPath(stdout: string, fallbackPath: string): string { const lines = stdout.trim().split('\n'); - // The filepath from --print is typically the last non-empty line + + // Known non-path prefixes from yt-dlp output + const NON_PATH_PREFIXES = ['[', 'Deleting', 'WARNING:', 'ERROR:', 'Merging', 'Post-process']; + for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); - if (line && !line.startsWith('[') && !line.startsWith('Deleting')) { + if (!line) continue; + + // Skip known yt-dlp output lines + const isNonPath = NON_PATH_PREFIXES.some((prefix) => line.startsWith(prefix)); + if (isNonPath) continue; + + // A valid path should have a file extension or start with / + if (line.startsWith('/') || /\.\w{2,5}$/.test(line)) { return line; } } + + console.warn('[download] Could not parse final path from yt-dlp output, using fallback'); return fallbackPath; } diff --git a/src/sources/yt-dlp.ts b/src/sources/yt-dlp.ts index 23aeb2a..2977828 100644 --- a/src/sources/yt-dlp.ts +++ b/src/sources/yt-dlp.ts @@ -212,3 +212,88 @@ export async function getYtDlpVersion(): Promise { return null; } } + +// ── Auto-Update ── + +/** + * Update yt-dlp to the latest version. + * Uses `yt-dlp -U` which handles self-update on most installations. + * For pip-based installs (Alpine/Docker), falls back to `pip install -U yt-dlp`. + * + * Returns { updated, version, previousVersion } on success. + */ +export async function updateYtDlp(): Promise<{ + updated: boolean; + version: string | null; + previousVersion: string | null; +}> { + const previousVersion = await getYtDlpVersion(); + + try { + // Try native self-update first + const { stderr } = await execFileAsync('yt-dlp', ['-U'], { + timeout: 120_000, + windowsHide: true, + }); + + // Check if it actually updated + const newVersion = await getYtDlpVersion(); + const didUpdate = newVersion !== previousVersion; + + if (didUpdate) { + console.log(`[yt-dlp] Updated from ${previousVersion} to ${newVersion}`); + } else if (stderr.toLowerCase().includes('up to date')) { + console.log(`[yt-dlp] Already up to date (${newVersion})`); + } + + return { updated: didUpdate, version: newVersion, previousVersion }; + } catch (err) { + // Self-update may not work in pip-based installs — try pip + try { + await execFileAsync('pip', ['install', '-U', 'yt-dlp'], { + timeout: 120_000, + windowsHide: true, + }); + + const newVersion = await getYtDlpVersion(); + const didUpdate = newVersion !== previousVersion; + console.log( + `[yt-dlp] pip update: ${previousVersion} → ${newVersion}${didUpdate ? '' : ' (no change)'}` + ); + + return { updated: didUpdate, version: newVersion, previousVersion }; + } catch (pipErr) { + console.warn( + `[yt-dlp] Auto-update failed: ${err instanceof Error ? err.message : String(err)}` + ); + return { updated: false, version: previousVersion, previousVersion }; + } + } +} + +// ── Error Classification ── + +/** + * Classify a yt-dlp error into a category for better retry/fallback decisions. + */ +export type YtDlpErrorCategory = + | 'rate_limit' // 429, too many requests + | 'format_unavailable' // requested format not available + | 'geo_blocked' // geo-restriction + | 'age_restricted' // age-gated content + | 'private' // private or removed video + | 'network' // DNS, connection, timeout + | 'unknown'; + +export function classifyYtDlpError(stderr: string): YtDlpErrorCategory { + const lower = stderr.toLowerCase(); + + if (lower.includes('429') || lower.includes('too many requests')) return 'rate_limit'; + if (lower.includes('requested format') || lower.includes('format is not available')) return 'format_unavailable'; + if (lower.includes('not available in your country') || lower.includes('geo')) return 'geo_blocked'; + if (lower.includes('age') && (lower.includes('restricted') || lower.includes('verify'))) return 'age_restricted'; + if (lower.includes('private video') || lower.includes('video unavailable') || lower.includes('been removed')) return 'private'; + if (lower.includes('unable to download') || lower.includes('connection') || lower.includes('timed out') || lower.includes('urlopen error')) return 'network'; + + return 'unknown'; +}