mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -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:
@@ -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;
|
||||
}
|
||||
|
||||
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
||||
alass: '/usr/bin/alass',
|
||||
ffsubsync: '/usr/bin/ffsubsync',
|
||||
ffmpeg: '/usr/bin/ffmpeg',
|
||||
} as const;
|
||||
|
||||
export interface SubsyncContext {
|
||||
videoPath: string;
|
||||
primaryTrack: MpvTrack;
|
||||
@@ -82,22 +76,35 @@ function resolveCommandInvocation(
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* An unset path stays empty here on purpose: hard-coding `/usr/bin/<tool>` made
|
||||
* the documented "leave empty to auto-discover from PATH" a lie and broke every
|
||||
* default-config macOS install, where none of these live in /usr/bin.
|
||||
* Discovery happens at run time in `resolveSubsyncExecutable`.
|
||||
*/
|
||||
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
|
||||
const resolvePath = (value: string | undefined, fallback: string): string => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
||||
};
|
||||
const trim = (value: string | undefined): string => value?.trim() ?? '';
|
||||
|
||||
return {
|
||||
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
||||
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
||||
alassPath: trim(config?.alass_path),
|
||||
ffsubsyncPath: trim(config?.ffsubsync_path),
|
||||
ffmpegPath: trim(config?.ffmpeg_path),
|
||||
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPathSeparators(value: string): boolean {
|
||||
return value.includes('/') || value.includes('\\');
|
||||
export function summarizeCommandFailure(command: string, result: CommandResult): string {
|
||||
const parts = [
|
||||
`code=${result.code ?? 'n/a'}`,
|
||||
result.stderr ? `stderr: ${result.stderr}` : '',
|
||||
result.stdout ? `stdout: ${result.stdout}` : '',
|
||||
result.error ? `error: ${result.error}` : '',
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return `command failed (${command})`;
|
||||
return `command failed (${command}) ${parts.join(' | ')}`;
|
||||
}
|
||||
|
||||
export function fileExists(pathOrEmpty: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user