mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix: clear aborted playback state, fix overlay passthrough, and guard du
- Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item - Record visible overlay action only after command succeeds, not before - Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering) - Defer activeParsedSubtitleMediaPath assignment until after prefetch completes - Move autoplay gate release into the hide branch of toggleVisibleOverlay - Clear active Jellyfin playback when stopping media that never loaded - Reset managed subtitle delay and delay key when no external tracks are available - Await async removeDir in subtitle cache cleanup - Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs - Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
This commit is contained in:
@@ -59,17 +59,33 @@ test('same media path updates do not reset autoplay ready fallback state', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles suppress current-media autoplay release', () => {
|
||||
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);/);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') <
|
||||
actionBlock.indexOf('toggleVisibleOverlayHandler();'),
|
||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -290,6 +290,37 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears aborted playback that never loaded', async () => {
|
||||
let cleared = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'Transcode',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: null,
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
throw new Error('should not report stopped for unloaded media');
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => null,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
@@ -409,7 +440,7 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback'
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, false);
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
|
||||
|
||||
@@ -209,7 +209,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
return async (): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
if (playback.loadedMediaPath === null) return;
|
||||
if (playback.loadedMediaPath === null) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof playback.stopReportsAfterMs === 'number' &&
|
||||
Number.isFinite(playback.stopReportsAfterMs) &&
|
||||
|
||||
@@ -67,3 +67,27 @@ test('jellyfin subtitle cache io removes temp dir when download fails', async ()
|
||||
);
|
||||
assert.deepEqual(removed, ['/tmp/subminer-jellyfin-subtitles-failed']);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle cache io awaits async temp cleanup when download fails', async () => {
|
||||
let removed = false;
|
||||
const cacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => '/tmp',
|
||||
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
|
||||
writeFile: async () => {},
|
||||
removeDir: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
removed = true;
|
||||
},
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }),
|
||||
/HTTP 500/,
|
||||
);
|
||||
assert.equal(removed, true);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ type JellyfinSubtitleCacheIoDeps = {
|
||||
tmpDir: () => string;
|
||||
makeTempDir: (prefix: string) => Promise<string>;
|
||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
removeDir: (dir: string, options: { recursive: true; force: true }) => void;
|
||||
removeDir: (dir: string, options: { recursive: true; force: true }) => void | Promise<void>;
|
||||
fetch: (url: string) => Promise<FetchResponseLike>;
|
||||
};
|
||||
|
||||
@@ -59,14 +59,16 @@ export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps)
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
await deps.writeFile(subtitlePath, bytes);
|
||||
} catch (error) {
|
||||
deps.removeDir(cacheDir, { recursive: true, force: true });
|
||||
try {
|
||||
await Promise.resolve(deps.removeDir(cacheDir, { recursive: true, force: true }));
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
return { path: subtitlePath, cleanupDir: cacheDir };
|
||||
},
|
||||
cleanupCachedSubtitles(dirs: string[]): void {
|
||||
for (const dir of dirs) {
|
||||
deps.removeDir(dir, { recursive: true, force: true });
|
||||
void Promise.resolve(deps.removeDir(dir, { recursive: true, force: true })).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,7 +73,8 @@ function withoutTrackAutoSelectionCommands(
|
||||
(command[1] === 'sid' && command[2] === 'no') ||
|
||||
(command[1] === 'secondary-sid' && command[2] === 'no') ||
|
||||
(command[1] === 'sub-visibility' && command[2] === 'no') ||
|
||||
(command[1] === 'secondary-sub-visibility' && command[2] === 'no'))
|
||||
(command[1] === 'secondary-sub-visibility' && command[2] === 'no') ||
|
||||
(command[1] === 'sub-delay' && command[2] === 0))
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -284,6 +285,25 @@ 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]]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese sources', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
@@ -953,7 +973,7 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async (
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.equal(waited, false);
|
||||
assert.deepEqual(commands, []);
|
||||
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles logs debug on failure', async () => {
|
||||
|
||||
@@ -326,9 +326,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
let preloadQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function resetManagedSubtitleDelay(): void {
|
||||
if (deps.getSavedSubtitleDelay) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
@@ -358,6 +356,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user