fix(subtitles): extract embedded subtitle tracks from network-mounted media

The network-mount skip made SMB/NFS libraries fall back to live mpv text for
any release shipping subtitles only inside the container, losing karaoke
reconstruction, sidebar cues, and mining. The starvation it guarded against
had a different cause, and measured extraction runs at wire speed (~10s/GB
on gigabit) once per episode. Skip extraction only for true remote URLs,
which have no on-disk container to demux, and raise the extraction timeout
to cover large Bluray remuxes read over the network.
This commit is contained in:
2026-08-23 20:45:30 -07:00
parent 1717d2d3f2
commit 4635bfb264
7 changed files with 27 additions and 21 deletions
-1
View File
@@ -2059,7 +2059,6 @@ const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackEx
const detectRemoteMediaPath = createRemoteMediaPathDetector();
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
isRemoteMediaPath: detectRemoteMediaPath,
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message),
+4 -3
View File
@@ -864,15 +864,16 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
);
});
test('main process guards internal subtitle extraction with the remote media detector', () => {
test('main process extracts internal subtitle tracks without a network-mount guard', () => {
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/);
// Network-mounted files are extracted like local ones; only remote URLs skip
// extraction, handled inside the resolver itself.
assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/);
assert.match(
resolverWiring,
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
@@ -46,7 +46,10 @@ export type InternalSubtitleTrackExtractor = (
track: MpvSubtitleTrackLike,
) => Promise<ExtractedInternalSubtitleTrack | null>;
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
// Subtitle packets are interleaved through the container, so extraction reads the
// entire file. Network mounts move ~100 MB/s on gigabit, so large Bluray remuxes
// need well over 30 seconds.
const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000;
export function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
@@ -157,14 +157,16 @@ 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 () => {
test('subtitle prefetch runtime extracts internal subtitle tracks from network-mounted media', async () => {
let extracted = false;
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg-custom',
isRemoteMediaPath: async (videoPath) => videoPath.startsWith('/Volumes/jellyfin/'),
extractInternalSubtitleTrack: async () => {
extracted = true;
return null;
return {
path: '/tmp/subminer-sidebar-123/track_7.ass',
cleanup: async () => {},
};
},
});
@@ -181,8 +183,8 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from n
videoPath: '/Volumes/jellyfin/movie.mkv',
});
assert.equal(resolved, null);
assert.equal(extracted, false);
assert.equal(resolved?.path, '/tmp/subminer-sidebar-123/track_7.ass');
assert.equal(extracted, true);
});
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
@@ -17,8 +17,6 @@ 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;
@@ -88,7 +86,6 @@ function getActiveSubtitleTrack(
export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
getFfmpegPath: () => string;
isRemoteMediaPath?: RemoteMediaPathDetector;
extractInternalSubtitleTrack: (
ffmpegPath: string,
videoPath: string,
@@ -129,8 +126,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: externalFilename, sourceKey: externalFilename };
}
const isRemoteMediaPath = deps.isRemoteMediaPath ?? isRemoteMediaUrl;
if (await isRemoteMediaPath(input.videoPath)) {
// Network-mounted files extract like local ones: demuxing reads the whole
// container (~10s/GB on gigabit), which a LAN handles alongside playback.
// Only true remote URLs have no on-disk container to demux.
if (isRemoteMediaUrl(input.videoPath)) {
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
return null;
}