feat(youtube): add mediaCache mode and safer stream media extraction (#130)

This commit is contained in:
2026-06-27 19:20:18 -07:00
committed by GitHub
parent d199376364
commit 5326ad32f5
52 changed files with 4065 additions and 165 deletions
+143 -7
View File
@@ -21,9 +21,12 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from './logger';
import { normalizeMediaInput, type MediaInput } from './media-input';
const log = createLogger('media');
export type { MediaInput, MediaInputOptions } from './media-input';
function normalizeAnimatedImageFps(fps: number | undefined): number {
const fallbackFps = 10;
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
@@ -69,12 +72,65 @@ export function buildAnimatedImageVideoFilter(options: {
return vfParts.join(',');
}
export interface MediaGeneratorOptions {
logDebug?: (message: string) => void;
now?: () => number;
}
function sanitizeDebugToken(value: string, fallback: string): string {
const trimmed = value.trim();
if (!trimmed) {
return fallback;
}
const sanitized = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80);
return sanitized || fallback;
}
function describeMediaInputPathForDebugLog(value: string): string {
try {
const url = new URL(value);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
}
return `${url.protocol.replace(/:$/, '')}:`;
} catch {
// Not a URL; treat as a local file path below.
}
if (value.startsWith('edl://')) {
return 'edl:';
}
return `local:${value}`;
}
function describeMediaInputForDebugLog(input: MediaInput): string {
const pathValue = typeof input === 'string' ? input : input.path;
const sourceValue = typeof input === 'string' ? 'raw' : input.source;
const source = sanitizeDebugToken(sourceValue ?? 'raw', 'raw');
return `source=${source} input=${describeMediaInputPathForDebugLog(pathValue)}`;
}
function describeFfmpegFailureForDebugLog(error: ExecFileException): string {
const code = typeof error.code === 'string' || typeof error.code === 'number' ? error.code : null;
const signal = typeof error.signal === 'string' ? error.signal : null;
if (code !== null) {
return `code=${code}`;
}
if (signal) {
return `signal=${signal}`;
}
return `name=${sanitizeDebugToken(error.name || 'Error', 'Error')}`;
}
export class MediaGenerator {
private tempDir: string;
private notifyIconDir: string;
private av1EncoderPromise: Promise<string | null> | null = null;
private readonly options: MediaGeneratorOptions;
constructor(tempDir?: string) {
constructor(tempDir?: string, options: MediaGeneratorOptions = {}) {
this.options = options;
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
this.ensureDirectory(this.tempDir);
@@ -83,6 +139,28 @@ export class MediaGenerator {
this.cleanupOldNotificationIcons();
}
private nowMs(): number {
try {
const value = this.options.now?.() ?? Date.now();
return Number.isFinite(value) ? value : Date.now();
} catch {
return Date.now();
}
}
private elapsedMs(startedAt: number): number {
return Math.max(0, Math.round(this.nowMs() - startedAt));
}
private logMediaDebug(message: string): void {
const logDebug = this.options.logDebug ?? ((line: string) => log.debug(line));
try {
logDebug(`[media-generator] ${message}`);
} catch {
// Debug logging should not affect media generation.
}
}
private ensureDirectory(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -181,7 +259,7 @@ export class MediaGenerator {
}
async generateAudio(
videoPath: string,
videoPath: MediaInput,
startTime: number,
endTime: number,
padding: number = 0,
@@ -190,12 +268,24 @@ export class MediaGenerator {
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime - safePadding);
const duration = endTime - start + safePadding;
const mediaInput = normalizeMediaInput(videoPath);
const inputDescription = describeMediaInputForDebugLog(videoPath);
return new Promise((resolve, reject) => {
const outputPath = this.createTempOutputPath('audio', 'mp3');
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
const startedAt = this.nowMs();
const args: string[] = [
'-ss',
start.toString(),
'-t',
duration.toString(),
...mediaInput.inputArgs,
'-i',
mediaInput.path,
];
if (
!mediaInput.singleResolvedStream &&
typeof audioStreamIndex === 'number' &&
Number.isInteger(audioStreamIndex) &&
audioStreamIndex >= 0
@@ -205,8 +295,14 @@ export class MediaGenerator {
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
this.logMediaDebug(
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
);
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
if (error) {
this.logMediaDebug(
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
);
reject(this.ffmpegError('audio generation', error));
return;
}
@@ -214,6 +310,9 @@ export class MediaGenerator {
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
this.logMediaDebug(
`audio complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
);
resolve(data);
} catch (err) {
reject(err);
@@ -223,7 +322,7 @@ export class MediaGenerator {
}
async generateScreenshot(
videoPath: string,
videoPath: MediaInput,
timestamp: number,
options: {
format: 'jpg' | 'png' | 'webp';
@@ -239,8 +338,18 @@ export class MediaGenerator {
png: 'png',
webp: 'webp',
};
const mediaInput = normalizeMediaInput(videoPath);
const inputDescription = describeMediaInputForDebugLog(videoPath);
const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1'];
const args: string[] = [
'-ss',
timestamp.toString(),
...mediaInput.inputArgs,
'-i',
mediaInput.path,
'-vframes',
'1',
];
const vfParts: string[] = [];
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
@@ -270,10 +379,17 @@ export class MediaGenerator {
return new Promise((resolve, reject) => {
const outputPath = this.createTempOutputPath('screenshot', ext);
const startedAt = this.nowMs();
args.push(outputPath);
this.logMediaDebug(
`screenshot start ${inputDescription} timestamp=${timestamp} format=${format} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'}`,
);
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
if (error) {
this.logMediaDebug(
`screenshot failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
);
reject(this.ffmpegError('screenshot generation', error));
return;
}
@@ -281,6 +397,9 @@ export class MediaGenerator {
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
this.logMediaDebug(
`screenshot complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
);
resolve(data);
} catch (err) {
reject(err);
@@ -334,7 +453,7 @@ export class MediaGenerator {
}
async generateAnimatedImage(
videoPath: string,
videoPath: MediaInput,
startTime: number,
endTime: number,
padding: number = 0,
@@ -352,10 +471,15 @@ export class MediaGenerator {
const start = Math.max(0, startTime - safePadding);
const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps);
const totalLeadingStillDuration = Math.max(0, leadingStillDuration);
const inputDescription = describeMediaInputForDebugLog(videoPath);
const clampedCrf = Math.max(0, Math.min(63, crf));
const encoderDetectionStartedAt = this.nowMs();
const av1Encoder = await this.detectAv1Encoder();
this.logMediaDebug(
`animated-image encoder ${inputDescription} elapsedMs=${this.elapsedMs(encoderDetectionStartedAt)} encoder=${av1Encoder ?? 'none'}`,
);
if (!av1Encoder) {
throw new Error(
'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).',
@@ -364,6 +488,8 @@ export class MediaGenerator {
return new Promise((resolve, reject) => {
const outputPath = this.createTempOutputPath('animation', 'avif');
const mediaInput = normalizeMediaInput(videoPath);
const startedAt = this.nowMs();
const encoderArgs: string[] = ['-c:v', av1Encoder];
if (av1Encoder === 'libaom-av1') {
@@ -375,6 +501,9 @@ export class MediaGenerator {
encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8');
}
this.logMediaDebug(
`animated-image start ${inputDescription} start=${start} duration=${duration} padding=${safePadding} fps=${clampedFps} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'} crf=${clampedCrf} encoder=${av1Encoder}`,
);
execFile(
'ffmpeg',
[
@@ -382,8 +511,9 @@ export class MediaGenerator {
start.toString(),
'-t',
duration.toString(),
...mediaInput.inputArgs,
'-i',
videoPath,
mediaInput.path,
'-vf',
buildAnimatedImageVideoFilter({
fps: clampedFps,
@@ -398,6 +528,9 @@ export class MediaGenerator {
{ timeout: 60000 },
(error) => {
if (error) {
this.logMediaDebug(
`animated-image failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
);
reject(this.ffmpegError('animation generation', error));
return;
}
@@ -405,6 +538,9 @@ export class MediaGenerator {
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
this.logMediaDebug(
`animated-image complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
);
resolve(data);
} catch (err) {
reject(err);