mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-04 19:21:33 -07:00
132 lines
3.6 KiB
TypeScript
132 lines
3.6 KiB
TypeScript
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<YoutubeVideoMetadata | null> {
|
|
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),
|
|
};
|
|
}
|