mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183)
This commit is contained in:
@@ -28,6 +28,9 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
? (itemId, streamIndex, delaySeconds) =>
|
||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||
: undefined,
|
||||
initSubtitlePrefetch: deps.initSubtitlePrefetch
|
||||
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
|
||||
: undefined,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ function makeDeps(overrides: {
|
||||
>[0]['setActiveSubtitleDelayKey'];
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
||||
initSubtitlePrefetch?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['initSubtitlePrefetch'];
|
||||
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
|
||||
}) {
|
||||
return {
|
||||
@@ -58,6 +61,7 @@ function makeDeps(overrides: {
|
||||
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
||||
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
||||
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
||||
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
|
||||
logDebug: overrides.logDebug ?? (() => {}),
|
||||
};
|
||||
}
|
||||
@@ -134,6 +138,92 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles starts prefetch for the selected japanese track', async () => {
|
||||
const prefetched: string[] = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 6,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
initSubtitlePrefetch: (sourcePath) => {
|
||||
prefetched.push(sourcePath);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(prefetched, ['/tmp/subminer-jellyfin-subtitles/0.srt']);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles survives prefetch start failures', async () => {
|
||||
const logs: string[] = [];
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
initSubtitlePrefetch: async () => {
|
||||
throw new Error('parse failed');
|
||||
},
|
||||
logDebug: (message) => logs.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(logs, ['Failed to start subtitle prefetch for Jellyfin subtitle']);
|
||||
assert.ok(
|
||||
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid'),
|
||||
'subtitle selection still happens when prefetch start fails',
|
||||
);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
|
||||
@@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
||||
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
const activeCacheDirs = new Set<string>();
|
||||
@@ -329,6 +330,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
|
||||
// mpv's sid property-change is the only thing that normally starts prefetching, so a
|
||||
// coalesced or missed event leaves the whole episode uncached. The downloaded path is
|
||||
// known here, so seed the pipeline directly instead of waiting on the observer.
|
||||
function startSubtitlePrefetchForCachedTrack(sourcePath: string): void {
|
||||
if (!deps.initSubtitlePrefetch) return;
|
||||
void Promise.resolve()
|
||||
.then(() => deps.initSubtitlePrefetch!(sourcePath))
|
||||
.catch((error) => {
|
||||
deps.logDebug('Failed to start subtitle prefetch for Jellyfin subtitle', error);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
if (dirs.length === 0) return;
|
||||
@@ -438,6 +451,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
}
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
|
||||
} else {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
|
||||
@@ -54,7 +54,6 @@ test('latest subtitle prefetch init wins over stale async loads', async () => {
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
@@ -99,7 +98,6 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
@@ -137,7 +135,6 @@ test('subtitle prefetch init publishes parsed cues and clears them on cancel', a
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
@@ -181,7 +178,6 @@ test('subtitle prefetch init publishes the provided stable source key instead of
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (_cues, source) => {
|
||||
@@ -222,7 +218,6 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
@@ -247,7 +242,6 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
|
||||
},
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
isCacheFull: () => boolean;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||
@@ -72,7 +71,6 @@ export function createSubtitlePrefetchInitController(
|
||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
||||
isCacheFull: () => deps.isCacheFull(),
|
||||
});
|
||||
|
||||
if (revision !== initRevision) {
|
||||
|
||||
Reference in New Issue
Block a user