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>
418 lines
12 KiB
TypeScript
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;
|
|
}
|