mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
fix(subtitles): improve secondary subtitle extraction and display (#215)
This commit is contained in:
+10
-4
@@ -588,9 +588,10 @@ import {
|
||||
import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source';
|
||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||
import {
|
||||
createCachedInternalSubtitleTrackExtractor,
|
||||
loadSubtitleSourceText,
|
||||
extractInternalSubtitleTrackToTempFile,
|
||||
} from './main/runtime/internal-subtitle-extraction';
|
||||
import { createRemoteMediaPathDetector } from './main/runtime/network-media-path';
|
||||
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
|
||||
import { getSubsyncConfig } from './subsync/utils';
|
||||
|
||||
@@ -2054,10 +2055,13 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
}
|
||||
},
|
||||
});
|
||||
const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor();
|
||||
const detectRemoteMediaPath = createRemoteMediaPathDetector();
|
||||
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
||||
isRemoteMediaPath: detectRemoteMediaPath,
|
||||
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||
cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
@@ -2086,8 +2090,8 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
// Remote media has no extractable on-disk track to fall back to, so a transient
|
||||
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
|
||||
// otherwise drop a working cue list for the rest of the episode.
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
|
||||
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
|
||||
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
|
||||
isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)),
|
||||
subtitlePrefetchInitController,
|
||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
@@ -3962,6 +3966,7 @@ const {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
@@ -4522,6 +4527,7 @@ const {
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
);
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
cachedInternalSubtitleTrackExtractor.clear();
|
||||
secondarySubtitleTrackController.reset();
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||
|
||||
@@ -183,7 +183,10 @@ test('remote media keeps parsed cues when the active subtitle source cannot be r
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
||||
@@ -860,3 +863,18 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main process guards internal subtitle extraction with the remote media detector', () => {
|
||||
const source = readMainSource();
|
||||
const resolverWiring = source.match(
|
||||
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resolverWiring);
|
||||
assert.match(source, /const detectRemoteMediaPath = createRemoteMediaPathDetector\(\);/);
|
||||
assert.match(resolverWiring, /isRemoteMediaPath:\s*detectRemoteMediaPath/);
|
||||
assert.match(
|
||||
resolverWiring,
|
||||
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
@@ -50,10 +51,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 34);
|
||||
assert.equal(calls.length, 35);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-internal-subtitles'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
@@ -97,6 +99,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
calls.push('stop-jellyfin-remote');
|
||||
throw new Error('stop failed');
|
||||
},
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
@@ -104,7 +107,11 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
});
|
||||
|
||||
assert.throws(() => cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
|
||||
assert.deepEqual(calls, [
|
||||
'stop-jellyfin-remote',
|
||||
'cleanup-jellyfin-subtitles',
|
||||
'cleanup-internal-subtitles',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
@@ -67,7 +68,11 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
try {
|
||||
deps.stopJellyfinRemoteSession();
|
||||
} finally {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
try {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
} finally {
|
||||
deps.cleanupInternalSubtitleTrackCache();
|
||||
}
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupYoutubeMediaCache();
|
||||
|
||||
@@ -72,6 +72,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
@@ -95,6 +96,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('cleanup-internal-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
@@ -152,6 +154,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
@@ -204,6 +207,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
|
||||
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
@@ -144,6 +145,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
|
||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
|
||||
@@ -6,6 +6,7 @@ import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildFfmpegSubtitleExtractionArgs,
|
||||
createCachedInternalSubtitleTrackExtractor,
|
||||
extractInternalSubtitleTrackToTempFile,
|
||||
parseTrackId,
|
||||
} from './internal-subtitle-extraction';
|
||||
@@ -22,6 +23,65 @@ test('parseTrackId rejects negative track ids', () => {
|
||||
assert.equal(parseTrackId(' -2 '), null);
|
||||
});
|
||||
|
||||
test('cached internal subtitle extraction shares concurrent and repeated track requests', async () => {
|
||||
let extractionCalls = 0;
|
||||
let cleanupCalls = 0;
|
||||
let resolveExtraction:
|
||||
| ((result: { path: string; cleanup: () => Promise<void> }) => void)
|
||||
| undefined;
|
||||
const firstExtraction = new Promise<{ path: string; cleanup: () => Promise<void> }>((resolve) => {
|
||||
resolveExtraction = resolve;
|
||||
});
|
||||
const extractor = createCachedInternalSubtitleTrackExtractor({
|
||||
extract: async () => {
|
||||
extractionCalls += 1;
|
||||
if (extractionCalls === 1) {
|
||||
return firstExtraction;
|
||||
}
|
||||
return {
|
||||
path: `/tmp/subtitle-${extractionCalls}.ass`,
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const request = () =>
|
||||
extractor.extract('ffmpeg', '/Volumes/media/episode.mkv', {
|
||||
'ff-index': 3,
|
||||
codec: 'ass',
|
||||
});
|
||||
|
||||
const concurrent = Array.from({ length: 6 }, request);
|
||||
assert.equal(extractionCalls, 1);
|
||||
if (!resolveExtraction) {
|
||||
throw new Error('extraction did not start');
|
||||
}
|
||||
resolveExtraction({
|
||||
path: '/tmp/subtitle-1.ass',
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const results = await Promise.all(concurrent);
|
||||
assert.deepEqual(
|
||||
results.map((result) => result?.path),
|
||||
Array.from({ length: 6 }, () => '/tmp/subtitle-1.ass'),
|
||||
);
|
||||
await Promise.all(results.map((result) => result?.cleanup()));
|
||||
assert.equal(cleanupCalls, 0);
|
||||
|
||||
assert.equal((await request())?.path, '/tmp/subtitle-1.ass');
|
||||
assert.equal(extractionCalls, 1);
|
||||
|
||||
extractor.clear();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(cleanupCalls, 1);
|
||||
assert.equal((await request())?.path, '/tmp/subtitle-2.ass');
|
||||
assert.equal(extractionCalls, 2);
|
||||
});
|
||||
|
||||
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
|
||||
const videoPath = path.join(root, 'video.mkv');
|
||||
|
||||
@@ -35,6 +35,17 @@ export type MpvSubtitleTrackLike = {
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
export type ExtractedInternalSubtitleTrack = {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type InternalSubtitleTrackExtractor = (
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
) => Promise<ExtractedInternalSubtitleTrack | null>;
|
||||
|
||||
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function parseTrackId(value: unknown): number | null {
|
||||
@@ -80,7 +91,7 @@ export async function extractInternalSubtitleTrackToTempFile(
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
|
||||
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||
): Promise<ExtractedInternalSubtitleTrack | null> {
|
||||
const ffIndex = parseTrackId(track['ff-index']);
|
||||
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||
const extension = codecToExtension(codec ?? undefined);
|
||||
@@ -145,3 +156,69 @@ export async function extractInternalSubtitleTrackToTempFile(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CachedExtraction = {
|
||||
promise: Promise<ExtractedInternalSubtitleTrack | null>;
|
||||
};
|
||||
|
||||
function buildCachedExtractionKey(
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
): string {
|
||||
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||
return JSON.stringify([ffmpegPath, videoPath, parseTrackId(track['ff-index']), codec]);
|
||||
}
|
||||
|
||||
const releaseCachedExtraction = async (): Promise<void> => {};
|
||||
|
||||
/**
|
||||
* Owns extracted subtitle files for the active media and shares one extraction between callers.
|
||||
* Caller cleanup releases only its view; clear removes the owned files on media changes or quit.
|
||||
*/
|
||||
export function createCachedInternalSubtitleTrackExtractor(
|
||||
deps: { extract?: InternalSubtitleTrackExtractor } = {},
|
||||
): {
|
||||
extract: InternalSubtitleTrackExtractor;
|
||||
clear: () => void;
|
||||
} {
|
||||
const extractTrack = deps.extract ?? extractInternalSubtitleTrackToTempFile;
|
||||
const extractions = new Map<string, CachedExtraction>();
|
||||
|
||||
const extract: InternalSubtitleTrackExtractor = async (ffmpegPath, videoPath, track) => {
|
||||
const key = buildCachedExtractionKey(ffmpegPath, videoPath, track);
|
||||
let cached = extractions.get(key);
|
||||
if (!cached) {
|
||||
const next: CachedExtraction = {
|
||||
promise: extractTrack(ffmpegPath, videoPath, track),
|
||||
};
|
||||
cached = next;
|
||||
extractions.set(key, next);
|
||||
void next.promise.catch(() => {
|
||||
if (extractions.get(key) === next) {
|
||||
extractions.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await cached.promise;
|
||||
if (extractions.get(key) !== cached || !result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: result.path,
|
||||
cleanup: releaseCachedExtraction,
|
||||
};
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
const staleExtractions = [...extractions.values()];
|
||||
extractions.clear();
|
||||
for (const extraction of staleExtractions) {
|
||||
void extraction.promise.then((result) => result?.cleanup()).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return { extract, clear };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createRemoteMediaPathDetector } from './network-media-path';
|
||||
|
||||
test('remote media detector recognizes mounted network filesystems', async () => {
|
||||
const detectRemoteMedia = createRemoteMediaPathDetector({
|
||||
platform: 'darwin',
|
||||
readMountOutput: async () =>
|
||||
[
|
||||
'/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled)',
|
||||
'//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
assert.equal(await detectRemoteMedia('/Volumes/jellyfin/movie.mkv'), true);
|
||||
assert.equal(await detectRemoteMedia('/Volumes/jellyfin-another/movie.mkv'), false);
|
||||
assert.equal(await detectRemoteMedia('/Users/viewer/movie.mkv'), false);
|
||||
});
|
||||
|
||||
test('remote media detector recognizes Linux network mount output', async () => {
|
||||
const detectRemoteMedia = createRemoteMediaPathDetector({
|
||||
platform: 'linux',
|
||||
readMountOutput: async () =>
|
||||
'//media/jellyfin on /mnt/Jellyfin\\040Media type cifs (rw,relatime)',
|
||||
});
|
||||
|
||||
assert.equal(await detectRemoteMedia('/mnt/Jellyfin Media/movie.mkv'), true);
|
||||
});
|
||||
|
||||
test('remote media detector shares its mount lookup between concurrent callers', async () => {
|
||||
let mountReads = 0;
|
||||
const detectRemoteMedia = createRemoteMediaPathDetector({
|
||||
platform: 'darwin',
|
||||
readMountOutput: async () => {
|
||||
mountReads += 1;
|
||||
return '//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)';
|
||||
},
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 6 }, () => detectRemoteMedia('/Volumes/jellyfin/movie.mkv')),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
results,
|
||||
Array.from({ length: 6 }, () => true),
|
||||
);
|
||||
assert.equal(mountReads, 1);
|
||||
});
|
||||
|
||||
test('remote media detector recognizes URLs and Windows UNC paths without reading mounts', async () => {
|
||||
let mountReads = 0;
|
||||
const detectRemoteMedia = createRemoteMediaPathDetector({
|
||||
platform: 'win32',
|
||||
readMountOutput: async () => {
|
||||
mountReads += 1;
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await detectRemoteMedia('https://media.example/movie.mkv'), true);
|
||||
assert.equal(await detectRemoteMedia('\\\\media-server\\jellyfin\\movie.mkv'), true);
|
||||
assert.equal(mountReads, 0);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
|
||||
const DEFAULT_MOUNT_CACHE_TTL_MS = 5_000;
|
||||
const NETWORK_FILESYSTEM_TYPES = new Set([
|
||||
'9p',
|
||||
'afpfs',
|
||||
'cifs',
|
||||
'davfs',
|
||||
'davfs2',
|
||||
'fuse.sshfs',
|
||||
'nfs',
|
||||
'nfs4',
|
||||
'smbfs',
|
||||
'sshfs',
|
||||
'webdav',
|
||||
]);
|
||||
|
||||
function isRemoteUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMountPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/g, (_match, digits: string) =>
|
||||
String.fromCharCode(Number.parseInt(digits, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseNetworkMountPaths(output: string): string[] {
|
||||
const networkMountPaths: string[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const optionsStart = line.lastIndexOf(' (');
|
||||
if (optionsStart < 0) continue;
|
||||
|
||||
let mountDescription = line.slice(0, optionsStart);
|
||||
const options = line.slice(optionsStart + 2, line.indexOf(')', optionsStart));
|
||||
const linuxTypeSeparator = mountDescription.lastIndexOf(' type ');
|
||||
const filesystemType = (
|
||||
linuxTypeSeparator >= 0
|
||||
? mountDescription.slice(linuxTypeSeparator + ' type '.length)
|
||||
: (options.split(',').at(0) ?? '')
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!NETWORK_FILESYSTEM_TYPES.has(filesystemType)) continue;
|
||||
|
||||
if (linuxTypeSeparator >= 0) {
|
||||
mountDescription = mountDescription.slice(0, linuxTypeSeparator);
|
||||
}
|
||||
const mountSeparator = mountDescription.indexOf(' on ');
|
||||
if (mountSeparator < 0) continue;
|
||||
networkMountPaths.push(
|
||||
path.posix.normalize(decodeMountPath(mountDescription.slice(mountSeparator + 4).trim())),
|
||||
);
|
||||
}
|
||||
return networkMountPaths;
|
||||
}
|
||||
|
||||
function readMountOutput(platform: NodeJS.Platform): Promise<string> {
|
||||
if (platform === 'win32') return Promise.resolve('');
|
||||
const command = platform === 'darwin' ? '/sbin/mount' : 'mount';
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
[],
|
||||
{ encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024 },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isPathWithinMount(filePath: string, mountPath: string): boolean {
|
||||
const relativePath = path.posix.relative(mountPath, filePath);
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${path.posix.sep}`) &&
|
||||
!path.posix.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
export type RemoteMediaPathDetector = (mediaPath: string) => Promise<boolean>;
|
||||
|
||||
export function createRemoteMediaPathDetector(
|
||||
deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
readMountOutput?: () => Promise<string>;
|
||||
now?: () => number;
|
||||
mountCacheTtlMs?: number;
|
||||
} = {},
|
||||
): RemoteMediaPathDetector {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const getMountOutput = deps.readMountOutput ?? (() => readMountOutput(platform));
|
||||
const now = deps.now ?? Date.now;
|
||||
const mountCacheTtlMs = deps.mountCacheTtlMs ?? DEFAULT_MOUNT_CACHE_TTL_MS;
|
||||
let mountCache: { expiresAt: number; networkMountPaths: Promise<readonly string[]> } | undefined;
|
||||
|
||||
const getNetworkMountPaths = (): Promise<readonly string[]> => {
|
||||
const currentTime = now();
|
||||
if (mountCache && currentTime < mountCache.expiresAt) {
|
||||
return mountCache.networkMountPaths;
|
||||
}
|
||||
|
||||
const networkMountPaths = getMountOutput()
|
||||
.then(parseNetworkMountPaths)
|
||||
.catch(() => []);
|
||||
mountCache = {
|
||||
expiresAt: currentTime + mountCacheTtlMs,
|
||||
networkMountPaths,
|
||||
};
|
||||
return networkMountPaths;
|
||||
};
|
||||
|
||||
return async (mediaPath): Promise<boolean> => {
|
||||
const source = mediaPath.trim();
|
||||
if (!source) return false;
|
||||
if (isRemoteUrl(source)) return true;
|
||||
|
||||
const filePath = resolveSubtitleSourcePath(source);
|
||||
if (platform === 'win32') {
|
||||
return filePath.startsWith('\\\\');
|
||||
}
|
||||
if (!path.posix.isAbsolute(filePath)) return false;
|
||||
|
||||
const networkMountPaths = await getNetworkMountPaths();
|
||||
const normalizedPath = path.posix.normalize(filePath);
|
||||
return networkMountPaths.some((mountPath) => isPathWithinMount(normalizedPath, mountPath));
|
||||
};
|
||||
}
|
||||
@@ -101,6 +101,32 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime preserves parsed cues when a network mount source is unresolved', async () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => (name === 'path' ? '/Volumes/jellyfin/movie.mkv' : null),
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {
|
||||
calls.push('cancel');
|
||||
},
|
||||
initSubtitlePrefetch: async () => {
|
||||
calls.push('init');
|
||||
},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
|
||||
videoPath.startsWith('/Volumes/jellyfin/'),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => {
|
||||
let extracted = false;
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -131,6 +157,34 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
|
||||
assert.equal(extracted, false);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime does not extract internal subtitle tracks from network mounts', async () => {
|
||||
let extracted = false;
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg-custom',
|
||||
isRemoteMediaPath: async (videoPath) => videoPath.startsWith('/Volumes/jellyfin/'),
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
extracted = true;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: {
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
codec: 'ass',
|
||||
},
|
||||
trackListRaw: [],
|
||||
sidRaw: 3,
|
||||
videoPath: '/Volumes/jellyfin/movie.mkv',
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
assert.equal(extracted, false);
|
||||
});
|
||||
|
||||
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
|
||||
const warnings: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
|
||||
@@ -17,6 +17,8 @@ type ActiveSubtitleSidebarSource = {
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type RemoteMediaPathDetector = (mediaPath: string) => boolean | Promise<boolean>;
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
@@ -28,7 +30,7 @@ function parseTrackId(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRemoteMediaPath(value: string): boolean {
|
||||
function isRemoteMediaUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
@@ -86,6 +88,7 @@ function getActiveSubtitleTrack(
|
||||
|
||||
export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
getFfmpegPath: () => string;
|
||||
isRemoteMediaPath?: RemoteMediaPathDetector;
|
||||
extractInternalSubtitleTrack: (
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
@@ -126,7 +129,8 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
return { path: externalFilename, sourceKey: externalFilename };
|
||||
}
|
||||
|
||||
if (isRemoteMediaPath(input.videoPath)) {
|
||||
const isRemoteMediaPath = deps.isRemoteMediaPath ?? isRemoteMediaUrl;
|
||||
if (await isRemoteMediaPath(input.videoPath)) {
|
||||
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
|
||||
return null;
|
||||
}
|
||||
@@ -156,7 +160,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean | Promise<boolean>;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
@@ -195,7 +199,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||
if ((await deps.shouldKeepExistingCuesOnMissingSource?.(videoPath)) === true) {
|
||||
deps.logDebug?.(
|
||||
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
|
||||
);
|
||||
|
||||
@@ -1928,10 +1928,6 @@ body.layer-modal #overlay {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
/* Backstop: pathological tracks (karaoke typesetting, sign spam) must never grow
|
||||
the hover-pause band beyond a top strip. ~4 lines at line-height 1.5. */
|
||||
max-height: 6em;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
|
||||
paint-order: stroke fill;
|
||||
|
||||
@@ -1424,11 +1424,8 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves short stacks without layer metadata', () => {
|
||||
test('prepareSecondarySubtitleLines collapses exact short copies in stacks', () => {
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [
|
||||
'Your',
|
||||
'Your',
|
||||
'Your',
|
||||
'Your',
|
||||
'mosaic',
|
||||
]);
|
||||
@@ -1438,6 +1435,15 @@ test('prepareSecondarySubtitleLines preserves short stacks without layer metadat
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines collapses exact short sign copies beside dialogue', () => {
|
||||
const liveText = "And for today's sports festival...\nEntrance\nEntrance";
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(liveText), [
|
||||
"And for today's sports festival...",
|
||||
'Entrance',
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => {
|
||||
// Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers,
|
||||
// joined with \N by mpv's secondary-sub-text.
|
||||
@@ -1448,10 +1454,10 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => {
|
||||
test('prepareSecondarySubtitleLines collapses exact repeated short lines', () => {
|
||||
const dialogue = ['Wait', 'Wait', 'Wait'];
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), ['Wait']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => {
|
||||
@@ -1469,6 +1475,10 @@ test('prepareSecondarySubtitleLines preserves short simultaneous dialogue withou
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves distinct short lines with internal whitespace', () => {
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('AB\\NA B'), ['AB', 'A B']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => {
|
||||
const dialogue = ' I never expected this. \\N\\N But here we are. ';
|
||||
|
||||
@@ -1490,13 +1500,13 @@ test('prepareSecondarySubtitleLines strips ASS override tags and handles empty i
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []);
|
||||
});
|
||||
|
||||
test('secondary subtitle root CSS caps height so hover-pause band stays a top strip', () => {
|
||||
test('secondary subtitle root CSS does not clip long subtitle stacks', () => {
|
||||
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
|
||||
const cssText = fs.readFileSync(srcCssPath, 'utf-8');
|
||||
|
||||
const secondaryRootBlock = extractClassBlock(cssText, '#secondarySubRoot');
|
||||
assert.match(secondaryRootBlock, /max-height:\s*6em;/);
|
||||
assert.match(secondaryRootBlock, /overflow:\s*hidden;/);
|
||||
assert.doesNotMatch(secondaryRootBlock, /max-height\s*:/);
|
||||
assert.doesNotMatch(secondaryRootBlock, /overflow\s*:\s*hidden/);
|
||||
});
|
||||
|
||||
test('applySubtitleStyle sets known-word maturity color variables', () => {
|
||||
|
||||
@@ -667,12 +667,17 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
|
||||
}
|
||||
|
||||
function collapseFullLineFallbackCopies(lines: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const seenExact = new Set<string>();
|
||||
const seenFlattened = new Set<string>();
|
||||
return lines.filter((line) => {
|
||||
const identity = flattenedSecondarySubtitleLineIdentity(line);
|
||||
if (!identity) return true;
|
||||
if (seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
const exactIdentity = line.normalize('NFKC');
|
||||
if (seenExact.has(exactIdentity)) return false;
|
||||
seenExact.add(exactIdentity);
|
||||
|
||||
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(line);
|
||||
if (!flattenedIdentity) return true;
|
||||
if (seenFlattened.has(flattenedIdentity)) return false;
|
||||
seenFlattened.add(flattenedIdentity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user