mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
fix(subsync): sync subtitles from stream URLs, auto-discover tool paths
- Download URL-loaded subtitle tracks (Aniyomi extension streams, Jellyfin) to a temp file first, reusing mpv's own request headers, instead of rejecting them with "Subtitle file not found" - Pass mpv's headers through to ffmpeg for internal track extraction from streams too - Auto-discover alass/ffsubsync/ffmpeg on PATH and common install prefixes when the config path is empty, instead of a hard-coded /usr/bin fallback that never exists on macOS - Log subsync failures to the app log, not just a toast that vanishes in seconds - Update docs-site and README credits for the new behavior
This commit is contained in:
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
SubMiner builds on the work of these open-source projects:
|
||||||
|
|
||||||
| Project | Role |
|
| Project | Role |
|
||||||
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
| [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 |
|
| [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 |
|
| [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
|
||||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
| [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
|
||||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
| [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
|
## License
|
||||||
|
|
||||||
[GNU General Public License v3.0](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.
|
||||||
|
|||||||
@@ -1214,11 +1214,15 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
|
| `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 falls back to `/usr/bin/ffsubsync`. `ffsubsync` 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` falls back to `/usr/bin/ffmpeg`. |
|
| `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>`. |
|
| `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.
|
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`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
|
|||||||
@@ -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.
|
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:
|
Install alass or configure the path:
|
||||||
|
|
||||||
|
- **Homebrew**: `brew install alass`
|
||||||
- **Arch Linux (AUR)**: `paru -S alass`
|
- **Arch Linux (AUR)**: `paru -S alass`
|
||||||
- **Cargo**: `cargo install alass-cli`
|
- **Cargo**: `cargo install alass-cli`
|
||||||
- Set the path: `subsync.alass_path` in your config.
|
- 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:
|
Install ffsubsync or configure the path:
|
||||||
|
|
||||||
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
|
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
|
||||||
- **pip**: `pip install 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"**
|
**"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.
|
- 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).
|
- 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
|
## TsukiHime
|
||||||
|
|
||||||
**"xz binary not found"**
|
**"xz binary not found"**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||||
import type { MpvClient } from '../types/runtime';
|
import type { MpvClient } from '../types/runtime';
|
||||||
|
import { resolveMpvHttpHeaders, setHeaderIfMissing } from '../core/services/mpv-http-headers';
|
||||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||||
|
|
||||||
export type MediaGenerationKind = 'audio' | 'video';
|
export type MediaGenerationKind = 'audio' | 'video';
|
||||||
@@ -37,23 +38,6 @@ export function resolveAudioStreamIndexForMediaGeneration(
|
|||||||
return audioStreamIndex ?? undefined;
|
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 {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
@@ -62,17 +46,6 @@ function trimToNonEmptyString(value: unknown): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : 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[] {
|
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||||
return extractFileUrlsFromMpvEdlSource(source)
|
return extractFileUrlsFromMpvEdlSource(source)
|
||||||
.map((value) => trimToNonEmptyString(value))
|
.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(
|
async function resolveRemoteInputOptions(
|
||||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||||
resolvedPath: string,
|
resolvedPath: string,
|
||||||
@@ -268,24 +179,7 @@ async function resolveRemoteInputOptions(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const { headers, userAgent } = await resolveMpvHttpHeaders(mpvClient);
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (isGoogleVideoMediaPath(resolvedPath)) {
|
if (isGoogleVideoMediaPath(resolvedPath)) {
|
||||||
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
||||||
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
|
const logger = createLogger('subsync');
|
||||||
|
|
||||||
export interface HandleMpvCommandFromIpcOptions {
|
export interface HandleMpvCommandFromIpcOptions {
|
||||||
specialCommands: {
|
specialCommands: {
|
||||||
@@ -193,6 +196,8 @@ export async function runSubsyncManualFromIpc(
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = `Subsync failed: ${(error as Error).message}`;
|
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);
|
options.showMpvOsd(message);
|
||||||
return { ok: false, message };
|
return { ok: false, message };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Read back the HTTP request options mpv is using for the current stream.
|
||||||
|
*
|
||||||
|
* Anything SubMiner fetches out-of-band — ffmpeg extractions, subtitle
|
||||||
|
* downloads — has to speak to the origin the same way mpv did, or an
|
||||||
|
* authenticated or referer-gated host answers 403 to us while playback works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||||
|
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
||||||
|
'http-header-fields',
|
||||||
|
'options/http-header-fields',
|
||||||
|
'file-local-options/http-header-fields',
|
||||||
|
] as const;
|
||||||
|
const USER_AGENT_PROPERTY_NAMES = [
|
||||||
|
'file-local-options/user-agent',
|
||||||
|
'options/user-agent',
|
||||||
|
'user-agent',
|
||||||
|
] as const;
|
||||||
|
const REFERRER_PROPERTY_NAMES = [
|
||||||
|
'file-local-options/referrer',
|
||||||
|
'options/referrer',
|
||||||
|
'referrer',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface MpvHttpPropertySource {
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMpvHttpHeaders {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderName(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHeaderIfMissing(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMpvHeaderField(value: string): [string, string] | null {
|
||||||
|
const separatorIndex = value.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
||||||
|
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
||||||
|
if (!name || !headerValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHeaderFields(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOptionalMpvProperty(
|
||||||
|
client: MpvHttpPropertySource,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!client.requestProperty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await client.requestProperty(name);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestFirstNonEmptyStringProperty(
|
||||||
|
client: MpvHttpPropertySource,
|
||||||
|
names: readonly string[],
|
||||||
|
): Promise<string | null> {
|
||||||
|
for (const name of names) {
|
||||||
|
const value = trimToNonEmptyString(await requestOptionalMpvProperty(client, name));
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMpvHttpHeaders(
|
||||||
|
client: MpvHttpPropertySource,
|
||||||
|
): Promise<ResolvedMpvHttpHeaders> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (!client.requestProperty) {
|
||||||
|
return { headers, userAgent: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||||
|
const mpvHeaderFields = toHeaderFields(await requestOptionalMpvProperty(client, propertyName));
|
||||||
|
for (const field of mpvHeaderFields) {
|
||||||
|
const parsed = parseMpvHeaderField(field);
|
||||||
|
if (parsed) {
|
||||||
|
headers[parsed[0]] = parsed[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = await requestFirstNonEmptyStringProperty(client, USER_AGENT_PROPERTY_NAMES);
|
||||||
|
const referrer = await requestFirstNonEmptyStringProperty(client, REFERRER_PROPERTY_NAMES);
|
||||||
|
if (referrer) {
|
||||||
|
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers, userAgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten to the header map a plain `http.get` wants. */
|
||||||
|
export function toRequestHeaders(resolved: ResolvedMpvHttpHeaders | null): Record<string, string> {
|
||||||
|
if (!resolved) return {};
|
||||||
|
const headers = { ...resolved.headers };
|
||||||
|
if (resolved.userAgent) {
|
||||||
|
setHeaderIfMissing(headers, 'User-Agent', resolved.userAgent);
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ffmpeg input options carrying the same request context. These configure the
|
||||||
|
* HTTP demuxer, so they must precede `-i` on the command line.
|
||||||
|
*/
|
||||||
|
export function toFfmpegInputHttpArgs(resolved: ResolvedMpvHttpHeaders | null): string[] {
|
||||||
|
if (!resolved) return [];
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
if (resolved.userAgent) {
|
||||||
|
args.push('-user_agent', resolved.userAgent);
|
||||||
|
}
|
||||||
|
const fields = Object.entries(resolved.headers);
|
||||||
|
if (fields.length > 0) {
|
||||||
|
args.push('-headers', fields.map(([name, value]) => `${name}: ${value}\r\n`).join(''));
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync }
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { runCommand, type CommandResult } from '../../subsync/utils';
|
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 { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
|
||||||
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.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 TIMING_TOLERANCE_SECONDS = 0.25;
|
||||||
const SAME_TIMING_EPSILON_SECONDS = 0.001;
|
const SAME_TIMING_EPSILON_SECONDS = 0.001;
|
||||||
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
|
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 = {
|
type SidecarCandidate = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -74,61 +68,8 @@ function expandPreferredLanguages(
|
|||||||
return unique(expanded);
|
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 {
|
function resolveAlassPath(configuredPath: string | null | undefined): string {
|
||||||
const trimmed = configuredPath?.trim() ?? '';
|
return resolveExecutable(configuredPath, SUBSYNC_EXECUTABLE_NAMES.alass);
|
||||||
if (trimmed) {
|
|
||||||
return findExecutable([trimmed]);
|
|
||||||
}
|
|
||||||
return findExecutable(['alass', 'alass-cli']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileSignature(filePath: string): string | null {
|
function fileSignature(filePath: string): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { cleanupTemporaryFile, extractSubtitleTrackToFile } from './subsync-extract';
|
||||||
|
import type { ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
||||||
|
|
||||||
|
const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
|
||||||
|
|
||||||
|
interface StubServer {
|
||||||
|
url: (pathname: string) => string;
|
||||||
|
requests: Array<{ url: string; headers: http.IncomingHttpHeaders }>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSubtitleServer(): Promise<StubServer> {
|
||||||
|
const requests: StubServer['requests'] = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
requests.push({ url: req.url ?? '', headers: req.headers });
|
||||||
|
if (req.url?.startsWith('/forbidden')) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end('nope');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(SUBTITLE_BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
|
||||||
|
requests,
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('extractSubtitleTrackToFile downloads an external track served over http', async () => {
|
||||||
|
const server = await startSubtitleServer();
|
||||||
|
try {
|
||||||
|
const result = await extractSubtitleTrackToFile({
|
||||||
|
ffmpegPath: '/unused/ffmpeg',
|
||||||
|
videoPath: server.url('/stream.mp4'),
|
||||||
|
track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') },
|
||||||
|
httpHeaders: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.temporary, true);
|
||||||
|
assert.equal(path.extname(result.path), '.srt');
|
||||||
|
assert.equal(fs.readFileSync(result.path, 'utf8'), SUBTITLE_BODY);
|
||||||
|
|
||||||
|
cleanupTemporaryFile(result);
|
||||||
|
assert.equal(fs.existsSync(result.path), false);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractSubtitleTrackToFile forwards mpv request headers to the subtitle host', async () => {
|
||||||
|
const server = await startSubtitleServer();
|
||||||
|
const httpHeaders: ResolvedMpvHttpHeaders = {
|
||||||
|
headers: { Referer: 'https://example.test/watch' },
|
||||||
|
userAgent: 'SubMinerTest/1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await extractSubtitleTrackToFile({
|
||||||
|
ffmpegPath: '/unused/ffmpeg',
|
||||||
|
videoPath: server.url('/stream.mp4'),
|
||||||
|
track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') },
|
||||||
|
httpHeaders,
|
||||||
|
});
|
||||||
|
cleanupTemporaryFile(result);
|
||||||
|
|
||||||
|
const sent = server.requests.at(-1);
|
||||||
|
assert.equal(sent?.headers.referer, 'https://example.test/watch');
|
||||||
|
assert.equal(sent?.headers['user-agent'], 'SubMinerTest/1.0');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractSubtitleTrackToFile reports the HTTP status when the download fails', async () => {
|
||||||
|
const server = await startSubtitleServer();
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
extractSubtitleTrackToFile({
|
||||||
|
ffmpegPath: '/unused/ffmpeg',
|
||||||
|
videoPath: server.url('/stream.mp4'),
|
||||||
|
track: {
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
external: true,
|
||||||
|
'external-filename': server.url('/forbidden/ja.srt'),
|
||||||
|
},
|
||||||
|
httpHeaders: null,
|
||||||
|
}),
|
||||||
|
/Failed to download subtitle track.*403/s,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractSubtitleTrackToFile still uses a local external track in place', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
|
||||||
|
const localPath = path.join(dir, 'local.srt');
|
||||||
|
fs.writeFileSync(localPath, SUBTITLE_BODY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await extractSubtitleTrackToFile({
|
||||||
|
ffmpegPath: '/unused/ffmpeg',
|
||||||
|
videoPath: path.join(dir, 'video.mkv'),
|
||||||
|
track: { id: 2, type: 'sub', external: true, 'external-filename': localPath },
|
||||||
|
httpHeaders: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { path: localPath, temporary: false });
|
||||||
|
// A borrowed file must survive cleanup.
|
||||||
|
cleanupTemporaryFile(result);
|
||||||
|
assert.equal(fs.existsSync(localPath), true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractSubtitleTrackToFile rejects a missing local external track', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
extractSubtitleTrackToFile({
|
||||||
|
ffmpegPath: '/unused/ffmpeg',
|
||||||
|
videoPath: '/tmp/video.mkv',
|
||||||
|
track: { id: 2, type: 'sub', external: true, 'external-filename': '/tmp/does-not-exist.srt' },
|
||||||
|
httpHeaders: null,
|
||||||
|
}),
|
||||||
|
/Subtitle file not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupTemporaryFile preserves the retimed output sharing the temp directory', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
|
||||||
|
const sourcePath = path.join(dir, 'remote_track.srt');
|
||||||
|
const retimedPath = path.join(dir, 'remote_track_retimed.srt');
|
||||||
|
fs.writeFileSync(sourcePath, SUBTITLE_BODY);
|
||||||
|
fs.writeFileSync(retimedPath, SUBTITLE_BODY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
cleanupTemporaryFile({ path: sourcePath, temporary: true }, retimedPath);
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(sourcePath), false);
|
||||||
|
assert.equal(fs.existsSync(retimedPath), true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupTemporaryFile keeps the file when it is itself the preserved output', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
|
||||||
|
const inPlacePath = path.join(dir, 'remote_track.srt');
|
||||||
|
fs.writeFileSync(inPlacePath, SUBTITLE_BODY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
cleanupTemporaryFile({ path: inPlacePath, temporary: true }, inPlacePath);
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(inPlacePath), true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
codecToExtension,
|
||||||
|
fileExists,
|
||||||
|
MpvTrack,
|
||||||
|
runCommand,
|
||||||
|
summarizeCommandFailure,
|
||||||
|
} from '../../subsync/utils';
|
||||||
|
import { downloadToFile, isRemoteMediaPath } from '../../jimaku/utils';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
import {
|
||||||
|
ResolvedMpvHttpHeaders,
|
||||||
|
toFfmpegInputHttpArgs,
|
||||||
|
toRequestHeaders,
|
||||||
|
} from './mpv-http-headers';
|
||||||
|
|
||||||
|
const logger = createLogger('subsync');
|
||||||
|
|
||||||
|
export interface FileExtractionResult {
|
||||||
|
path: string;
|
||||||
|
temporary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleExtractionInput {
|
||||||
|
ffmpegPath: string;
|
||||||
|
videoPath: string;
|
||||||
|
track: MpvTrack;
|
||||||
|
/** mpv's request context, so stream-hosted tracks stay reachable. */
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForUrl(url: string, fallback: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const ext = path.extname(parsed.pathname).replace(/^\./, '').toLowerCase();
|
||||||
|
return /^[a-z0-9]{1,5}$/.test(ext) ? ext : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a subtitle track mpv loaded from a URL down to disk.
|
||||||
|
*
|
||||||
|
* Extension-backed and Jellyfin streams add their subtitles by URL, and neither
|
||||||
|
* alass nor ffmpeg can read one — the whole subsync path used to reject these
|
||||||
|
* with "Subtitle file not found: https://…".
|
||||||
|
*/
|
||||||
|
async function downloadRemoteSubtitleTrack(
|
||||||
|
url: string,
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders | null,
|
||||||
|
): Promise<FileExtractionResult> {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
|
||||||
|
const outputPath = path.join(tempDir, `remote_track.${extensionForUrl(url, 'srt')}`);
|
||||||
|
|
||||||
|
const result = await downloadToFile(url, outputPath, toRequestHeaders(httpHeaders));
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`Failed to download subtitle track: ${result.error?.error ?? 'unknown error'}`);
|
||||||
|
}
|
||||||
|
if (!fileExists(outputPath)) {
|
||||||
|
throw new Error(`Downloaded subtitle track is missing: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Downloaded remote subtitle track to ${outputPath}`);
|
||||||
|
return { path: outputPath, temporary: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractInternalTrack(input: SubtitleExtractionInput): Promise<FileExtractionResult> {
|
||||||
|
const ffIndex = input.track['ff-index'];
|
||||||
|
const extension = codecToExtension(input.track.codec);
|
||||||
|
if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) {
|
||||||
|
throw new Error('Internal subtitle track has no valid ff-index');
|
||||||
|
}
|
||||||
|
if (!extension) {
|
||||||
|
throw new Error(`Unsupported subtitle codec: ${input.track.codec ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
|
||||||
|
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||||
|
// Header args configure the HTTP demuxer, so they belong before `-i`.
|
||||||
|
const httpArgs = isRemoteMediaPath(input.videoPath)
|
||||||
|
? toFfmpegInputHttpArgs(input.httpHeaders)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const extraction = await runCommand(input.ffmpegPath, [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-y',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
...httpArgs,
|
||||||
|
'-an',
|
||||||
|
'-vn',
|
||||||
|
'-i',
|
||||||
|
input.videoPath,
|
||||||
|
'-map',
|
||||||
|
`0:${ffIndex}`,
|
||||||
|
'-f',
|
||||||
|
extension,
|
||||||
|
outputPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!extraction.ok || !fileExists(outputPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure(
|
||||||
|
'ffmpeg',
|
||||||
|
extraction,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: outputPath, temporary: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractSubtitleTrackToFile(
|
||||||
|
input: SubtitleExtractionInput,
|
||||||
|
): Promise<FileExtractionResult> {
|
||||||
|
if (input.track.external) {
|
||||||
|
const externalPath = input.track['external-filename'];
|
||||||
|
if (typeof externalPath !== 'string' || externalPath.length === 0) {
|
||||||
|
throw new Error('External subtitle track has no file path');
|
||||||
|
}
|
||||||
|
if (isRemoteMediaPath(externalPath)) {
|
||||||
|
return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders);
|
||||||
|
}
|
||||||
|
if (!fileExists(externalPath)) {
|
||||||
|
throw new Error(`Subtitle file not found: ${externalPath}`);
|
||||||
|
}
|
||||||
|
return { path: externalPath, temporary: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractInternalTrack(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a temporary extraction once alass/ffsubsync is done with it.
|
||||||
|
*
|
||||||
|
* `preservePath` is the retimed subtitle mpv is about to load. With
|
||||||
|
* `replace: false` it lands in this same temp directory, so the removal has to
|
||||||
|
* skip it — and the directory removal must stay non-recursive, since that is
|
||||||
|
* what keeps the retimed file alive for mpv.
|
||||||
|
*/
|
||||||
|
export function cleanupTemporaryFile(
|
||||||
|
extraction: FileExtractionResult,
|
||||||
|
preservePath?: string,
|
||||||
|
): void {
|
||||||
|
if (!extraction.temporary) return;
|
||||||
|
if (preservePath && path.resolve(preservePath) === path.resolve(extraction.path)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fileExists(extraction.path)) {
|
||||||
|
fs.unlinkSync(extraction.path);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(extraction.path);
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
fs.rmdirSync(dir);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { runSubsyncManual } from './subsync';
|
||||||
|
import type { TriggerSubsyncFromConfigDeps } from './subsync';
|
||||||
|
|
||||||
|
const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension-backed and Jellyfin playback add subtitles by URL, which subsync
|
||||||
|
* used to reject outright with "Subtitle file not found: https://…".
|
||||||
|
*/
|
||||||
|
async function startStubHost(): Promise<{
|
||||||
|
url: (pathname: string) => string;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const server = http.createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(SUBTITLE_BODY);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runSubsyncManual syncs stream subtitle tracks served over http', async (t) => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
t.skip('stub shell scripts are not executable on Windows');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await startStubHost();
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-remote-'));
|
||||||
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
|
||||||
|
// Record argv and copy the inputs aside — subsync deletes its temp files as
|
||||||
|
// soon as the run finishes — then write the third argument (alass's output).
|
||||||
|
fs.writeFileSync(
|
||||||
|
alassPath,
|
||||||
|
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\ncp "$1" "${tmpDir}/reference.copy"\ncp "$2" "${tmpDir}/target.copy"\nprintf '%s' "retimed" > "$3"\nexit 0\n`,
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryUrl = host.url('/subs/ja.srt');
|
||||||
|
const sourceUrl = host.url('/subs/en.srt');
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
|
||||||
|
const deps: Pick<TriggerSubsyncFromConfigDeps, 'getMpvClient' | 'getResolvedConfig'> = {
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return host.url('/stream/video.mp4');
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
lang: 'ja',
|
||||||
|
'external-filename': primaryUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
lang: 'en',
|
||||||
|
'external-filename': sourceUrl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath: '',
|
||||||
|
ffmpegPath: '',
|
||||||
|
replace: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runSubsyncManual({ engine: 'alass', 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
+78
-117
@@ -1,39 +1,31 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
|
import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types';
|
||||||
import {
|
import {
|
||||||
CommandResult,
|
CommandResult,
|
||||||
codecToExtension,
|
|
||||||
fileExists,
|
fileExists,
|
||||||
formatTrackLabel,
|
formatTrackLabel,
|
||||||
getTrackById,
|
getTrackById,
|
||||||
hasPathSeparators,
|
|
||||||
MpvTrack,
|
MpvTrack,
|
||||||
runCommand,
|
runCommand,
|
||||||
|
summarizeCommandFailure,
|
||||||
SubsyncContext,
|
SubsyncContext,
|
||||||
SubsyncResolvedConfig,
|
SubsyncResolvedConfig,
|
||||||
} from '../../subsync/utils';
|
} 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 { isRemoteMediaPath } from '../../jimaku/utils';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
interface FileExtractionResult {
|
const logger = createLogger('subsync');
|
||||||
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(' | ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MpvClientLike {
|
interface MpvClientLike {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -160,86 +152,19 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureExecutablePath(pathOrName: string, name: string): string {
|
/**
|
||||||
if (!pathOrName) {
|
* Resolve a sync tool, discovering it on PATH (plus the usual install prefixes)
|
||||||
throw new Error(`Missing ${name} path in config`);
|
* 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(
|
||||||
throw new Error(`Configured ${name} executable not found: ${pathOrName}`);
|
configuredPath.trim()
|
||||||
}
|
? `Configured ${name} executable not found: ${configuredPath}`
|
||||||
return pathOrName;
|
: `Could not find ${name}. Install it or set subsync.${name}_path in your config.`,
|
||||||
}
|
);
|
||||||
|
|
||||||
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 {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRetimedPath(subPath: string, replace: boolean): string {
|
function buildRetimedPath(subPath: string, replace: boolean): string {
|
||||||
@@ -285,23 +210,28 @@ async function subsyncToReference(
|
|||||||
context: SubsyncContext,
|
context: SubsyncContext,
|
||||||
resolved: SubsyncResolvedConfig,
|
resolved: SubsyncResolvedConfig,
|
||||||
client: MpvClientLike,
|
client: MpvClientLike,
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders | null,
|
||||||
): Promise<SubsyncResult> {
|
): Promise<SubsyncResult> {
|
||||||
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg');
|
||||||
const primaryExtraction = await extractSubtitleTrackToFile(
|
const primaryExtraction = await extractSubtitleTrackToFile({
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
context.videoPath,
|
videoPath: context.videoPath,
|
||||||
context.primaryTrack,
|
track: context.primaryTrack,
|
||||||
);
|
httpHeaders,
|
||||||
|
});
|
||||||
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
||||||
|
logger.info(
|
||||||
|
`Running ${engine}: target=${primaryExtraction.path} reference=${referenceFilePath} output=${outputPath}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
if (engine === 'alass') {
|
if (engine === 'alass') {
|
||||||
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
|
const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass');
|
||||||
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
|
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
|
||||||
} else {
|
} else {
|
||||||
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
|
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
|
||||||
result = await runFfsubsyncSync(
|
result = await runFfsubsyncSync(
|
||||||
ffsubsyncPath,
|
ffsubsyncPath,
|
||||||
context.videoPath,
|
context.videoPath,
|
||||||
@@ -313,6 +243,7 @@ async function subsyncToReference(
|
|||||||
|
|
||||||
if (!result.ok || !fileExists(outputPath)) {
|
if (!result.ok || !fileExists(outputPath)) {
|
||||||
const details = summarizeCommandFailure(engine, result);
|
const details = summarizeCommandFailure(engine, result);
|
||||||
|
logger.error(`${engine} synchronization failed: ${details}`);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: `${engine} synchronization failed: ${details}`,
|
message: `${engine} synchronization failed: ${details}`,
|
||||||
@@ -320,12 +251,13 @@ async function subsyncToReference(
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSyncedSubtitle(client, outputPath);
|
loadSyncedSubtitle(client, outputPath);
|
||||||
|
logger.info(`${engine} synchronization succeeded; loaded ${outputPath}`);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Subtitle synchronized with ${engine}`,
|
message: `Subtitle synchronized with ${engine}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTemporaryFile(primaryExtraction);
|
cleanupTemporaryFile(primaryExtraction, outputPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,29 +276,58 @@ export async function runSubsyncManual(
|
|||||||
const client = getMpvClientForSubsync(deps);
|
const client = getMpvClientForSubsync(deps);
|
||||||
const context = await gatherSubsyncContext(client);
|
const context = await gatherSubsyncContext(client);
|
||||||
const resolved = deps.getResolvedConfig();
|
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') {
|
if (request.engine === 'ffsubsync') {
|
||||||
try {
|
try {
|
||||||
validateFfsubsyncReference(context.videoPath);
|
validateFfsubsyncReference(context.videoPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
const message = `ffsubsync synchronization failed: ${(error as Error).message}`;
|
||||||
ok: false,
|
logger.error(message);
|
||||||
message: `ffsubsync synchronization failed: ${(error as 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);
|
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
|
||||||
if (!sourceTrack) {
|
if (!sourceTrack) {
|
||||||
|
logger.warn('Manual subsync rejected: no alass source track selected');
|
||||||
return { ok: false, message: 'Select a subtitle source track for alass' };
|
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;
|
let sourceExtraction: FileExtractionResult | null = null;
|
||||||
try {
|
try {
|
||||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
sourceExtraction = await extractSubtitleTrackToFile({
|
||||||
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
ffmpegPath,
|
||||||
|
videoPath: context.videoPath,
|
||||||
|
track: sourceTrack,
|
||||||
|
httpHeaders,
|
||||||
|
});
|
||||||
|
return await subsyncToReference(
|
||||||
|
'alass',
|
||||||
|
sourceExtraction.path,
|
||||||
|
context,
|
||||||
|
resolved,
|
||||||
|
client,
|
||||||
|
httpHeaders,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceExtraction) {
|
if (sourceExtraction) {
|
||||||
cleanupTemporaryFile(sourceExtraction);
|
cleanupTemporaryFile(sourceExtraction);
|
||||||
|
|||||||
@@ -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']), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GUI launch inherits a minimal PATH that omits the package-manager prefixes
|
||||||
|
* users actually install these tools under, so PATH alone cannot find them.
|
||||||
|
* Probing the usual prefixes is what makes "leave the config empty and we will
|
||||||
|
* discover it" true rather than aspirational.
|
||||||
|
*/
|
||||||
|
const FALLBACK_BIN_DIRS = [
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/opt/local/bin',
|
||||||
|
'/usr/bin',
|
||||||
|
'/bin',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Names each tool ships under, in the order they should be preferred. */
|
||||||
|
export const SUBSYNC_EXECUTABLE_NAMES = {
|
||||||
|
alass: ['alass', 'alass-cli'],
|
||||||
|
ffsubsync: ['ffsubsync'],
|
||||||
|
ffmpeg: ['ffmpeg'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SubsyncExecutable = keyof typeof SUBSYNC_EXECUTABLE_NAMES;
|
||||||
|
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExecutableFile(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchDirectories(): string[] {
|
||||||
|
const entries = (process.env.PATH ?? '')
|
||||||
|
.split(path.delimiter)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return unique([...entries, ...FALLBACK_BIN_DIRS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableNames(name: string): string[] {
|
||||||
|
if (process.platform !== 'win32') return [name];
|
||||||
|
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
|
||||||
|
.split(';')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (path.extname(name)) return [name];
|
||||||
|
return [name, ...extensions.map((extension) => `${name}${extension}`)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the first of `names` that exists, returning '' when none do.
|
||||||
|
*
|
||||||
|
* A name carrying a directory component is taken literally — the caller spelled
|
||||||
|
* out a path, so falling back to a same-named binary elsewhere would silently
|
||||||
|
* run something they did not point at.
|
||||||
|
*/
|
||||||
|
export function findExecutable(names: readonly string[]): string {
|
||||||
|
for (const name of names) {
|
||||||
|
if (path.dirname(name) !== '.') {
|
||||||
|
return isExecutableFile(name) ? name : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of searchDirectories()) {
|
||||||
|
for (const name of names) {
|
||||||
|
for (const executableName of executableNames(name)) {
|
||||||
|
const candidate = path.join(dir, executableName);
|
||||||
|
if (isExecutableFile(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a configured tool path, discovering it when the config is empty. */
|
||||||
|
export function resolveExecutable(
|
||||||
|
configuredPath: string | null | undefined,
|
||||||
|
names: readonly string[],
|
||||||
|
): string {
|
||||||
|
const trimmed = configuredPath?.trim() ?? '';
|
||||||
|
if (trimmed) return findExecutable([trimmed]);
|
||||||
|
return findExecutable(names);
|
||||||
|
}
|
||||||
+22
-15
@@ -23,12 +23,6 @@ export interface SubsyncResolvedConfig {
|
|||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
|
||||||
alass: '/usr/bin/alass',
|
|
||||||
ffsubsync: '/usr/bin/ffsubsync',
|
|
||||||
ffmpeg: '/usr/bin/ffmpeg',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface SubsyncContext {
|
export interface SubsyncContext {
|
||||||
videoPath: string;
|
videoPath: string;
|
||||||
primaryTrack: MpvTrack;
|
primaryTrack: MpvTrack;
|
||||||
@@ -81,22 +75,35 @@ function resolveCommandInvocation(
|
|||||||
return { command: executable, args };
|
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 {
|
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
|
||||||
const resolvePath = (value: string | undefined, fallback: string): string => {
|
const trim = (value: string | undefined): string => value?.trim() ?? '';
|
||||||
const trimmed = value?.trim();
|
|
||||||
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
alassPath: trim(config?.alass_path),
|
||||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
ffsubsyncPath: trim(config?.ffsubsync_path),
|
||||||
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
ffmpegPath: trim(config?.ffmpeg_path),
|
||||||
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPathSeparators(value: string): boolean {
|
export function summarizeCommandFailure(command: string, result: CommandResult): string {
|
||||||
return value.includes('/') || value.includes('\\');
|
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 {
|
export function fileExists(pathOrEmpty: string): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user