diff --git a/CHANGELOG.md b/CHANGELOG.md index 3240d1a0..03b612e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`, 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. diff --git a/README.md b/README.md index b3fef966..d5f0deb2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 631991d1..395432b9 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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 `_retimed.`. | +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`. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index ca9821c5..a2f7508e 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -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"** diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts index 8f511bc9..c00bb65d 100644 --- a/src/anki-integration/media-source.ts +++ b/src/anki-integration/media-source.ts @@ -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, 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, - name: string, -): Promise { - if (!mpvClient.requestProperty) { - return null; - } - - try { - return await mpvClient.requestProperty(name); - } catch { - return null; - } -} - -async function requestFirstNonEmptyStringProperty( - mpvClient: Pick, - names: readonly string[], -): Promise { - for (const name of names) { - const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name)); - if (value) { - return value; - } - } - return null; -} - async function resolveRemoteInputOptions( mpvClient: Pick, resolvedPath: string, @@ -268,24 +179,7 @@ async function resolveRemoteInputOptions( return undefined; } - const headers: Record = {}; - 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'); diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 1cad075d..4fed0a63 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -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 { diff --git a/src/core/services/mpv-http-headers.ts b/src/core/services/mpv-http-headers.ts new file mode 100644 index 00000000..092f7ad0 --- /dev/null +++ b/src/core/services/mpv-http-headers.ts @@ -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; +} + +export interface ResolvedMpvHttpHeaders { + headers: Record; + 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, + 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 { + if (!client.requestProperty) { + return null; + } + + try { + return await client.requestProperty(name); + } catch { + return null; + } +} + +async function requestFirstNonEmptyStringProperty( + client: MpvHttpPropertySource, + names: readonly string[], +): Promise { + 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 { + const headers: Record = {}; + 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 { + 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; +} diff --git a/src/core/services/secondary-subtitle-sidecar.ts b/src/core/services/secondary-subtitle-sidecar.ts index 988db6f7..15169247 100644 --- a/src/core/services/secondary-subtitle-sidecar.ts +++ b/src/core/services/secondary-subtitle-sidecar.ts @@ -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 { diff --git a/src/core/services/subsync-extract.test.ts b/src/core/services/subsync-extract.test.ts new file mode 100644 index 00000000..5fdd939d --- /dev/null +++ b/src/core/services/subsync-extract.test.ts @@ -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; +} + +async function startSubtitleServer(): Promise { + 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((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((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 }); + } +}); diff --git a/src/core/services/subsync-extract.ts b/src/core/services/subsync-extract.ts new file mode 100644 index 00000000..31870e50 --- /dev/null +++ b/src/core/services/subsync-extract.ts @@ -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 { + 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 { + 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 { + 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 {} +} diff --git a/src/core/services/subsync-remote.test.ts b/src/core/services/subsync-remote.test.ts new file mode 100644 index 00000000..06c205ed --- /dev/null +++ b/src/core/services/subsync-remote.test.ts @@ -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; +}> { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(SUBTITLE_BODY); + }); + await new Promise((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((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> = []; + + const deps: Pick = { + 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', 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 }); + } +}); diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index 053076f2..38a92efe 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -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 { - 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,28 @@ async function subsyncToReference( context: SubsyncContext, resolved: SubsyncResolvedConfig, client: MpvClientLike, + httpHeaders: ResolvedMpvHttpHeaders | null, ): Promise { - const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); - const primaryExtraction = await extractSubtitleTrackToFile( + const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'); + const primaryExtraction = await extractSubtitleTrackToFile({ ffmpegPath, - context.videoPath, - context.primaryTrack, - ); + 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 +243,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 +251,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 +276,58 @@ 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'); + const ffmpegPath = resolveSubsyncExecutable(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({ + ffmpegPath, + videoPath: context.videoPath, + track: sourceTrack, + httpHeaders, + }); + return await subsyncToReference( + 'alass', + sourceExtraction.path, + context, + resolved, + client, + httpHeaders, + ); } finally { if (sourceExtraction) { cleanupTemporaryFile(sourceExtraction); diff --git a/src/subsync/executables.test.ts b/src/subsync/executables.test.ts new file mode 100644 index 00000000..061bcd6f --- /dev/null +++ b/src/subsync/executables.test.ts @@ -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']), ''); + }); +}); diff --git a/src/subsync/executables.ts b/src/subsync/executables.ts new file mode 100644 index 00000000..2f0aaee4 --- /dev/null +++ b/src/subsync/executables.ts @@ -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); +} diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index b22e9927..ad29369e 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -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,35 @@ function resolveCommandInvocation( return { command: executable, args }; } +/** + * An unset path stays empty here on purpose: hard-coding `/usr/bin/` 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 {