Compare commits

...
2 Commits
Author SHA1 Message Date
sudacode 558e00cb44 fix(subsync): resolve ffmpeg only when a track needs extraction
Stream subtitles arrive as URLs and are downloaded, never piped through ffmpeg, so demanding an ffmpeg install up front broke stream syncs (and CI, which has no ffmpeg). Resolution is now lazy, scoped to embedded tracks.
2026-08-01 17:35:59 -07:00
sudacode 4b85ad3eb4 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
2026-08-01 17:23:36 -07:00
16 changed files with 1033 additions and 322 deletions

No files matched your search

+3
View File
@@ -12,6 +12,9 @@
### Fixed
- Subtitle Sync On Streams: Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. Such a track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can now be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
- Subsync Tool Discovery: Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
- Subsync Logging: Subsync failures are now written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
- Anime Browser Playback Session: Launching a video from the anime browser now starts a regular SubMiner session (tray icon plus the on-demand overlay runtime), and in `subminer anime` standalone mode, closing the browser window during playback no longer quits the app and kills the stream — the window can be reopened from the tray while mpv keeps playing.
+20 -12
View File
@@ -256,19 +256,27 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
SubMiner builds on the work of these open-source projects:
| Project | Role |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
| Project | Role |
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
| [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
| [Mangatan](https://github.com/1Selxo/Mangatan) / [Mangayomi](https://github.com/kodjodevf/mangayomi) | Reference implementation for the extension bridge protocol |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
## License
[GNU General Public License v3.0](LICENSE)
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server)
(Mozilla Public License 2.0), downloaded at runtime rather than bundled. MPL-2.0 is
compatible with the GPL. Aniyomi and Mihon are Apache-2.0; Mangatan and Mangayomi are
GPL-3.0. SubMiner ships no extension repositories and bundles no extensions.
+7 -3
View File
@@ -1214,11 +1214,15 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
| Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `alass_path` | string path | Path to `alass` executable. Empty auto-discovers `alass` or `alass-cli`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty auto-discovers `ffsubsync`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` auto-discovers `ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Auto-discovery searches `PATH`, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/usr/bin`, `/bin`) — a GUI launch inherits a minimal `PATH` that often omits the first two. Set the option explicitly if your binary lives elsewhere.
Subtitle tracks that mpv loaded from a URL (Aniyomi extension streams, Jellyfin) are downloaded to a temporary file first, reusing mpv's own request headers, so they can be used as either the sync target or the alass reference.
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
+14 -3
View File
@@ -207,21 +207,26 @@ Resume playback and wait for the next subtitle to appear, then try mining again.
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
**"Configured alass executable not found"**
Subsync writes to the application log under the `subsync` scope, so the full command failure — exit code, stderr, resolved file paths — is recorded there as well as on the OSD.
**"Could not find alass" / "Configured alass executable not found"**
Install alass or configure the path:
- **Homebrew**: `brew install alass`
- **Arch Linux (AUR)**: `paru -S alass`
- **Cargo**: `cargo install alass-cli`
- Set the path: `subsync.alass_path` in your config.
**"Configured ffsubsync executable not found"**
Leaving the option empty searches `PATH` plus the usual install prefixes, and accepts either `alass` or `alass-cli`. Set the option explicitly when the binary lives somewhere else. The second message means the configured path itself does not exist — SubMiner never silently substitutes a different binary for one you named.
**"Could not find ffsubsync" / "Configured ffsubsync executable not found"**
Install ffsubsync or configure the path:
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
- **pip**: `pip install ffsubsync`
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config.
- Must be discoverable, or configured via `subsync.ffsubsync_path` in your config.
**"alass synchronization failed" / "ffsubsync synchronization failed"**
@@ -232,6 +237,12 @@ If subtitle sync fails (the error message is prefixed with the engine name):
- Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
**Syncing subtitles on a stream (Aniyomi extensions, Jellyfin)**
Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, reusing mpv's own request headers, so they work as either the sync target or the alass reference. Downloading a Japanese track from Jimaku or TsukiHime while a stream is playing also works — it becomes the primary track and therefore the sync target.
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
## TsukiHime
**"xz binary not found"**
+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');
@@ -1805,7 +1805,7 @@ describe('stats server API routes', () => {
Stale English subtitle
`;
fs.writeFileSync(sourcePath, 'fake media');
fs.writeFileSync(alassPath, 'fake alass');
fs.writeFileSync(alassPath, 'fake alass', { mode: 0o755 });
fs.writeFileSync(
japanesePath,
`1
@@ -1865,7 +1865,7 @@ Aligned English subtitle
const englishPath = path.join(dir, 'episode.en.srt');
const alassPath = path.join(dir, 'alass-cli');
fs.writeFileSync(sourcePath, 'fake media');
fs.writeFileSync(alassPath, 'fake alass');
fs.writeFileSync(alassPath, 'fake alass', { mode: 0o755 });
fs.writeFileSync(
japanesePath,
`1
+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 {
+178
View File
@@ -0,0 +1,178 @@
/**
* 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']);
// Least specific first: every list is merged in order and later entries
// overwrite earlier ones, so the file-local headers win.
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
'http-header-fields',
'options/http-header-fields',
'file-local-options/http-header-fields',
] as const;
// Most specific first: these resolve through requestFirstNonEmptyStringProperty,
// which stops at the first match, so the file-local value has to lead.
const USER_AGENT_PROPERTY_NAMES = [
'file-local-options/user-agent',
'options/user-agent',
'user-agent',
] as const;
// Most specific first, for the same first-match reason as the user agent.
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 {
+195
View File
@@ -0,0 +1,195 @@
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({
resolveFfmpegPath: () => {
throw new Error('external tracks must not resolve 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({
resolveFfmpegPath: () => {
throw new Error('external tracks must not resolve 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({
resolveFfmpegPath: () => {
throw new Error('external tracks must not resolve 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({
resolveFfmpegPath: () => {
throw new Error('external tracks must not resolve 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({
resolveFfmpegPath: () => {
throw new Error('external tracks must not resolve 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 });
}
});
+192
View File
@@ -0,0 +1,192 @@
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 {
/**
* 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 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',
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 };
} catch (error) {
discardTemporaryDirectory(tempDir);
throw error;
}
}
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 {}
}
+124
View File
@@ -0,0 +1,124 @@
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: '',
// Points nowhere on purpose: both tracks are external, so resolving ffmpeg
// at all would throw. An empty path would just auto-discover the real
// ffmpeg on a dev machine and prove nothing.
ffmpegPath: path.join(tmpDir, 'no-such-ffmpeg'),
replace: true,
}),
};
try {
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 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 });
}
});
+77 -118
View File
@@ -1,39 +1,31 @@
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;
}
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(' | ')}`;
}
const logger = createLogger('subsync');
interface MpvClientLike {
connected: boolean;
@@ -160,86 +152,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 {
@@ -285,23 +210,27 @@ async function subsyncToReference(
context: SubsyncContext,
resolved: SubsyncResolvedConfig,
client: MpvClientLike,
httpHeaders: ResolvedMpvHttpHeaders | null,
): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const primaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.primaryTrack,
);
const primaryExtraction = await extractSubtitleTrackToFile({
resolveFfmpegPath: () => resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'),
videoPath: context.videoPath,
track: context.primaryTrack,
httpHeaders,
});
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
logger.info(
`Running ${engine}: target=${primaryExtraction.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, primaryExtraction.path, outputPath);
} else {
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
@@ -313,6 +242,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}`,
@@ -320,12 +250,13 @@ async function subsyncToReference(
}
loadSyncedSubtitle(client, outputPath);
logger.info(`${engine} synchronization succeeded; loaded ${outputPath}`);
return {
ok: true,
message: `Subtitle synchronized with ${engine}`,
};
} finally {
cleanupTemporaryFile(primaryExtraction);
cleanupTemporaryFile(primaryExtraction, outputPath);
}
}
@@ -344,29 +275,57 @@ 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} sourceTrackId=${
request.sourceTrackId ?? 'none'
} remote=${httpHeaders !== null}`,
);
if (request.engine === 'ffsubsync') {
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', context.videoPath, context, resolved, client);
return subsyncToReference(
'ffsubsync',
context.videoPath,
context,
resolved,
client,
httpHeaders,
);
}
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
if (!sourceTrack) {
logger.warn('Manual subsync rejected: no alass source track selected');
return { ok: false, message: 'Select a subtitle source track for alass' };
}
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
let sourceExtraction: FileExtractionResult | null = null;
try {
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
sourceExtraction = await extractSubtitleTrackToFile({
resolveFfmpegPath: () => resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'),
videoPath: context.videoPath,
track: sourceTrack,
httpHeaders,
});
return await subsyncToReference(
'alass',
sourceExtraction.path,
context,
resolved,
client,
httpHeaders,
);
} finally {
if (sourceExtraction) {
cleanupTemporaryFile(sourceExtraction);
+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']), '');
});
});
+99
View File
@@ -0,0 +1,99 @@
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);
}
/**
* A same-named non-executable file earlier on PATH would otherwise shadow the
* real binary and surface as a spawn EACCES rather than "keep looking". Windows
* has no execute bit, so the regular-file check is all it can offer there.
*/
export function isExecutableFile(filePath: string): boolean {
try {
if (!fs.statSync(filePath).isFile()) return false;
if (process.platform === 'win32') return true;
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} 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);
}
+34 -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;
@@ -81,22 +75,47 @@ 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('\\');
/**
* ffsubsync streams a progress bar to stderr, so an unbounded summary reaches
* the OSD (and the error log) as tens of kilobytes of carriage returns. The tail
* is the part that says what went wrong.
*/
const COMMAND_OUTPUT_SUMMARY_LIMIT = 2000;
function tailForSummary(value: string): string {
if (value.length <= COMMAND_OUTPUT_SUMMARY_LIMIT) return value;
return `…${value.slice(-COMMAND_OUTPUT_SUMMARY_LIMIT)}`;
}
export function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [
`code=${result.code ?? 'n/a'}`,
result.stderr ? `stderr: ${tailForSummary(result.stderr)}` : '',
result.stdout ? `stdout: ${tailForSummary(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 {