tubearr/src/services/download.ts
John Lightner 0541a5f1d1 WIP: in-progress WebSocket download progress & event bus
Snapshot of active development by separate Claude instance.
Includes: event bus, progress parser, WebSocket route,
download progress bar component, SSE contexts/hooks.
Not tested or validated — commit for migration to dev01.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 11:34:26 -05:00

418 lines
12 KiB
TypeScript

import { stat } from 'node:fs/promises';
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 { updateContentItem } from '../db/repositories/content-repository';
import { parseProgressLine } from './progress-parser';
import type { DownloadEventBus } from './event-bus';
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<typeof schema>;
// ── 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 {
private readonly eventBus?: DownloadEventBus;
constructor(
private readonly db: Db,
private readonly rateLimiter: RateLimiter,
private readonly fileOrganizer: FileOrganizer,
private readonly qualityAnalyzer: QualityAnalyzer,
private readonly cookieManager: CookieManager,
eventBus?: DownloadEventBus
) {
this.eventBus = eventBus;
}
/**
* 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<ContentItem> {
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 — streaming spawn when event bus is available, buffered exec otherwise
let stdout: string;
if (this.eventBus) {
stdout = await this.spawnDownload(args, contentItem.id, 1_800_000);
} else {
const result = await execYtDlp(args, { timeout: 1_800_000 });
stdout = result.stdout;
}
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(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);
// Emit download:complete event
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
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)}"`);
// Emit download:failed event
this.eventBus?.emitDownload('download:failed', {
contentItemId: contentItem.id,
error: errorMsg.slice(0, 200),
});
throw err;
}
}
// ── Internal ──
/**
* Spawn yt-dlp and stream progress events via the event bus.
* Returns collected stdout (non-progress lines) for final path parsing.
*/
private spawnDownload(
args: string[],
contentItemId: number,
timeoutMs: number
): Promise<string> {
return new Promise((resolve, reject) => {
// Add --newline to ensure progress updates are separate lines
const spawnArgs = ['--newline', '--progress', ...args];
const child = spawnYtDlp(spawnArgs);
const stdoutLines: string[] = [];
const stderrChunks: string[] = [];
let killed = false;
// Timeout — kill child after timeoutMs
const timer = setTimeout(() => {
killed = true;
child.kill('SIGTERM');
}, timeoutMs);
// Parse stdout line-by-line
if (child.stdout) {
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
rl.on('line', (line: string) => {
const progress = parseProgressLine(line);
if (progress) {
this.eventBus!.emitDownload('download:progress', {
contentItemId,
percent: progress.percent,
speed: progress.speed,
eta: progress.eta,
});
} else {
// Non-progress lines — collect for final path parsing
stdoutLines.push(line);
}
});
}
// Collect stderr
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk.toString());
});
}
// Handle process exit
child.on('close', (code: number | null) => {
clearTimeout(timer);
const stdout = stdoutLines.join('\n');
const stderr = stderrChunks.join('');
if (killed) {
reject(new YtDlpError(
`yt-dlp timed out after ${timeoutMs}ms`,
stderr,
-1
));
return;
}
if (code !== 0 && code !== null) {
reject(new YtDlpError(
`yt-dlp exited with code ${code}: ${stderr.slice(0, 200)}`,
stderr,
code
));
return;
}
resolve(stdout);
});
// Handle spawn errors (e.g., yt-dlp not found)
child.on('error', (err: Error) => {
clearTimeout(timer);
reject(new YtDlpError(
`Failed to spawn yt-dlp: ${err.message}`,
'',
-1
));
});
});
}
/**
* 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 <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;
}