mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
Merge remote-tracking branch 'origin/main' into add-hachidori-backend
This commit is contained in:
8 files changed
+123
-19
No files matched your search
@@ -213,6 +213,30 @@ test('resolveMediaGenerationInput reads file-local mpv request options', async (
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput keeps stream selection for a directly played remote container', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
// Jellyfin direct play: mpv opens the URL as-is, so it can hold several audio streams.
|
||||
const jellyfinUrl = 'http://jellyfin.local:8096/Videos/abc/stream?static=true';
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: jellyfinUrl,
|
||||
requestProperty: async (name: string) =>
|
||||
name === 'stream-open-filename' ? jellyfinUrl : null,
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, jellyfinUrl);
|
||||
assert.equal(result?.source, 'stream-open-filename');
|
||||
assert.equal(result?.singleResolvedStream, false);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
|
||||
@@ -400,12 +400,15 @@ export async function resolveMediaGenerationInput(
|
||||
return result;
|
||||
}
|
||||
if (streamOpenFilename) {
|
||||
// A URL that yt-dlp resolved (e.g. youtube.com -> googlevideo) is one picked stream.
|
||||
// When mpv opens the path as-is (e.g. Jellyfin direct play), it is the full container,
|
||||
// so mpv's audio stream index still applies.
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
isRemoteMediaPath(streamOpenFilename),
|
||||
isRemoteMediaPath(streamOpenFilename) && streamOpenFilename !== currentVideoPath,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
|
||||
@@ -377,6 +377,14 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
|
||||
DeliveryUrl: 'https://cdn.example.com/subs.srt',
|
||||
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,
|
||||
'movie-1',
|
||||
);
|
||||
assert.equal(tracks.length, 3);
|
||||
assert.equal(tracks.length, 4);
|
||||
assert.deepEqual(
|
||||
tracks.map((track) => track.index),
|
||||
[2, 3, 4],
|
||||
[2, 3, 4, 5],
|
||||
);
|
||||
assert.equal(
|
||||
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',
|
||||
);
|
||||
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 {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@ function setApiKeyParam(url: URL, accessToken: string): void {
|
||||
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(
|
||||
session: JellyfinAuthSession,
|
||||
stream: JellyfinMediaStream,
|
||||
@@ -163,6 +174,8 @@ function resolveDeliveryUrl(
|
||||
const streamIndex = asIntegerOrNull(stream.Index);
|
||||
if (streamIndex === null || !itemId || !mediaSourceId) return null;
|
||||
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 =
|
||||
codec === 'subrip'
|
||||
? '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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
|
||||
@@ -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
|
||||
// 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 downloads = new Map(
|
||||
uniqueTracks.map((track) => [
|
||||
track,
|
||||
(async (): Promise<CachedExternalSubtitleTrack> => {
|
||||
(async (): Promise<CachedExternalSubtitleTrack | null> => {
|
||||
const labelBase = (track.title || track.language || '').trim();
|
||||
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);
|
||||
cachedTracks.push(cached);
|
||||
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
|
||||
// Japanese track is still being selected.
|
||||
const allDownloads = Promise.allSettled(downloads.values());
|
||||
const allDownloads = Promise.all(downloads.values());
|
||||
|
||||
try {
|
||||
let subtitleTracks: MpvSubtitleTrack[] = [];
|
||||
let japanesePrimaryId: number | null | undefined;
|
||||
|
||||
const preferredJapaneseSource = pickPreferredJapaneseSource(uniqueTracks);
|
||||
const preferredJapaneseDownload = preferredJapaneseSource
|
||||
? downloads.get(preferredJapaneseSource)
|
||||
: undefined;
|
||||
if (preferredJapaneseDownload) {
|
||||
const preferredJapanese = await preferredJapaneseDownload;
|
||||
const preferredJapanese = preferredJapaneseSource
|
||||
? await downloads.get(preferredJapaneseSource)
|
||||
: null;
|
||||
if (preferredJapanese) {
|
||||
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
|
||||
subtitleTracks =
|
||||
(await waitForPreferredSubtitleTracks(deps, true, [preferredJapanese.path])) ?? [];
|
||||
@@ -418,11 +422,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
const results = await allDownloads;
|
||||
const failedDownload = results.find((result) => result.status === 'rejected');
|
||||
if (failedDownload) {
|
||||
throw failedDownload.reason;
|
||||
}
|
||||
await allDownloads;
|
||||
const cachedPaths = cachedTracks.map((track) => track.path);
|
||||
if (!hasExpectedExternalSubtitleTracks(subtitleTracks, cachedPaths)) {
|
||||
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
|
||||
|
||||
Reference in new issue
Block a user