mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import { isRemoteMediaPath } from '../jimaku/utils';
|
|
import type { MediaInput, MediaInputOptions } from '../media-input';
|
|
import type { MpvClient } from '../types/runtime';
|
|
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
|
|
|
export type MediaGenerationKind = 'audio' | 'video';
|
|
export type MediaGenerationInputSource =
|
|
| 'current-path'
|
|
| 'stream-open-filename'
|
|
| 'edl-stream'
|
|
| 'youtube-cache';
|
|
|
|
export interface ResolvedMediaGenerationInput {
|
|
path: string;
|
|
kind: MediaGenerationKind;
|
|
source: MediaGenerationInputSource;
|
|
singleResolvedStream: boolean;
|
|
inputOptions?: MediaInputOptions;
|
|
}
|
|
|
|
export interface MediaGenerationInputResolverOptions {
|
|
getCachedMediaPath?: (
|
|
currentVideoPath: string,
|
|
kind: MediaGenerationKind,
|
|
) => Promise<string | null>;
|
|
remoteCacheMode?: 'optional' | 'required';
|
|
logDebug?: (message: string) => void;
|
|
}
|
|
|
|
export function resolveAudioStreamIndexForMediaGeneration(
|
|
input: MediaInput,
|
|
audioStreamIndex: number | null | undefined,
|
|
): number | undefined {
|
|
if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') {
|
|
return undefined;
|
|
}
|
|
return audioStreamIndex ?? undefined;
|
|
}
|
|
|
|
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
|
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
|
'http-header-fields',
|
|
'options/http-header-fields',
|
|
'file-local-options/http-header-fields',
|
|
] as const;
|
|
const USER_AGENT_PROPERTY_NAMES = [
|
|
'file-local-options/user-agent',
|
|
'options/user-agent',
|
|
'user-agent',
|
|
] as const;
|
|
const REFERRER_PROPERTY_NAMES = [
|
|
'file-local-options/referrer',
|
|
'options/referrer',
|
|
'referrer',
|
|
] as const;
|
|
|
|
function trimToNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function normalizeHeaderName(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
|
return null;
|
|
}
|
|
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
|
return null;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
|
return extractFileUrlsFromMpvEdlSource(source)
|
|
.map((value) => trimToNonEmptyString(value))
|
|
.filter((value): value is string => value !== null);
|
|
}
|
|
|
|
function classifyMediaUrl(url: string): MediaGenerationKind | null {
|
|
try {
|
|
const mime = new URL(url).searchParams.get('mime')?.toLowerCase() ?? '';
|
|
if (mime.startsWith('audio/')) {
|
|
return 'audio';
|
|
}
|
|
if (mime.startsWith('video/')) {
|
|
return 'video';
|
|
}
|
|
} catch {
|
|
// Ignore malformed URLs and fall back to stream order.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolvePreferredUrlFromMpvEdlSource(
|
|
source: string,
|
|
kind: MediaGenerationKind,
|
|
): string | null {
|
|
const urls = extractUrlsFromMpvEdlSource(source);
|
|
if (urls.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const typedMatch = urls.find((url) => classifyMediaUrl(url) === kind);
|
|
if (typedMatch) {
|
|
return typedMatch;
|
|
}
|
|
|
|
// mpv EDL sources usually list audio streams first and video streams last, so
|
|
// when classifyMediaUrl cannot identify a typed URL we fall back to stream order.
|
|
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
|
|
}
|
|
|
|
function getHostname(value: string): string | null {
|
|
try {
|
|
return new URL(value).hostname.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function matchesHost(hostname: string, expectedHost: string): boolean {
|
|
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
|
}
|
|
|
|
function isGoogleVideoMediaPath(value: string): boolean {
|
|
const host = getHostname(value);
|
|
return Boolean(host && matchesHost(host, 'googlevideo.com'));
|
|
}
|
|
|
|
function describeMediaPathForDebugLog(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 logMediaResolutionDebug(
|
|
options: MediaGenerationInputResolverOptions,
|
|
message: string,
|
|
): void {
|
|
if (!options.logDebug) {
|
|
return;
|
|
}
|
|
try {
|
|
options.logDebug(`[media-source] ${message}`);
|
|
} catch {
|
|
// Debug logging should not affect media generation.
|
|
}
|
|
}
|
|
|
|
function logResolvedMediaGenerationInput(
|
|
options: MediaGenerationInputResolverOptions,
|
|
currentVideoPath: string,
|
|
result: ResolvedMediaGenerationInput,
|
|
): void {
|
|
logMediaResolutionDebug(
|
|
options,
|
|
[
|
|
`kind=${result.kind}`,
|
|
`source=${result.source}`,
|
|
`input=${describeMediaPathForDebugLog(result.path)}`,
|
|
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
|
`singleResolvedStream=${result.singleResolvedStream}`,
|
|
].join(' '),
|
|
);
|
|
}
|
|
|
|
function logMediaGenerationInputMiss(
|
|
options: MediaGenerationInputResolverOptions,
|
|
kind: MediaGenerationKind,
|
|
currentVideoPath: string,
|
|
reason: string,
|
|
): void {
|
|
logMediaResolutionDebug(
|
|
options,
|
|
[
|
|
`kind=${kind}`,
|
|
'source=cache-miss',
|
|
`reason=${reason}`,
|
|
`mode=${options.remoteCacheMode ?? 'optional'}`,
|
|
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
|
].join(' '),
|
|
);
|
|
}
|
|
|
|
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
|
const lowerName = name.toLowerCase();
|
|
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
|
headers[name] = value;
|
|
}
|
|
}
|
|
|
|
function parseMpvHeaderField(value: string): [string, string] | null {
|
|
const separatorIndex = value.indexOf(':');
|
|
if (separatorIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
|
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
|
if (!name || !headerValue) {
|
|
return null;
|
|
}
|
|
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
|
}
|
|
|
|
function toHeaderFields(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.filter((entry): entry is string => typeof entry === 'string');
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
.split(/\r?\n/)
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async function requestOptionalMpvProperty(
|
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
|
name: string,
|
|
): Promise<unknown> {
|
|
if (!mpvClient.requestProperty) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return await mpvClient.requestProperty(name);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function requestFirstNonEmptyStringProperty(
|
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
|
names: readonly string[],
|
|
): Promise<string | null> {
|
|
for (const name of names) {
|
|
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolveRemoteInputOptions(
|
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
|
resolvedPath: string,
|
|
): Promise<MediaInputOptions | undefined> {
|
|
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
|
|
return undefined;
|
|
}
|
|
|
|
const headers: Record<string, string> = {};
|
|
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
|
const mpvHeaderFields = toHeaderFields(
|
|
await requestOptionalMpvProperty(mpvClient, propertyName),
|
|
);
|
|
for (const field of mpvHeaderFields) {
|
|
const parsed = parseMpvHeaderField(field);
|
|
if (parsed) {
|
|
headers[parsed[0]] = parsed[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
|
|
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
|
|
if (referrer) {
|
|
setHeaderIfMissing(headers, 'Referer', referrer);
|
|
}
|
|
if (isGoogleVideoMediaPath(resolvedPath)) {
|
|
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
|
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
|
}
|
|
|
|
return {
|
|
reconnect: true,
|
|
...(userAgent ? { userAgent } : {}),
|
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
};
|
|
}
|
|
|
|
async function toResolvedMediaGenerationInput(
|
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
|
path: string,
|
|
kind: MediaGenerationKind,
|
|
source: MediaGenerationInputSource,
|
|
singleResolvedStream: boolean,
|
|
): Promise<ResolvedMediaGenerationInput> {
|
|
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
|
|
return {
|
|
path,
|
|
kind,
|
|
source,
|
|
singleResolvedStream,
|
|
...(inputOptions ? { inputOptions } : {}),
|
|
};
|
|
}
|
|
|
|
export async function resolveMediaGenerationInput(
|
|
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
|
kind: MediaGenerationKind = 'video',
|
|
options: MediaGenerationInputResolverOptions = {},
|
|
): Promise<ResolvedMediaGenerationInput | null> {
|
|
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
|
if (!currentVideoPath) {
|
|
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
|
|
return null;
|
|
}
|
|
|
|
if (!isRemoteMediaPath(currentVideoPath)) {
|
|
const result: ResolvedMediaGenerationInput = {
|
|
path: currentVideoPath,
|
|
kind,
|
|
source: 'current-path',
|
|
singleResolvedStream: false,
|
|
};
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
|
|
let cachedPath: string | null = null;
|
|
if (options.getCachedMediaPath) {
|
|
try {
|
|
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
|
|
} catch {
|
|
cachedPath = null;
|
|
}
|
|
}
|
|
if (cachedPath) {
|
|
const result: ResolvedMediaGenerationInput = {
|
|
path: cachedPath,
|
|
kind,
|
|
source: 'youtube-cache',
|
|
singleResolvedStream: false,
|
|
};
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
|
|
if (options.remoteCacheMode === 'required') {
|
|
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
|
|
return null;
|
|
}
|
|
|
|
if (!mpvClient?.requestProperty) {
|
|
const result: ResolvedMediaGenerationInput = {
|
|
path: currentVideoPath,
|
|
kind,
|
|
source: 'current-path',
|
|
singleResolvedStream: false,
|
|
};
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
const streamOpenFilename = trimToNonEmptyString(
|
|
await mpvClient.requestProperty('stream-open-filename'),
|
|
);
|
|
if (streamOpenFilename?.startsWith('edl://')) {
|
|
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
|
if (preferredUrl) {
|
|
const result = await toResolvedMediaGenerationInput(
|
|
mpvClient,
|
|
preferredUrl,
|
|
kind,
|
|
'edl-stream',
|
|
true,
|
|
);
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
const result = await toResolvedMediaGenerationInput(
|
|
mpvClient,
|
|
streamOpenFilename,
|
|
kind,
|
|
'stream-open-filename',
|
|
false,
|
|
);
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
if (streamOpenFilename) {
|
|
const result = await toResolvedMediaGenerationInput(
|
|
mpvClient,
|
|
streamOpenFilename,
|
|
kind,
|
|
'stream-open-filename',
|
|
isRemoteMediaPath(streamOpenFilename),
|
|
);
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
} catch {
|
|
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
|
}
|
|
|
|
const result = await toResolvedMediaGenerationInput(
|
|
mpvClient,
|
|
currentVideoPath,
|
|
kind,
|
|
'current-path',
|
|
false,
|
|
);
|
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
|
return result;
|
|
}
|
|
|
|
export async function resolveMediaGenerationInputPath(
|
|
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
|
kind: MediaGenerationKind = 'video',
|
|
): Promise<string | null> {
|
|
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
|
if (!currentVideoPath) {
|
|
return null;
|
|
}
|
|
|
|
if (!isRemoteMediaPath(currentVideoPath) || !mpvClient?.requestProperty) {
|
|
return currentVideoPath;
|
|
}
|
|
|
|
try {
|
|
const streamOpenFilename = trimToNonEmptyString(
|
|
await mpvClient.requestProperty('stream-open-filename'),
|
|
);
|
|
if (streamOpenFilename?.startsWith('edl://')) {
|
|
return resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind) ?? streamOpenFilename;
|
|
}
|
|
if (streamOpenFilename) {
|
|
return streamOpenFilename;
|
|
}
|
|
} catch {
|
|
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
|
}
|
|
|
|
return currentVideoPath;
|
|
}
|