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
This commit is contained in:
jlightner 2026-04-03 02:29:49 +00:00
parent 0541a5f1d1
commit c057b6a286
8 changed files with 420 additions and 83 deletions

View file

@ -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:

View file

@ -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<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,

View file

@ -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<PaginatedResponse<ContentItem>>(
`/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. */

View file

@ -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<string | null>(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<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
@ -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<number, ContentItem>();
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',
}}
>
<h2
@ -1078,6 +1055,65 @@ export function ChannelDetail() {
>
Content
</h2>
{contentPagination ? (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>
{contentPagination.totalItems} items
</span>
) : null}
<div style={{ flex: 1 }} />
{/* Search */}
<input
type="text"
placeholder="Search content…"
value={contentSearch}
onChange={(e) => { 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 */}
<select
value={contentStatusFilter}
onChange={(e) => { setContentStatusFilter(e.target.value); setContentPage(1); }}
aria-label="Filter by status"
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 100,
}}
>
<option value="">All Status</option>
<option value="monitored">Monitored</option>
<option value="queued">Queued</option>
<option value="downloading">Downloading</option>
<option value="downloaded">Downloaded</option>
<option value="failed">Failed</option>
<option value="ignored">Ignored</option>
</select>
{/* Type filter */}
<select
value={contentTypeFilter}
onChange={(e) => { setContentTypeFilter(e.target.value); setContentPage(1); }}
aria-label="Filter by type"
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 90,
}}
>
<option value="">All Types</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="livestream">Livestream</option>
</select>
</div>
{contentError ? (
<div
@ -1128,8 +1164,19 @@ export function ChannelDetail() {
) : hasPlaylistGroups ? (
renderPlaylistGroups(playlistGroups!)
) : (
renderTable(sortedContent)
renderTable(content)
)}
{/* Pagination controls */}
{contentPagination && contentPagination.totalPages > 1 ? (
<div style={{ padding: 'var(--space-3) var(--space-5)', borderTop: '1px solid var(--border)' }}>
<Pagination
page={contentPagination.page}
totalPages={contentPagination.totalPages}
totalItems={contentPagination.totalItems}
onPageChange={setContentPage}
/>
</div>
) : null}
</div>
{/* Floating bulk action bar */}

View file

@ -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<void> {
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;

View file

@ -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<void> {
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<void> {
});
}
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<ContentItem[]> = {
// 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<ContentItem[]> = {
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<ContentItem> = {
success: true,
data: items,
data: result.items,
pagination: {
page,
pageSize,
totalItems: result.total,
totalPages: Math.ceil(result.total / pageSize),
},
};
return response;

View file

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

View file

@ -212,3 +212,88 @@ export async function getYtDlpVersion(): Promise<string | null> {
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';
}