Files
SubMiner/src/core/services/subsync-extract.ts
T
sudacode 51fc9034f7 fix(subsync): decode file:// tracks mpv reports for dropped subtitles
- Sync a subtitle dropped onto mpv without hitting "Protocol file: not supported"; decode file:// external-filename/path back to a real path for target, reference, and video
- Stream strip proxy: destroy the connection instead of retrying/502 once a response is handed off, and cap buffered playlist bodies
- Bridge installer: cap downloaded bundle size to guard against a lying/missing content-length
- Anime browser playback: bump generation on dispose so a stale in-flight playEpisode cleans up its own subtitle cache
- Fix a flaky sidecar-process test by binding to an OS-assigned port instead of pre-allocating one
2026-08-15 21:44:51 -07:00

197 lines
6.0 KiB
TypeScript

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
codecToExtension,
fileExists,
MpvTrack,
resolveLocalMediaPath,
runCommand,
summarizeCommandFailure,
} from '../../subsync/utils';
import { downloadToFile, isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
import {
ResolvedMpvHttpHeaders,
toFfmpegInputHttpArgs,
toRequestHeaders,
} from './mpv-http-headers';
const logger = createLogger('subsync');
export interface FileExtractionResult {
path: string;
temporary: boolean;
}
export interface SubtitleExtractionInput {
/**
* Resolved lazily: an external track never shells out to ffmpeg, so a stream
* whose subtitles arrive by URL must not fail just because ffmpeg is absent.
*/
resolveFfmpegPath: () => string;
videoPath: string;
track: MpvTrack;
/** mpv's request context, so stream-hosted tracks stay reachable. */
httpHeaders: ResolvedMpvHttpHeaders | null;
}
function extensionForUrl(url: string, fallback: string): string {
try {
const parsed = new URL(url);
const ext = path.extname(parsed.pathname).replace(/^\./, '').toLowerCase();
return /^[a-z0-9]{1,5}$/.test(ext) ? ext : fallback;
} catch {
return fallback;
}
}
/**
* Drop a scratch directory whose extraction never completed.
*
* Removal is recursive here — unlike `cleanupTemporaryFile`, nothing in a failed
* directory is worth keeping, and a half-written download would otherwise leave
* the directory behind for the life of the machine.
*/
function discardTemporaryDirectory(tempDir: string): void {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
/**
* Pull a subtitle track mpv loaded from a URL down to disk.
*
* Extension-backed and Jellyfin streams add their subtitles by URL, and neither
* alass nor ffmpeg can read one — the whole subsync path used to reject these
* with "Subtitle file not found: https://…".
*/
async function downloadRemoteSubtitleTrack(
url: string,
httpHeaders: ResolvedMpvHttpHeaders | null,
): Promise<FileExtractionResult> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
try {
const outputPath = path.join(tempDir, `remote_track.${extensionForUrl(url, 'srt')}`);
const result = await downloadToFile(url, outputPath, toRequestHeaders(httpHeaders));
if (!result.ok) {
throw new Error(
`Failed to download subtitle track: ${result.error?.error ?? 'unknown error'}`,
);
}
if (!fileExists(outputPath)) {
throw new Error(`Downloaded subtitle track is missing: ${url}`);
}
logger.info(`Downloaded remote subtitle track to ${outputPath}`);
return { path: outputPath, temporary: true };
} catch (error) {
discardTemporaryDirectory(tempDir);
throw error;
}
}
async function extractInternalTrack(input: SubtitleExtractionInput): Promise<FileExtractionResult> {
const ffIndex = input.track['ff-index'];
const extension = codecToExtension(input.track.codec);
if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) {
throw new Error('Internal subtitle track has no valid ff-index');
}
if (!extension) {
throw new Error(`Unsupported subtitle codec: ${input.track.codec ?? 'unknown'}`);
}
const ffmpegMuxer = extension === 'vtt' ? 'webvtt' : extension;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
try {
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
// Header args configure the HTTP demuxer, so they belong before `-i`.
const httpArgs = isRemoteMediaPath(input.videoPath)
? toFfmpegInputHttpArgs(input.httpHeaders)
: [];
const extraction = await runCommand(input.resolveFfmpegPath(), [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
...httpArgs,
'-an',
'-vn',
'-i',
input.videoPath,
'-map',
`0:${ffIndex}`,
'-f',
ffmpegMuxer,
outputPath,
]);
if (!extraction.ok || !fileExists(outputPath)) {
throw new Error(
`Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure(
'ffmpeg',
extraction,
)}`,
);
}
return { path: outputPath, temporary: true };
} catch (error) {
discardTemporaryDirectory(tempDir);
throw error;
}
}
export async function extractSubtitleTrackToFile(
input: SubtitleExtractionInput,
): Promise<FileExtractionResult> {
if (input.track.external) {
const externalFilename = input.track['external-filename'];
if (typeof externalFilename !== 'string' || externalFilename.length === 0) {
throw new Error('External subtitle track has no file path');
}
// A dropped subtitle arrives as a `file://` URL, which is local, not remote.
const externalPath = resolveLocalMediaPath(externalFilename);
if (isRemoteMediaPath(externalPath)) {
return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders);
}
if (!fileExists(externalPath)) {
throw new Error(`Subtitle file not found: ${externalPath}`);
}
return { path: externalPath, temporary: false };
}
return extractInternalTrack(input);
}
/**
* Drop a temporary extraction once alass/ffsubsync is done with it.
*
* `preservePath` is the retimed subtitle mpv is about to load. With
* `replace: false` it lands in this same temp directory, so the removal has to
* skip it — and the directory removal must stay non-recursive, since that is
* what keeps the retimed file alive for mpv.
*/
export function cleanupTemporaryFile(
extraction: FileExtractionResult,
preservePath?: string,
): void {
if (!extraction.temporary) return;
if (preservePath && path.resolve(preservePath) === path.resolve(extraction.path)) return;
try {
if (fileExists(extraction.path)) {
fs.unlinkSync(extraction.path);
}
} catch {}
try {
const dir = path.dirname(extraction.path);
if (fs.existsSync(dir)) {
fs.rmdirSync(dir);
}
} catch {}
}