import { stat } from 'node:fs/promises'; import { extname } from 'node:path'; import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { execYtDlp, YtDlpError } from '../sources/yt-dlp'; import { updateContentItem } from '../db/repositories/content-repository'; import type { RateLimiter } from './rate-limiter'; import type { FileOrganizer } from './file-organizer'; import type { QualityAnalyzer } from './quality-analyzer'; import type { CookieManager } from './cookie-manager'; import type { ContentItem, Channel, FormatProfile, Platform, ContentType, } from '../types/index'; // ── Types ── type Db = LibSQLDatabase; // ── DownloadService ── /** * Orchestrates the full download lifecycle: * acquire rate limiter → build yt-dlp args → download → organize file → * run quality analysis → update content item. */ export class DownloadService { constructor( private readonly db: Db, private readonly rateLimiter: RateLimiter, private readonly fileOrganizer: FileOrganizer, private readonly qualityAnalyzer: QualityAnalyzer, private readonly cookieManager: CookieManager ) {} /** * Download a content item and update its record in the database. * * Status transitions: monitored → downloading → downloaded | failed * * @throws YtDlpError on download failure (after updating status to 'failed') */ async downloadItem( contentItem: ContentItem, channel: Channel, formatProfile?: FormatProfile ): Promise { const logPrefix = `[download] item=${contentItem.id} channel="${channel.name}"`; // Mark as downloading console.log(`${logPrefix} status=downloading`); await updateContentItem(this.db, contentItem.id, { status: 'downloading' }); try { // Acquire rate limiter for platform await this.rateLimiter.acquire(channel.platform as Platform); // Build yt-dlp args const outputTemplate = this.fileOrganizer.buildOutputPath( channel.platform, channel.name, contentItem.title, this.guessExtension(contentItem.contentType, formatProfile) ); const args = this.buildYtDlpArgs( contentItem, channel, formatProfile, outputTemplate ); console.log( `${logPrefix} starting yt-dlp download subs=${formatProfile?.subtitleLanguages ? 'write-subs:' + formatProfile.subtitleLanguages : 'none'} embed=${formatProfile?.embedSubtitles ?? false}` ); const startTime = Date.now(); // Execute download — 30 minute timeout const result = await execYtDlp(args, { timeout: 1_800_000 }); const duration = Date.now() - startTime; console.log(`${logPrefix} yt-dlp completed in ${duration}ms`); // Parse final file path from --print after_move:filepath output const finalPath = this.parseFinalPath(result.stdout, outputTemplate); // Ensure directories exist and resolve duplicate filenames await this.fileOrganizer.ensureDirectory(finalPath); // Get file size const fileStat = await stat(finalPath); const fileSize = fileStat.size; // Run quality analysis const qualityInfo = await this.qualityAnalyzer.analyze(finalPath); // Determine format from file extension const format = extname(finalPath).replace(/^\./, '') || null; // Update content item as downloaded const updated = await updateContentItem(this.db, contentItem.id, { filePath: finalPath, fileSize, format, qualityMetadata: qualityInfo, status: 'downloaded', downloadedAt: new Date().toISOString(), }); this.rateLimiter.reportSuccess(channel.platform as Platform); console.log( `${logPrefix} status=downloaded path="${finalPath}" size=${fileSize} format=${format}` ); return updated!; } catch (err: unknown) { // Report error to rate limiter this.rateLimiter.reportError(channel.platform as Platform); // 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)}"`); throw err; } } // ── Internal ── /** * Build the yt-dlp command-line args based on content type and format profile. */ private buildYtDlpArgs( contentItem: ContentItem, channel: Channel, formatProfile: FormatProfile | undefined, outputTemplate: string ): string[] { const args: string[] = []; // Format selection if (contentItem.contentType === 'audio') { args.push(...this.buildAudioArgs(formatProfile)); } else { args.push(...this.buildVideoArgs(formatProfile)); } // Subtitle support args.push(...this.buildSubtitleArgs(formatProfile)); // Always include these flags args.push('--no-playlist'); args.push('--print', 'after_move:filepath'); // Cookie support const cookiePath = this.cookieManager.getCookieFilePath( channel.platform as Platform ); if (cookiePath) { args.push('--cookies', cookiePath); } // Output template args.push('-o', outputTemplate); // URL is always last args.push(contentItem.url); return args; } /** * Build format args for video content. */ private buildVideoArgs(formatProfile?: FormatProfile): string[] { const args: string[] = []; 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'; args.push('--merge-output-format', container); } else if (formatProfile?.videoResolution) { const height = parseResolutionHeight(formatProfile.videoResolution); if (height) { args.push( '-f', `bestvideo[height<=${height}]+bestaudio/best[height<=${height}]` ); } else { args.push('-f', 'best'); } // Container format for merge const container = formatProfile.containerFormat ?? 'mp4'; args.push('--merge-output-format', container); } else { args.push('-f', 'best'); } return args; } /** * Build format args for audio content. */ private buildAudioArgs(formatProfile?: FormatProfile): string[] { const args: string[] = ['-f', 'bestaudio']; if (formatProfile) { args.push('--extract-audio'); if (formatProfile.audioCodec) { args.push('--audio-format', formatProfile.audioCodec); } if (formatProfile.audioBitrate && formatProfile.audioBitrate !== 'Best') { args.push('--audio-quality', formatProfile.audioBitrate); } } return args; } /** * Build subtitle flags based on format profile preferences. * * When subtitleLanguages is set: --write-subs --sub-langs * When embedSubtitles is also true: --embed-subs * * --embed-subs without --write-subs is a no-op (no subs to embed), * so we only emit it when subtitleLanguages is also set. * * yt-dlp gracefully continues when requested subs are unavailable. */ private buildSubtitleArgs(formatProfile?: FormatProfile): string[] { const args: string[] = []; if (formatProfile?.subtitleLanguages) { args.push('--write-subs'); args.push('--sub-langs', formatProfile.subtitleLanguages); if (formatProfile.embedSubtitles) { args.push('--embed-subs'); } } return args; } /** * 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. */ private parseFinalPath(stdout: string, fallbackPath: string): string { const lines = stdout.trim().split('\n'); // The filepath from --print is typically the last non-empty line for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (line && !line.startsWith('[') && !line.startsWith('Deleting')) { return line; } } return fallbackPath; } /** * Guess a reasonable file extension based on content type and format profile. * This is used for the output template — yt-dlp may change the actual extension. */ private guessExtension( contentType: ContentType, formatProfile?: FormatProfile ): string { if (formatProfile?.containerFormat) { return formatProfile.containerFormat; } return contentType === 'audio' ? 'mp3' : 'mp4'; } } // ── Helpers ── /** * Parse a resolution string like "1080p", "720p", "4k" to a numeric height. * Returns null if the format is not recognized. */ function parseResolutionHeight(resolution: string): number | null { const lower = resolution.toLowerCase().trim(); // Handle "4k", "8k" etc. if (lower === '4k') return 2160; if (lower === '8k') return 4320; // Handle "1080p", "720p", "480p", "1080" etc. const match = lower.match(/^(\d+)p?$/); if (match) return Number(match[1]); return null; }