import { spawn } from 'node:child_process'; import type { YoutubeVideoMetadata } from '../immersion-tracker/types'; import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000; type YtDlpThumbnail = { url?: string; width?: number; height?: number; }; type YtDlpYoutubeMetadata = { id?: string; title?: string; webpage_url?: string; thumbnail?: string; thumbnails?: YtDlpThumbnail[]; channel_id?: string; channel?: string; channel_url?: string; uploader_id?: string; uploader_url?: string; description?: string; }; function runCapture( command: string, args: string[], timeoutMs = YOUTUBE_METADATA_PROBE_TIMEOUT_MS, ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { proc.kill(); reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`)); }, timeoutMs); proc.stdout.setEncoding('utf8'); proc.stderr.setEncoding('utf8'); proc.stdout.on('data', (chunk) => { stdout += String(chunk); }); proc.stderr.on('data', (chunk) => { stderr += String(chunk); }); proc.once('error', (error) => { clearTimeout(timer); reject(error); }); proc.once('close', (code) => { clearTimeout(timer); if (code === 0) { resolve({ stdout, stderr }); return; } reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`)); }); }); } function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string | null { if (!Array.isArray(thumbnails)) return null; for (const thumbnail of thumbnails) { const candidate = thumbnail.url?.trim(); if (!candidate) continue; if (candidate.includes('/vi/')) continue; if ( typeof thumbnail.width === 'number' && typeof thumbnail.height === 'number' && thumbnail.width > 0 && thumbnail.height > 0 ) { const ratio = thumbnail.width / thumbnail.height; if (ratio >= 0.8 && ratio <= 1.25) { return candidate; } continue; } if (candidate.includes('yt3.googleusercontent.com')) { return candidate; } } return null; } export function buildYoutubeMetadataProbeArgs(targetUrl: string): string[] { return [ YTDLP_SINGLE_VIDEO_ARG, '--dump-single-json', '--no-warnings', '--skip-download', targetUrl, ]; } export async function probeYoutubeVideoMetadata( targetUrl: string, ): Promise { const { stdout } = await runCapture( getYoutubeYtDlpCommand(), buildYoutubeMetadataProbeArgs(targetUrl), ); let info: YtDlpYoutubeMetadata; try { info = JSON.parse(stdout) as YtDlpYoutubeMetadata; } catch { return null; } const youtubeVideoId = info.id?.trim(); const videoUrl = info.webpage_url?.trim() || targetUrl.trim(); if (!youtubeVideoId || !videoUrl) { return null; } return { youtubeVideoId, videoUrl, videoTitle: info.title?.trim() || null, videoThumbnailUrl: info.thumbnail?.trim() || null, channelId: info.channel_id?.trim() || null, channelName: info.channel?.trim() || null, channelUrl: info.channel_url?.trim() || null, channelThumbnailUrl: pickChannelThumbnail(info.thumbnails), uploaderId: info.uploader_id?.trim() || null, uploaderUrl: info.uploader_url?.trim() || null, description: info.description?.trim() || null, metadataJson: JSON.stringify(info), }; }