fix(jellyfin): keep selecting subtitles when a track download fails (#272)

This commit is contained in:
2026-09-24 21:32:56 -07:00
committed by GitHub
parent 63eb228e4f
commit b24008237b
5 changed files with 91 additions and 18 deletions
@@ -2,3 +2,4 @@ type: fixed
area: jellyfin area: jellyfin
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles. - Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
+11 -2
View File
@@ -377,6 +377,14 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
DeliveryUrl: 'https://cdn.example.com/subs.srt', DeliveryUrl: 'https://cdn.example.com/subs.srt',
IsExternalUrl: true, IsExternalUrl: true,
}, },
{
Type: 'Subtitle',
Index: 5,
Codec: 'PGSSUB',
Language: 'eng',
DisplayTitle: 'English PGS',
DeliveryMethod: 'Embed',
},
], ],
}, },
], ],
@@ -395,10 +403,10 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
clientInfo, clientInfo,
'movie-1', 'movie-1',
); );
assert.equal(tracks.length, 3); assert.equal(tracks.length, 4);
assert.deepEqual( assert.deepEqual(
tracks.map((track) => track.index), tracks.map((track) => track.index),
[2, 3, 4], [2, 3, 4, 5],
); );
assert.equal( assert.equal(
tracks[0]!.deliveryUrl, tracks[0]!.deliveryUrl,
@@ -409,6 +417,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token', 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token',
); );
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt'); assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
assert.equal(tracks[3]!.deliveryUrl, null, 'bitmap subtitles cannot be fetched as text');
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
+13
View File
@@ -146,6 +146,17 @@ function setApiKeyParam(url: URL, accessToken: string): void {
url.searchParams.set('ApiKey', accessToken); url.searchParams.set('ApiKey', accessToken);
} }
const IMAGE_SUBTITLE_CODECS = new Set([
'pgssub',
'pgs',
'hdmv_pgs_subtitle',
'dvdsub',
'dvd_subtitle',
'dvbsub',
'dvb_subtitle',
'xsub',
]);
function resolveDeliveryUrl( function resolveDeliveryUrl(
session: JellyfinAuthSession, session: JellyfinAuthSession,
stream: JellyfinMediaStream, stream: JellyfinMediaStream,
@@ -163,6 +174,8 @@ function resolveDeliveryUrl(
const streamIndex = asIntegerOrNull(stream.Index); const streamIndex = asIntegerOrNull(stream.Index);
if (streamIndex === null || !itemId || !mediaSourceId) return null; if (streamIndex === null || !itemId || !mediaSourceId) return null;
const codec = ensureString(stream.Codec).toLowerCase(); const codec = ensureString(stream.Codec).toLowerCase();
// Jellyfin cannot convert bitmap subtitles to text, so requesting them always fails.
if (IMAGE_SUBTITLE_CODECS.has(codec)) return null;
const ext = const ext =
codec === 'subrip' codec === 'subrip'
? 'srt' ? 'srt'
@@ -471,6 +471,56 @@ test('preload jellyfin subtitles does not lock in a fallback japanese track duri
]); ]);
}); });
test('preload jellyfin subtitles still selects remaining tracks when one download fails', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.srt' },
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 5, language: 'eng', title: 'English PGS', deliveryUrl: 'https://sub/pgs.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 1,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 2,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
cacheSubtitleTrack: async (track) => {
if (track.index === 5) {
throw new Error('Failed to download Jellyfin subtitle (HTTP 400)');
}
return {
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
};
},
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 2],
['set_property', 'secondary-sid', 1],
]);
});
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => { test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler( const preload = createPreloadJellyfinExternalSubtitlesHandler(
+16 -16
View File
@@ -367,15 +367,22 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
// Download every track at once and add each to mpv as soon as it lands. Jellyfin has // Download every track at once and add each to mpv as soon as it lands. Jellyfin has
// to extract embedded tracks from the container, so one slow track must not hold up // to extract embedded tracks from the container, so one slow track must not hold up
// the Japanese primary that annotations depend on. // the Japanese primary that annotations depend on. A failed track is skipped rather
// than failing the whole preload, so the remaining tracks still get selected.
const cachedTracks: CachedExternalSubtitleTrack[] = []; const cachedTracks: CachedExternalSubtitleTrack[] = [];
const downloads = new Map( const downloads = new Map(
uniqueTracks.map((track) => [ uniqueTracks.map((track) => [
track, track,
(async (): Promise<CachedExternalSubtitleTrack> => { (async (): Promise<CachedExternalSubtitleTrack | null> => {
const labelBase = (track.title || track.language || '').trim(); const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`; const label = labelBase || `Jellyfin Subtitle ${track.index}`;
const cached = { ...(await deps.cacheSubtitleTrack(track)), source: track }; let cached: CachedExternalSubtitleTrack;
try {
cached = { ...(await deps.cacheSubtitleTrack(track)), source: track };
} catch (error) {
deps.logDebug(`Failed to download Jellyfin subtitle track ${track.index}`, error);
return null;
}
activeCacheDirs.add(cached.cleanupDir); activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push(cached); cachedTracks.push(cached);
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']); deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
@@ -383,20 +390,17 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
})(), })(),
]), ]),
); );
// Settled up front so a failed download is not flagged as unhandled while the const allDownloads = Promise.all(downloads.values());
// Japanese track is still being selected.
const allDownloads = Promise.allSettled(downloads.values());
try { try {
let subtitleTracks: MpvSubtitleTrack[] = []; let subtitleTracks: MpvSubtitleTrack[] = [];
let japanesePrimaryId: number | null | undefined; let japanesePrimaryId: number | null | undefined;
const preferredJapaneseSource = pickPreferredJapaneseSource(uniqueTracks); const preferredJapaneseSource = pickPreferredJapaneseSource(uniqueTracks);
const preferredJapaneseDownload = preferredJapaneseSource const preferredJapanese = preferredJapaneseSource
? downloads.get(preferredJapaneseSource) ? await downloads.get(preferredJapaneseSource)
: undefined; : null;
if (preferredJapaneseDownload) { if (preferredJapanese) {
const preferredJapanese = await preferredJapaneseDownload;
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS); await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
subtitleTracks = subtitleTracks =
(await waitForPreferredSubtitleTracks(deps, true, [preferredJapanese.path])) ?? []; (await waitForPreferredSubtitleTracks(deps, true, [preferredJapanese.path])) ?? [];
@@ -418,11 +422,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
} }
} }
const results = await allDownloads; await allDownloads;
const failedDownload = results.find((result) => result.status === 'rejected');
if (failedDownload) {
throw failedDownload.reason;
}
const cachedPaths = cachedTracks.map((track) => track.path); const cachedPaths = cachedTracks.map((track) => track.path);
if (!hasExpectedExternalSubtitleTracks(subtitleTracks, cachedPaths)) { if (!hasExpectedExternalSubtitleTracks(subtitleTracks, cachedPaths)) {
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS); await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);