tubearr/src/services/download.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

306 lines
9.2 KiB
TypeScript

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<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 {
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<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 — 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 <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;
}