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:
@@ -272,6 +272,7 @@ function M.create(ctx)
|
|||||||
state.pending_reload_media_identity = nil
|
state.pending_reload_media_identity = nil
|
||||||
state.pending_reload_media_title = nil
|
state.pending_reload_media_title = nil
|
||||||
state.pending_reload_reason = nil
|
state.pending_reload_reason = nil
|
||||||
|
state.app_managed_playback_pending = false
|
||||||
state.app_managed_playback_active = false
|
state.app_managed_playback_active = false
|
||||||
if state.overlay_running and reason ~= "quit" then
|
if state.overlay_running and reason ~= "quit" then
|
||||||
process.hide_visible_overlay()
|
process.hide_visible_overlay()
|
||||||
|
|||||||
@@ -401,7 +401,6 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
run_control_command_async = function(action, overrides, callback)
|
run_control_command_async = function(action, overrides, callback)
|
||||||
record_visible_overlay_action(action)
|
|
||||||
local args = build_command_args(action, overrides)
|
local args = build_command_args(action, overrides)
|
||||||
local command = build_subprocess_command(args)
|
local command = build_subprocess_command(args)
|
||||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
@@ -414,6 +413,9 @@ function M.create(ctx)
|
|||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
}, function(success, result, error)
|
}, function(success, result, error)
|
||||||
local ok = success and (result == nil or result.status == 0)
|
local ok = success and (result == nil or result.status == 0)
|
||||||
|
if ok then
|
||||||
|
record_visible_overlay_action(action)
|
||||||
|
end
|
||||||
if callback then
|
if callback then
|
||||||
callback(ok, result, error)
|
callback(ok, result, error)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -83,10 +83,16 @@ local process = process_module.create({
|
|||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
environment = {
|
environment = {
|
||||||
detect_backend = function()
|
detect_backend = function()
|
||||||
return "x11"
|
return "x11"
|
||||||
end,
|
end,
|
||||||
|
is_linux = function()
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
is_subminer_app_running_async = function(callback)
|
||||||
|
callback(false)
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
options_helper = {
|
options_helper = {
|
||||||
coerce_bool = function(value, default_value)
|
coerce_bool = function(value, default_value)
|
||||||
@@ -125,4 +131,79 @@ for _, timeout_seconds in ipairs(recorded.timeouts) do
|
|||||||
end
|
end
|
||||||
assert_true(retry_timeout_seen, "expected shorter bounded retry timeout")
|
assert_true(retry_timeout_seen, "expected shorter bounded retry timeout")
|
||||||
|
|
||||||
|
do
|
||||||
|
local visibility_state = {
|
||||||
|
binary_path = "/tmp/subminer",
|
||||||
|
overlay_running = true,
|
||||||
|
texthooker_running = false,
|
||||||
|
visible_overlay_requested = false,
|
||||||
|
}
|
||||||
|
local visibility_calls = {}
|
||||||
|
local visibility_mp = {}
|
||||||
|
|
||||||
|
function visibility_mp.command_native_async(command, callback)
|
||||||
|
visibility_calls[#visibility_calls + 1] = command
|
||||||
|
if callback then
|
||||||
|
callback(false, { status = 1, stdout = "", stderr = "failed" }, "failed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local visibility_process = process_module.create({
|
||||||
|
mp = visibility_mp,
|
||||||
|
opts = {
|
||||||
|
backend = "x11",
|
||||||
|
socket_path = "/tmp/subminer.sock",
|
||||||
|
log_level = "debug",
|
||||||
|
texthooker_enabled = true,
|
||||||
|
texthooker_port = 5174,
|
||||||
|
auto_start_visible_overlay = false,
|
||||||
|
},
|
||||||
|
state = visibility_state,
|
||||||
|
binary = {
|
||||||
|
ensure_binary_available = function()
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
environment = {
|
||||||
|
detect_backend = function()
|
||||||
|
return "x11"
|
||||||
|
end,
|
||||||
|
is_linux = function()
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
is_subminer_app_running_async = function(callback)
|
||||||
|
callback(true)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
options_helper = {
|
||||||
|
coerce_bool = function(value, default_value)
|
||||||
|
if value == true or value == "yes" or value == "true" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if value == false or value == "no" or value == "false" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return default_value
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
log = {
|
||||||
|
subminer_log = function(_level, _scope, line)
|
||||||
|
recorded.logs[#recorded.logs + 1] = line
|
||||||
|
end,
|
||||||
|
show_osd = function(_) end,
|
||||||
|
normalize_log_level = function(value)
|
||||||
|
return value or "info"
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
visibility_process.run_control_command_async("show-visible-overlay")
|
||||||
|
|
||||||
|
assert_true(#visibility_calls == 1, "expected visible overlay command to run")
|
||||||
|
assert_true(
|
||||||
|
visibility_state.visible_overlay_requested == false,
|
||||||
|
"failed visible-overlay command should not update requested visibility state"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
print("plugin process retry regression tests: OK")
|
print("plugin process retry regression tests: OK")
|
||||||
|
|||||||
@@ -645,6 +645,36 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/aborted-app-managed.m3u8",
|
||||||
|
media_title = "Aborted App Managed",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local recorded, err = run_plugin_scenario(scenario)
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for aborted app-managed scenario: " .. tostring(err))
|
||||||
|
recorded.script_messages["subminer-managed-subtitles-loading"]()
|
||||||
|
fire_event(recorded, "end-file", { reason = "error" })
|
||||||
|
scenario.path = "/media/next-normal.mkv"
|
||||||
|
scenario.media_title = "Next Normal"
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_start_calls(recorded.async_calls) == 1,
|
||||||
|
"aborted app-managed playback should not leak pending state into the next item"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local scenario = {
|
local scenario = {
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -197,6 +197,68 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
|||||||
assert.ok(!calls.includes('osd'));
|
assert.ok(!calls.includes('osd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
overlayInteractionActive: false,
|
||||||
|
showOverlayLoadingOsd: () => {},
|
||||||
|
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||||
|
} as never);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
overlayInteractionActive: false,
|
||||||
|
showOverlayLoadingOsd: () => {},
|
||||||
|
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(calls.includes('mouse-ignore:false:plain'), false);
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
});
|
||||||
|
|
||||||
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
|
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
|
|||||||
@@ -181,12 +181,13 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
!isTrackedWindowsTargetMinimized &&
|
!isTrackedWindowsTargetMinimized &&
|
||||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||||
|
const isNonNativePassiveOverlay =
|
||||||
|
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||||
const shouldIgnoreMouseEvents =
|
const shouldIgnoreMouseEvents =
|
||||||
shouldUseMacOSMousePassthrough ||
|
shouldUseMacOSMousePassthrough ||
|
||||||
forceMousePassthrough ||
|
forceMousePassthrough ||
|
||||||
|
isNonNativePassiveOverlay ||
|
||||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||||
const isNonNativePassiveOverlay =
|
|
||||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
|
||||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||||
!args.isWindowsPlatform ||
|
!args.isWindowsPlatform ||
|
||||||
|
|||||||
+3
-2
@@ -1830,12 +1830,13 @@ async function refreshSubtitleSidebarFromSource(
|
|||||||
if (!normalizedSourcePath) {
|
if (!normalizedSourcePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||||
normalizedSourcePath,
|
normalizedSourcePath,
|
||||||
lastObservedTimePos,
|
lastObservedTimePos,
|
||||||
normalizedSourcePath,
|
normalizedSourcePath,
|
||||||
);
|
);
|
||||||
|
appState.activeParsedSubtitleMediaPath = nextMediaPath;
|
||||||
}
|
}
|
||||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||||
@@ -6476,8 +6477,8 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
|||||||
function toggleVisibleOverlay(): void {
|
function toggleVisibleOverlay(): void {
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
ensureOverlayWindowsReadyForVisibilityActions();
|
||||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
|
||||||
if (!nextVisible) {
|
if (!nextVisible) {
|
||||||
|
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
} else {
|
} else {
|
||||||
void ensureOverlayMpvSubtitlesHidden();
|
void ensureOverlayMpvSubtitlesHidden();
|
||||||
|
|||||||
@@ -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 source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
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(
|
assert.ok(
|
||||||
actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') <
|
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
||||||
actionBlock.indexOf('toggleVisibleOverlayHandler();'),
|
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,37 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
|||||||
assert.equal(cleared, true);
|
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 () => {
|
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||||
let cleared = false;
|
let cleared = false;
|
||||||
let stoppedPayload: {
|
let stoppedPayload: {
|
||||||
@@ -409,7 +440,7 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback'
|
|||||||
await reportStopped();
|
await reportStopped();
|
||||||
|
|
||||||
assert.equal(stopped, false);
|
assert.equal(stopped, false);
|
||||||
assert.equal(cleared, false);
|
assert.equal(cleared, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
|
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
|
||||||
|
|||||||
@@ -209,7 +209,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
const playback = deps.getActivePlayback();
|
const playback = deps.getActivePlayback();
|
||||||
if (!playback) return;
|
if (!playback) return;
|
||||||
if (playback.loadedMediaPath === null) return;
|
if (playback.loadedMediaPath === null) {
|
||||||
|
deps.clearActivePlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof playback.stopReportsAfterMs === 'number' &&
|
typeof playback.stopReportsAfterMs === 'number' &&
|
||||||
Number.isFinite(playback.stopReportsAfterMs) &&
|
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']);
|
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;
|
tmpDir: () => string;
|
||||||
makeTempDir: (prefix: string) => Promise<string>;
|
makeTempDir: (prefix: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
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>;
|
fetch: (url: string) => Promise<FetchResponseLike>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,14 +59,16 @@ export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps)
|
|||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
await deps.writeFile(subtitlePath, bytes);
|
await deps.writeFile(subtitlePath, bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.removeDir(cacheDir, { recursive: true, force: true });
|
try {
|
||||||
|
await Promise.resolve(deps.removeDir(cacheDir, { recursive: true, force: true }));
|
||||||
|
} catch {}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { path: subtitlePath, cleanupDir: cacheDir };
|
return { path: subtitlePath, cleanupDir: cacheDir };
|
||||||
},
|
},
|
||||||
cleanupCachedSubtitles(dirs: string[]): void {
|
cleanupCachedSubtitles(dirs: string[]): void {
|
||||||
for (const dir of dirs) {
|
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] === 'sid' && command[2] === 'no') ||
|
||||||
(command[1] === 'secondary-sid' && command[2] === 'no') ||
|
(command[1] === 'secondary-sid' && command[2] === 'no') ||
|
||||||
(command[1] === 'sub-visibility' && 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 () => {
|
test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese sources', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
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' });
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
|
||||||
assert.equal(waited, false);
|
assert.equal(waited, false);
|
||||||
assert.deepEqual(commands, []);
|
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preload jellyfin subtitles logs debug on failure', async () => {
|
test('preload jellyfin subtitles logs debug on failure', async () => {
|
||||||
|
|||||||
@@ -326,9 +326,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
let preloadQueue: Promise<void> = Promise.resolve();
|
let preloadQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
function resetManagedSubtitleDelay(): void {
|
function resetManagedSubtitleDelay(): void {
|
||||||
if (deps.getSavedSubtitleDelay) {
|
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupActiveCache(): void {
|
function cleanupActiveCache(): void {
|
||||||
@@ -358,6 +356,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
);
|
);
|
||||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||||
if (externalTracks.length === 0) {
|
if (externalTracks.length === 0) {
|
||||||
|
deps.setActiveSubtitleDelayKey?.(null);
|
||||||
|
resetManagedSubtitleDelay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('delete confirmation dialog swallows Escape before closing', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(process.cwd(), 'stats/src/components/layout/DeleteConfirmDialog.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const handlerBlock = source.match(
|
||||||
|
/const onKeyDown = \(event: KeyboardEvent\) => \{(?<body>[\s\S]*?)\n \};/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(handlerBlock);
|
||||||
|
assert.match(handlerBlock, /event\.preventDefault\(\);/);
|
||||||
|
assert.match(handlerBlock, /event\.stopPropagation\(\);/);
|
||||||
|
assert.match(handlerBlock, /event\.stopImmediatePropagation\(\);/);
|
||||||
|
assert.ok(
|
||||||
|
handlerBlock.indexOf('event.stopPropagation();') < handlerBlock.indexOf('finish(false);'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -40,6 +40,8 @@ export function DeleteConfirmDialog() {
|
|||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Escape') return;
|
if (event.key !== 'Escape') return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
finish(false);
|
finish(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown, true);
|
window.addEventListener('keydown', onKeyDown, true);
|
||||||
|
|||||||
@@ -125,3 +125,40 @@ test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () =>
|
|||||||
await handler();
|
await handler();
|
||||||
assert.equal(capturedError, 'Network failure');
|
assert.equal(capturedError, 'Network failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildDeleteEpisodeHandler guards duplicate clicks while confirmation is pending', async () => {
|
||||||
|
const confirmResolvers: Array<(value: boolean) => void> = [];
|
||||||
|
let confirmCalls = 0;
|
||||||
|
let deleteCalls = 0;
|
||||||
|
const isDeletingRef = { current: false };
|
||||||
|
|
||||||
|
const handler = buildDeleteEpisodeHandler({
|
||||||
|
videoId: 42,
|
||||||
|
title: 'Test Episode',
|
||||||
|
apiClient: {
|
||||||
|
deleteVideo: async () => {
|
||||||
|
deleteCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
confirmFn: () => {
|
||||||
|
confirmCalls += 1;
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
confirmResolvers.push(resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onBack: () => {},
|
||||||
|
setDeleteError: () => {},
|
||||||
|
isDeletingRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = handler();
|
||||||
|
const second = handler();
|
||||||
|
for (const resolveConfirm of confirmResolvers) {
|
||||||
|
resolveConfirm(true);
|
||||||
|
}
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
assert.equal(confirmCalls, 1);
|
||||||
|
assert.equal(deleteCalls, 1);
|
||||||
|
assert.equal(isDeletingRef.current, false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ interface DeleteEpisodeHandlerOptions {
|
|||||||
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||||
return async () => {
|
return async () => {
|
||||||
if (opts.isDeletingRef?.current) return;
|
if (opts.isDeletingRef?.current) return;
|
||||||
if (!(await opts.confirmFn(opts.title))) return;
|
|
||||||
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||||
|
let confirmed = false;
|
||||||
|
try {
|
||||||
|
confirmed = await opts.confirmFn(opts.title);
|
||||||
|
} catch (err) {
|
||||||
|
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
|
||||||
|
opts.setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirmed) {
|
||||||
|
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
opts.setIsDeleting?.(true);
|
opts.setIsDeleting?.(true);
|
||||||
opts.setDeleteError(null);
|
opts.setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
@@ -73,6 +84,7 @@ export function MediaDetailView({
|
|||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||||
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
||||||
const isDeletingEpisodeRef = useRef(false);
|
const isDeletingEpisodeRef = useRef(false);
|
||||||
|
const isDeletingSessionRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalSessions(data?.sessions ?? null);
|
setLocalSessions(data?.sessions ?? null);
|
||||||
@@ -101,7 +113,20 @@ export function MediaDetailView({
|
|||||||
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!(await confirmSessionDelete())) return;
|
if (isDeletingSessionRef.current) return;
|
||||||
|
isDeletingSessionRef.current = true;
|
||||||
|
let confirmed = false;
|
||||||
|
try {
|
||||||
|
confirmed = await confirmSessionDelete();
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
|
||||||
|
isDeletingSessionRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirmed) {
|
||||||
|
isDeletingSessionRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingSessionId(session.sessionId);
|
setDeletingSessionId(session.sessionId);
|
||||||
@@ -114,6 +139,7 @@ export function MediaDetailView({
|
|||||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingSessionId(null);
|
setDeletingSessionId(null);
|
||||||
|
isDeletingSessionRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,34 @@ test('buildBucketDeleteHandler reports errors via onError without calling onSucc
|
|||||||
assert.equal(successCalled, false);
|
assert.equal(successCalled, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildBucketDeleteHandler reports confirmation errors via onError', async () => {
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
let deleteCalled = false;
|
||||||
|
|
||||||
|
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||||
|
|
||||||
|
const handler = buildBucketDeleteHandler({
|
||||||
|
bucket,
|
||||||
|
apiClient: {
|
||||||
|
deleteSessions: async () => {
|
||||||
|
deleteCalled = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
confirm: async () => {
|
||||||
|
throw new Error('confirm failed');
|
||||||
|
},
|
||||||
|
onSuccess: () => {},
|
||||||
|
onError: (message) => {
|
||||||
|
errorMessage = message;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler();
|
||||||
|
|
||||||
|
assert.equal(errorMessage, 'confirm failed');
|
||||||
|
assert.equal(deleteCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => {
|
test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => {
|
||||||
let seenTitle: string | null = null;
|
let seenTitle: string | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
|
|||||||
return async () => {
|
return async () => {
|
||||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||||
if (!(await confirm(title, ids.length))) return;
|
|
||||||
try {
|
try {
|
||||||
|
if (!(await confirm(title, ids.length))) return;
|
||||||
await client.deleteSessions(ids);
|
await client.deleteSessions(ids);
|
||||||
onSuccess(ids);
|
onSuccess(ids);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -120,7 +120,14 @@ export function SessionsTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!(await confirmSessionDelete())) return;
|
let confirmed = false;
|
||||||
|
try {
|
||||||
|
confirmed = await confirmSessionDelete();
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingSessionId(session.sessionId);
|
setDeletingSessionId(session.sessionId);
|
||||||
|
|||||||
Reference in New Issue
Block a user