mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-01 23:54:31 -07:00
fix(jellyfin): stop inferring subtitle delays (#227)
This commit is contained in:
@@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
|
||||
},
|
||||
cleanupCachedSubtitles: () => calls.push('cleanup'),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => {
|
||||
calls.push(`load-delay:${streamIndex}`);
|
||||
return 1.25;
|
||||
},
|
||||
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
|
||||
loadSubtitleSourceText: async (source) => {
|
||||
calls.push(`load-source:${source}`);
|
||||
return 'subtitle';
|
||||
},
|
||||
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
|
||||
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
|
||||
return true;
|
||||
},
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
@@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
await deps.wait(1);
|
||||
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
|
||||
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
|
||||
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
|
||||
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
|
||||
deps.logDebug('oops', null);
|
||||
assert.deepEqual(calls, [
|
||||
'list',
|
||||
'send',
|
||||
'wait',
|
||||
'cache',
|
||||
'cleanup',
|
||||
'load-delay:3',
|
||||
'active-delay:3',
|
||||
'load-source:/tmp/sub.srt',
|
||||
'save-delay:3:-31.5',
|
||||
'debug:oops',
|
||||
]);
|
||||
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
|
||||
});
|
||||
|
||||
@@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
wait: (ms: number) => deps.wait(ms),
|
||||
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
|
||||
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
|
||||
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
|
||||
: undefined,
|
||||
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
|
||||
? (key) => deps.setActiveSubtitleDelayKey!(key)
|
||||
: undefined,
|
||||
loadSubtitleSourceText: deps.loadSubtitleSourceText
|
||||
? (source) => deps.loadSubtitleSourceText!(source)
|
||||
: undefined,
|
||||
saveSubtitleDelay: deps.saveSubtitleDelay
|
||||
? (itemId, streamIndex, delaySeconds) =>
|
||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||
: undefined,
|
||||
initSubtitlePrefetch: deps.initSubtitlePrefetch
|
||||
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
|
||||
: undefined,
|
||||
|
||||
@@ -32,14 +32,6 @@ function makeDeps(overrides: {
|
||||
cleanupCachedSubtitles?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['cleanupCachedSubtitles'];
|
||||
getSavedSubtitleDelay?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['getSavedSubtitleDelay'];
|
||||
setActiveSubtitleDelayKey?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['setActiveSubtitleDelayKey'];
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
||||
initSubtitlePrefetch?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['initSubtitlePrefetch'];
|
||||
@@ -57,10 +49,6 @@ function makeDeps(overrides: {
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
})),
|
||||
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
|
||||
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
|
||||
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
||||
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
||||
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
||||
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
|
||||
logDebug: overrides.logDebug ?? (() => {}),
|
||||
};
|
||||
@@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
|
||||
|
||||
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const activeDelayKeys: Array<unknown> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Embedded Japanese' },
|
||||
],
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(activeDelayKeys, [null]);
|
||||
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
|
||||
});
|
||||
|
||||
@@ -461,42 +446,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
|
||||
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sub-delay', 1.25],
|
||||
['set_property', 'sid', 11],
|
||||
]);
|
||||
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
|
||||
test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
@@ -516,14 +466,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
getSavedSubtitleDelay: () => 1.25,
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
const delayIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0,
|
||||
);
|
||||
const selectedSidIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
|
||||
@@ -533,143 +482,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
|
||||
assert.ok(delayIndex < selectedSidIndex);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
|
||||
const primarySrt = `1
|
||||
00:00:34,935 --> 00:00:36,937
|
||||
Japanese 1
|
||||
|
||||
2
|
||||
00:00:36,937 --> 00:00:41,441
|
||||
Japanese 2
|
||||
|
||||
3
|
||||
00:00:41,441 --> 00:00:45,279
|
||||
Japanese 3
|
||||
|
||||
4
|
||||
00:00:45,279 --> 00:00:48,115
|
||||
Japanese 4
|
||||
|
||||
5
|
||||
00:00:48,115 --> 00:00:52,286
|
||||
Japanese 5
|
||||
|
||||
6
|
||||
00:00:52,286 --> 00:00:54,955
|
||||
Japanese 6
|
||||
|
||||
7
|
||||
00:00:54,955 --> 00:00:59,793
|
||||
Japanese 7
|
||||
|
||||
8
|
||||
00:00:59,793 --> 00:01:03,630
|
||||
Japanese 8
|
||||
|
||||
9
|
||||
00:01:03,630 --> 00:01:07,634
|
||||
Japanese 9
|
||||
|
||||
10
|
||||
00:01:07,634 --> 00:01:13,040
|
||||
Japanese 10
|
||||
|
||||
11
|
||||
00:01:16,643 --> 00:01:20,814
|
||||
Japanese 11
|
||||
|
||||
12
|
||||
00:01:20,814 --> 00:01:23,116
|
||||
Japanese 12
|
||||
|
||||
13
|
||||
00:01:27,988 --> 00:01:30,991
|
||||
Japanese 13
|
||||
|
||||
14
|
||||
00:01:30,991 --> 00:01:34,094
|
||||
Japanese 14
|
||||
|
||||
15
|
||||
00:01:34,094 --> 00:01:37,097
|
||||
Japanese 15
|
||||
|
||||
16
|
||||
00:01:37,097 --> 00:01:39,100
|
||||
Japanese 16
|
||||
`;
|
||||
const referenceAss = `[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
|
||||
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
|
||||
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
|
||||
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
|
||||
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
|
||||
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
|
||||
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
|
||||
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
|
||||
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
|
||||
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
|
||||
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
|
||||
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
|
||||
`;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 12,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
getSavedSubtitleDelay: () => null,
|
||||
loadSubtitleSourceText: async (source) =>
|
||||
source.endsWith('.ass') ? referenceAss : primarySrt,
|
||||
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
|
||||
savedDelays.push({ itemId, streamIndex, delaySeconds });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
const delayCommand = commands.find(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
|
||||
);
|
||||
assert.ok(delayCommand);
|
||||
const delaySeconds = delayCommand[2];
|
||||
if (typeof delaySeconds !== 'number') {
|
||||
assert.fail('Expected numeric subtitle delay.');
|
||||
}
|
||||
assert.ok(delaySeconds > -32);
|
||||
assert.ok(delaySeconds < -31);
|
||||
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
@@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
|
||||
source: JellyfinSubtitleTrack;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayKey = {
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrack = {
|
||||
id: number;
|
||||
lang: string;
|
||||
@@ -257,54 +249,6 @@ async function waitForPreferredSubtitleTracks(
|
||||
return subtitleTracks;
|
||||
}
|
||||
|
||||
async function estimateSubtitleDelayFromReference(
|
||||
deps: {
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
primaryTrack: CachedExternalSubtitleTrack | null,
|
||||
referenceTrack: CachedExternalSubtitleTrack | null,
|
||||
): Promise<number | null> {
|
||||
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [primaryContent, referenceContent] = await Promise.all([
|
||||
deps.loadSubtitleSourceText(primaryTrack.path),
|
||||
deps.loadSubtitleSourceText(referenceTrack.path),
|
||||
]);
|
||||
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
|
||||
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
|
||||
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEstimatedSubtitleDelay(
|
||||
deps: {
|
||||
saveSubtitleDelay?: (
|
||||
itemId: string,
|
||||
streamIndex: number,
|
||||
delaySeconds: number,
|
||||
) => boolean | void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
key: JellyfinSubtitleDelayKey,
|
||||
delaySeconds: number,
|
||||
): void {
|
||||
try {
|
||||
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
|
||||
if (saved === false) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -316,10 +260,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
wait: (ms: number) => Promise<void>;
|
||||
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
|
||||
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 {
|
||||
@@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
resetManagedSubtitleDelay();
|
||||
try {
|
||||
cleanupActiveCache();
|
||||
} catch (error) {
|
||||
@@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
japanesePrimaryId,
|
||||
);
|
||||
if (selectedCachedTrack) {
|
||||
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
|
||||
deps.setActiveSubtitleDelayKey?.(delayKey);
|
||||
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
|
||||
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
|
||||
} else {
|
||||
const referenceCachedTrack = findCachedTrackForMpvTrackId(
|
||||
resolvedSubtitleTracks,
|
||||
cachedTracks,
|
||||
englishSecondaryId,
|
||||
);
|
||||
const estimatedDelay = await estimateSubtitleDelayFromReference(
|
||||
deps,
|
||||
selectedCachedTrack,
|
||||
referenceCachedTrack,
|
||||
);
|
||||
if (estimatedDelay !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
|
||||
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
|
||||
} else {
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
|
||||
} else {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
}
|
||||
} else {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
|
||||
if (englishSecondaryId !== null) {
|
||||
|
||||
Reference in New Issue
Block a user