mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
51fc9034f7
- 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
197 lines
6.0 KiB
TypeScript
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 {}
|
|
}
|