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:
2026-08-01 17:20:45 -07:00
parent 30b497d3b4
commit dfd07960f7
15 changed files with 943 additions and 320 deletions
+2 -108
View File
@@ -1,6 +1,7 @@
import { isRemoteMediaPath } from '../jimaku/utils';
import type { MediaInput, MediaInputOptions } from '../media-input';
import type { MpvClient } from '../types/runtime';
import { resolveMpvHttpHeaders, setHeaderIfMissing } from '../core/services/mpv-http-headers';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
export type MediaGenerationKind = 'audio' | 'video';
@@ -37,23 +38,6 @@ export function resolveAudioStreamIndexForMediaGeneration(
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;
@@ -62,17 +46,6 @@ function trimToNonEmptyString(value: unknown): string | null {
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))
@@ -198,68 +171,6 @@ function logMediaGenerationInputMiss(
);
}
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,
@@ -268,24 +179,7 @@ async function resolveRemoteInputOptions(
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);
}
const { headers, userAgent } = await resolveMpvHttpHeaders(mpvClient);
if (isGoogleVideoMediaPath(resolvedPath)) {
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
+5
View File
@@ -4,6 +4,9 @@ import {
SubsyncManualRunRequest,
SubsyncResult,
} from '../../types';
import { createLogger } from '../../logger';
const logger = createLogger('subsync');
export interface HandleMpvCommandFromIpcOptions {
specialCommands: {
@@ -193,6 +196,8 @@ export async function runSubsyncManualFromIpc(
return result;
} catch (error) {
const message = `Subsync failed: ${(error as Error).message}`;
// An OSD toast is the only other trace of this, and it is gone in seconds.
logger.error(message, error);
options.showMpvOsd(message);
return { ok: false, message };
} finally {
+173
View File
@@ -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;
}
@@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync }
import os from 'node:os';
import path from 'node:path';
import { runCommand, type CommandResult } from '../../subsync/utils';
import { resolveExecutable, SUBSYNC_EXECUTABLE_NAMES } from '../../subsync/executables';
import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js';
@@ -11,13 +12,6 @@ const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']);
const TIMING_TOLERANCE_SECONDS = 0.25;
const SAME_TIMING_EPSILON_SECONDS = 0.001;
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
const FALLBACK_ALASS_PATHS = [
'/opt/homebrew/bin/alass-cli',
'/opt/homebrew/bin/alass',
'/usr/local/bin/alass-cli',
'/usr/local/bin/alass',
'/usr/bin/alass',
];
type SidecarCandidate = {
path: string;
@@ -74,61 +68,8 @@ function expandPreferredLanguages(
return unique(expanded);
}
function isExecutableFile(filePath: string): boolean {
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function pathEntries(): string[] {
const entries = (process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]);
}
function executableNames(name: string): string[] {
if (process.platform !== 'win32') return [name];
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
if (path.extname(name)) return [name];
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
function findExecutable(names: readonly string[]): string {
for (const name of names) {
if (path.dirname(name) !== '.') {
return isExecutableFile(name) ? name : '';
}
}
for (const dir of pathEntries()) {
for (const name of names) {
for (const executableName of executableNames(name)) {
const candidate = path.join(dir, executableName);
if (isExecutableFile(candidate)) return candidate;
}
}
}
for (const candidate of FALLBACK_ALASS_PATHS) {
if (isExecutableFile(candidate)) return candidate;
}
return '';
}
function resolveAlassPath(configuredPath: string | null | undefined): string {
const trimmed = configuredPath?.trim() ?? '';
if (trimmed) {
return findExecutable([trimmed]);
}
return findExecutable(['alass', 'alass-cli']);
return resolveExecutable(configuredPath, SUBSYNC_EXECUTABLE_NAMES.alass);
}
function fileSignature(filePath: string): string | null {
+175
View File
@@ -0,0 +1,175 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { cleanupTemporaryFile, extractSubtitleTrackToFile } from './subsync-extract';
import type { ResolvedMpvHttpHeaders } from './mpv-http-headers';
const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
interface StubServer {
url: (pathname: string) => string;
requests: Array<{ url: string; headers: http.IncomingHttpHeaders }>;
close: () => Promise<void>;
}
async function startSubtitleServer(): Promise<StubServer> {
const requests: StubServer['requests'] = [];
const server = http.createServer((req, res) => {
requests.push({ url: req.url ?? '', headers: req.headers });
if (req.url?.startsWith('/forbidden')) {
res.writeHead(403);
res.end('nope');
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(SUBTITLE_BODY);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
return {
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
requests,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
test('extractSubtitleTrackToFile downloads an external track served over http', async () => {
const server = await startSubtitleServer();
try {
const result = await extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: server.url('/stream.mp4'),
track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') },
httpHeaders: null,
});
assert.equal(result.temporary, true);
assert.equal(path.extname(result.path), '.srt');
assert.equal(fs.readFileSync(result.path, 'utf8'), SUBTITLE_BODY);
cleanupTemporaryFile(result);
assert.equal(fs.existsSync(result.path), false);
} finally {
await server.close();
}
});
test('extractSubtitleTrackToFile forwards mpv request headers to the subtitle host', async () => {
const server = await startSubtitleServer();
const httpHeaders: ResolvedMpvHttpHeaders = {
headers: { Referer: 'https://example.test/watch' },
userAgent: 'SubMinerTest/1.0',
};
try {
const result = await extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: server.url('/stream.mp4'),
track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') },
httpHeaders,
});
cleanupTemporaryFile(result);
const sent = server.requests.at(-1);
assert.equal(sent?.headers.referer, 'https://example.test/watch');
assert.equal(sent?.headers['user-agent'], 'SubMinerTest/1.0');
} finally {
await server.close();
}
});
test('extractSubtitleTrackToFile reports the HTTP status when the download fails', async () => {
const server = await startSubtitleServer();
try {
await assert.rejects(
extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: server.url('/stream.mp4'),
track: {
id: 2,
type: 'sub',
external: true,
'external-filename': server.url('/forbidden/ja.srt'),
},
httpHeaders: null,
}),
/Failed to download subtitle track.*403/s,
);
} finally {
await server.close();
}
});
test('extractSubtitleTrackToFile still uses a local external track in place', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const localPath = path.join(dir, 'local.srt');
fs.writeFileSync(localPath, SUBTITLE_BODY);
try {
const result = await extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: path.join(dir, 'video.mkv'),
track: { id: 2, type: 'sub', external: true, 'external-filename': localPath },
httpHeaders: null,
});
assert.deepEqual(result, { path: localPath, temporary: false });
// A borrowed file must survive cleanup.
cleanupTemporaryFile(result);
assert.equal(fs.existsSync(localPath), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('extractSubtitleTrackToFile rejects a missing local external track', async () => {
await assert.rejects(
extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: '/tmp/video.mkv',
track: { id: 2, type: 'sub', external: true, 'external-filename': '/tmp/does-not-exist.srt' },
httpHeaders: null,
}),
/Subtitle file not found/,
);
});
test('cleanupTemporaryFile preserves the retimed output sharing the temp directory', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const sourcePath = path.join(dir, 'remote_track.srt');
const retimedPath = path.join(dir, 'remote_track_retimed.srt');
fs.writeFileSync(sourcePath, SUBTITLE_BODY);
fs.writeFileSync(retimedPath, SUBTITLE_BODY);
try {
cleanupTemporaryFile({ path: sourcePath, temporary: true }, retimedPath);
assert.equal(fs.existsSync(sourcePath), false);
assert.equal(fs.existsSync(retimedPath), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupTemporaryFile keeps the file when it is itself the preserved output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const inPlacePath = path.join(dir, 'remote_track.srt');
fs.writeFileSync(inPlacePath, SUBTITLE_BODY);
try {
cleanupTemporaryFile({ path: inPlacePath, temporary: true }, inPlacePath);
assert.equal(fs.existsSync(inPlacePath), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+163
View File
@@ -0,0 +1,163 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
codecToExtension,
fileExists,
MpvTrack,
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 {
ffmpegPath: 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;
}
}
/**
* 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-'));
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 };
}
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
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.ffmpegPath, [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
...httpArgs,
'-an',
'-vn',
'-i',
input.videoPath,
'-map',
`0:${ffIndex}`,
'-f',
extension,
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 };
}
export async function extractSubtitleTrackToFile(
input: SubtitleExtractionInput,
): Promise<FileExtractionResult> {
if (input.track.external) {
const externalPath = input.track['external-filename'];
if (typeof externalPath !== 'string' || externalPath.length === 0) {
throw new Error('External subtitle track has no file path');
}
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 {}
}
+121
View File
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { runSubsyncManual } from './subsync';
import type { TriggerSubsyncFromConfigDeps } from './subsync';
const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
/**
* Extension-backed and Jellyfin playback add subtitles by URL, which subsync
* used to reject outright with "Subtitle file not found: https://…".
*/
async function startStubHost(): Promise<{
url: (pathname: string) => string;
close: () => Promise<void>;
}> {
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(SUBTITLE_BODY);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
return {
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
test('runSubsyncManual syncs stream subtitle tracks served over http', async (t) => {
if (process.platform === 'win32') {
t.skip('stub shell scripts are not executable on Windows');
return;
}
const host = await startStubHost();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-remote-'));
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
// Record argv and copy the inputs aside — subsync deletes its temp files as
// soon as the run finishes — then write the third argument (alass's output).
fs.writeFileSync(
alassPath,
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\ncp "$1" "${tmpDir}/reference.copy"\ncp "$2" "${tmpDir}/target.copy"\nprintf '%s' "retimed" > "$3"\nexit 0\n`,
{ mode: 0o755 },
);
const primaryUrl = host.url('/subs/ja.srt');
const sourceUrl = host.url('/subs/en.srt');
const sentCommands: Array<Array<string | number>> = [];
const deps: Pick<TriggerSubsyncFromConfigDeps, 'getMpvClient' | 'getResolvedConfig'> = {
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
},
requestProperty: async (name: string) => {
if (name === 'path') return host.url('/stream/video.mp4');
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
lang: 'ja',
'external-filename': primaryUrl,
},
{
id: 2,
type: 'sub',
selected: false,
external: true,
lang: 'en',
'external-filename': sourceUrl,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath: '',
ffmpegPath: '',
replace: true,
}),
};
try {
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
assert.equal(result.ok, true, result.message);
assert.equal(result.message, 'Subtitle synchronized with alass');
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
// reference, target, output — both inputs were pulled down from the host.
assert.equal(alassArgs.length, 3);
assert.equal(fs.readFileSync(path.join(tmpDir, 'reference.copy'), 'utf8'), SUBTITLE_BODY);
assert.equal(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), SUBTITLE_BODY);
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
assert.equal(loadCommand?.[1], alassArgs[2]);
assert.equal(fs.readFileSync(String(loadCommand?.[1]), 'utf8'), 'retimed');
} finally {
await host.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
+66 -118
View File
@@ -1,45 +1,37 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
import {
CommandResult,
codecToExtension,
fileExists,
formatTrackLabel,
getTrackById,
hasPathSeparators,
MpvTrack,
runCommand,
summarizeCommandFailure,
SubsyncContext,
SubsyncResolvedConfig,
} from '../../subsync/utils';
import {
resolveExecutable,
SubsyncExecutable,
SUBSYNC_EXECUTABLE_NAMES,
} from '../../subsync/executables';
import {
cleanupTemporaryFile,
extractSubtitleTrackToFile,
FileExtractionResult,
} from './subsync-extract';
import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
interface FileExtractionResult {
path: string;
temporary: boolean;
}
const logger = createLogger('subsync');
type SubtitleSlot = 'primary' | 'secondary';
const SYNCED_TRACK_LOOKUP_ATTEMPTS = 5;
const SYNCED_TRACK_LOOKUP_RETRY_MS = 100;
function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [
`code=${result.code ?? 'n/a'}`,
result.stderr ? `stderr: ${result.stderr}` : '',
result.stdout ? `stdout: ${result.stdout}` : '',
result.error ? `error: ${result.error}` : '',
]
.map((value) => value.trim())
.filter(Boolean);
if (parts.length === 0) return `command failed (${command})`;
return `command failed (${command}) ${parts.join(' | ')}`;
}
interface MpvClientLike {
connected: boolean;
currentAudioStreamIndex: number | null;
@@ -178,86 +170,19 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
};
}
function ensureExecutablePath(pathOrName: string, name: string): string {
if (!pathOrName) {
throw new Error(`Missing ${name} path in config`);
}
/**
* Resolve a sync tool, discovering it on PATH (plus the usual install prefixes)
* when the config leaves it unset.
*/
function resolveSubsyncExecutable(configuredPath: string, name: SubsyncExecutable): string {
const resolved = resolveExecutable(configuredPath, SUBSYNC_EXECUTABLE_NAMES[name]);
if (resolved) return resolved;
if (hasPathSeparators(pathOrName) && !fileExists(pathOrName)) {
throw new Error(`Configured ${name} executable not found: ${pathOrName}`);
}
return pathOrName;
}
async function extractSubtitleTrackToFile(
ffmpegPath: string,
videoPath: string,
track: MpvTrack,
): Promise<FileExtractionResult> {
if (track.external) {
const externalPath = track['external-filename'];
if (typeof externalPath !== 'string' || externalPath.length === 0) {
throw new Error('External subtitle track has no file path');
}
if (!fileExists(externalPath)) {
throw new Error(`Subtitle file not found: ${externalPath}`);
}
return { path: externalPath, temporary: false };
}
const ffIndex = track['ff-index'];
const extension = codecToExtension(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: ${track.codec ?? 'unknown'}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
const extraction = await runCommand(ffmpegPath, [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
'-map',
`0:${ffIndex}`,
'-f',
extension,
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 };
}
function cleanupTemporaryFile(extraction: FileExtractionResult): void {
if (!extraction.temporary) 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 {}
throw new Error(
configuredPath.trim()
? `Configured ${name} executable not found: ${configuredPath}`
: `Could not find ${name}. Install it or set subsync.${name}_path in your config.`,
);
}
function buildRetimedPath(subPath: string, replace: boolean): string {
@@ -366,23 +291,28 @@ async function subsyncToReference(
resolved: SubsyncResolvedConfig,
client: MpvClientLike,
slot: SubtitleSlot,
httpHeaders: ResolvedMpvHttpHeaders | null,
): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const targetExtraction = await extractSubtitleTrackToFile(
const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg');
const targetExtraction = await extractSubtitleTrackToFile({
ffmpegPath,
context.videoPath,
targetTrack,
);
videoPath: context.videoPath,
track: targetTrack,
httpHeaders,
});
const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
logger.info(
`Running ${engine}: target=${targetExtraction.path} reference=${referenceFilePath} output=${outputPath}`,
);
try {
let result: CommandResult;
if (engine === 'alass') {
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
} else {
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
@@ -394,6 +324,7 @@ async function subsyncToReference(
if (!result.ok || !fileExists(outputPath)) {
const details = summarizeCommandFailure(engine, result);
logger.error(`${engine} synchronization failed: ${details}`);
return {
ok: false,
message: `${engine} synchronization failed: ${details}`,
@@ -401,12 +332,13 @@ async function subsyncToReference(
}
await loadSyncedSubtitle(client, outputPath, slot);
logger.info(`${engine} synchronization succeeded; loaded ${outputPath}`);
return {
ok: true,
message: `Subtitle synchronized with ${engine}`,
};
} finally {
cleanupTemporaryFile(targetExtraction);
cleanupTemporaryFile(targetExtraction, outputPath);
}
}
@@ -444,6 +376,18 @@ export async function runSubsyncManual(
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
// Only streams need mpv's request context, and asking costs four round-trips.
const httpHeaders = isRemoteMediaPath(context.videoPath)
? await resolveMpvHttpHeaders(client)
: null;
logger.info(
`Manual subsync requested: engine=${request.engine} referenceMode=${
request.referenceMode ?? 'track'
} referenceTrackId=${request.referenceTrackId ?? 'none'} targetTrackId=${
request.targetTrackId ?? 'none'
} remote=${httpHeaders !== null}`,
);
const targetTrack = resolveTargetTrack(request, context);
if (!targetTrack) {
@@ -455,10 +399,9 @@ export async function runSubsyncManual(
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
const message = `ffsubsync synchronization failed: ${(error as Error).message}`;
logger.error(message);
return { ok: false, message };
}
return subsyncToReference(
'ffsubsync',
@@ -468,6 +411,7 @@ export async function runSubsyncManual(
resolved,
client,
targetSlot,
httpHeaders,
);
}
@@ -487,25 +431,28 @@ export async function runSubsyncManual(
resolved,
client,
targetSlot,
httpHeaders,
);
}
const referenceTrack = getTrackById(context.subtitleTracks, request.referenceTrackId ?? null);
if (!referenceTrack) {
logger.warn('Manual subsync rejected: no alass reference track selected');
return { ok: false, message: 'Select a reference subtitle track for alass' };
}
if (referenceTrack.id === targetTrack.id) {
return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' };
}
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg');
let referenceExtraction: FileExtractionResult | null = null;
try {
referenceExtraction = await extractSubtitleTrackToFile(
referenceExtraction = await extractSubtitleTrackToFile({
ffmpegPath,
context.videoPath,
referenceTrack,
);
videoPath: context.videoPath,
track: referenceTrack,
httpHeaders,
});
return await subsyncToReference(
'alass',
referenceExtraction.path,
@@ -514,6 +461,7 @@ export async function runSubsyncManual(
resolved,
client,
targetSlot,
httpHeaders,
);
} finally {
if (referenceExtraction) {
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { findExecutable, resolveExecutable, SUBSYNC_EXECUTABLE_NAMES } from './executables';
import { getSubsyncConfig } from './utils';
function withTempBin(run: (dir: string) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-executables-'));
const previousPath = process.env.PATH;
try {
process.env.PATH = dir;
run(dir);
} finally {
process.env.PATH = previousPath;
fs.rmSync(dir, { recursive: true, force: true });
}
}
function writeExecutable(dir: string, name: string): string {
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
return filePath;
}
test('getSubsyncConfig leaves unset tool paths empty instead of guessing /usr/bin', () => {
const resolved = getSubsyncConfig({ alass_path: '', ffsubsync_path: '', ffmpeg_path: '' });
assert.equal(resolved.alassPath, '');
assert.equal(resolved.ffsubsyncPath, '');
assert.equal(resolved.ffmpegPath, '');
});
test('getSubsyncConfig trims configured paths', () => {
const resolved = getSubsyncConfig({ alass_path: ' /opt/homebrew/bin/alass-cli ' });
assert.equal(resolved.alassPath, '/opt/homebrew/bin/alass-cli');
});
test('resolveExecutable discovers alass-cli on PATH when config is empty', () => {
withTempBin((dir) => {
const expected = writeExecutable(dir, 'alass-cli');
assert.equal(resolveExecutable('', SUBSYNC_EXECUTABLE_NAMES.alass), expected);
assert.equal(resolveExecutable(undefined, SUBSYNC_EXECUTABLE_NAMES.alass), expected);
});
});
test('resolveExecutable prefers alass over alass-cli when both exist', () => {
withTempBin((dir) => {
const expected = writeExecutable(dir, 'alass');
writeExecutable(dir, 'alass-cli');
assert.equal(resolveExecutable('', SUBSYNC_EXECUTABLE_NAMES.alass), expected);
});
});
test('resolveExecutable honours an explicit path and does not fall back when it is missing', () => {
withTempBin((dir) => {
writeExecutable(dir, 'alass');
assert.equal(resolveExecutable('/nope/alass', SUBSYNC_EXECUTABLE_NAMES.alass), '');
});
});
test('resolveExecutable treats a bare configured name as a PATH lookup', () => {
withTempBin((dir) => {
const expected = writeExecutable(dir, 'ffmpeg');
assert.equal(resolveExecutable('ffmpeg', SUBSYNC_EXECUTABLE_NAMES.ffmpeg), expected);
});
});
test('findExecutable returns empty when nothing matches', () => {
withTempBin(() => {
assert.equal(findExecutable(['definitely-not-a-real-binary-xyz']), '');
});
});
+91
View File
@@ -0,0 +1,91 @@
import * as fs from 'fs';
import * as path from 'path';
/**
* A GUI launch inherits a minimal PATH that omits the package-manager prefixes
* users actually install these tools under, so PATH alone cannot find them.
* Probing the usual prefixes is what makes "leave the config empty and we will
* discover it" true rather than aspirational.
*/
const FALLBACK_BIN_DIRS = [
'/opt/homebrew/bin',
'/usr/local/bin',
'/opt/local/bin',
'/usr/bin',
'/bin',
];
/** Names each tool ships under, in the order they should be preferred. */
export const SUBSYNC_EXECUTABLE_NAMES = {
alass: ['alass', 'alass-cli'],
ffsubsync: ['ffsubsync'],
ffmpeg: ['ffmpeg'],
} as const;
export type SubsyncExecutable = keyof typeof SUBSYNC_EXECUTABLE_NAMES;
function unique(values: string[]): string[] {
return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index);
}
export function isExecutableFile(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function searchDirectories(): string[] {
const entries = (process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
return unique([...entries, ...FALLBACK_BIN_DIRS]);
}
function executableNames(name: string): string[] {
if (process.platform !== 'win32') return [name];
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
if (path.extname(name)) return [name];
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
/**
* Resolve the first of `names` that exists, returning '' when none do.
*
* A name carrying a directory component is taken literally — the caller spelled
* out a path, so falling back to a same-named binary elsewhere would silently
* run something they did not point at.
*/
export function findExecutable(names: readonly string[]): string {
for (const name of names) {
if (path.dirname(name) !== '.') {
return isExecutableFile(name) ? name : '';
}
}
for (const dir of searchDirectories()) {
for (const name of names) {
for (const executableName of executableNames(name)) {
const candidate = path.join(dir, executableName);
if (isExecutableFile(candidate)) return candidate;
}
}
}
return '';
}
/** Resolve a configured tool path, discovering it when the config is empty. */
export function resolveExecutable(
configuredPath: string | null | undefined,
names: readonly string[],
): string {
const trimmed = configuredPath?.trim() ?? '';
if (trimmed) return findExecutable([trimmed]);
return findExecutable(names);
}
+22 -15
View File
@@ -23,12 +23,6 @@ export interface SubsyncResolvedConfig {
replace?: boolean;
}
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
alass: '/usr/bin/alass',
ffsubsync: '/usr/bin/ffsubsync',
ffmpeg: '/usr/bin/ffmpeg',
} as const;
export interface SubsyncContext {
videoPath: string;
primaryTrack: MpvTrack;
@@ -82,22 +76,35 @@ function resolveCommandInvocation(
return { command: executable, args };
}
/**
* An unset path stays empty here on purpose: hard-coding `/usr/bin/<tool>` made
* the documented "leave empty to auto-discover from PATH" a lie and broke every
* default-config macOS install, where none of these live in /usr/bin.
* Discovery happens at run time in `resolveSubsyncExecutable`.
*/
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
const resolvePath = (value: string | undefined, fallback: string): string => {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : fallback;
};
const trim = (value: string | undefined): string => value?.trim() ?? '';
return {
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
alassPath: trim(config?.alass_path),
ffsubsyncPath: trim(config?.ffsubsync_path),
ffmpegPath: trim(config?.ffmpeg_path),
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
};
}
export function hasPathSeparators(value: string): boolean {
return value.includes('/') || value.includes('\\');
export function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [
`code=${result.code ?? 'n/a'}`,
result.stderr ? `stderr: ${result.stderr}` : '',
result.stdout ? `stdout: ${result.stdout}` : '',
result.error ? `error: ${result.error}` : '',
]
.map((value) => value.trim())
.filter(Boolean);
if (parts.length === 0) return `command failed (${command})`;
return `command failed (${command}) ${parts.join(' | ')}`;
}
export function fileExists(pathOrEmpty: string): boolean {