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)
306 lines
9.2 KiB
TypeScript
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;
|
|
}
|