mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(subsync): sync subtitles from stream URLs, auto-discover tool paths
- Download URL-loaded subtitle tracks (Aniyomi extension streams, Jellyfin) to a temp file first, reusing mpv's own request headers, instead of rejecting them with "Subtitle file not found" - Pass mpv's headers through to ffmpeg for internal track extraction from streams too - Auto-discover alass/ffsubsync/ffmpeg on PATH and common install prefixes when the config path is empty, instead of a hard-coded /usr/bin fallback that never exists on macOS - Log subsync failures to the app log, not just a toast that vanishes in seconds - Update docs-site and README credits for the new behavior
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Read back the HTTP request options mpv is using for the current stream.
|
||||
*
|
||||
* Anything SubMiner fetches out-of-band — ffmpeg extractions, subtitle
|
||||
* downloads — has to speak to the origin the same way mpv did, or an
|
||||
* authenticated or referer-gated host answers 403 to us while playback works.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
export interface MpvHttpPropertySource {
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ResolvedMpvHttpHeaders {
|
||||
headers: Record<string, string>;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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(
|
||||
client: MpvHttpPropertySource,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!client.requestProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await client.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFirstNonEmptyStringProperty(
|
||||
client: MpvHttpPropertySource,
|
||||
names: readonly string[],
|
||||
): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
const value = trimToNonEmptyString(await requestOptionalMpvProperty(client, name));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveMpvHttpHeaders(
|
||||
client: MpvHttpPropertySource,
|
||||
): Promise<ResolvedMpvHttpHeaders> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (!client.requestProperty) {
|
||||
return { headers, userAgent: null };
|
||||
}
|
||||
|
||||
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||
const mpvHeaderFields = toHeaderFields(await requestOptionalMpvProperty(client, propertyName));
|
||||
for (const field of mpvHeaderFields) {
|
||||
const parsed = parseMpvHeaderField(field);
|
||||
if (parsed) {
|
||||
headers[parsed[0]] = parsed[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = await requestFirstNonEmptyStringProperty(client, USER_AGENT_PROPERTY_NAMES);
|
||||
const referrer = await requestFirstNonEmptyStringProperty(client, REFERRER_PROPERTY_NAMES);
|
||||
if (referrer) {
|
||||
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||
}
|
||||
|
||||
return { headers, userAgent };
|
||||
}
|
||||
|
||||
/** Flatten to the header map a plain `http.get` wants. */
|
||||
export function toRequestHeaders(resolved: ResolvedMpvHttpHeaders | null): Record<string, string> {
|
||||
if (!resolved) return {};
|
||||
const headers = { ...resolved.headers };
|
||||
if (resolved.userAgent) {
|
||||
setHeaderIfMissing(headers, 'User-Agent', resolved.userAgent);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg input options carrying the same request context. These configure the
|
||||
* HTTP demuxer, so they must precede `-i` on the command line.
|
||||
*/
|
||||
export function toFfmpegInputHttpArgs(resolved: ResolvedMpvHttpHeaders | null): string[] {
|
||||
if (!resolved) return [];
|
||||
|
||||
const args: string[] = [];
|
||||
if (resolved.userAgent) {
|
||||
args.push('-user_agent', resolved.userAgent);
|
||||
}
|
||||
const fields = Object.entries(resolved.headers);
|
||||
if (fields.length > 0) {
|
||||
args.push('-headers', fields.map(([name, value]) => `${name}: ${value}\r\n`).join(''));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
Reference in New Issue
Block a user