From b1bdeabca8818994b73535181dcc5bb6ac3b8592 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 24 May 2026 18:40:56 -0700 Subject: [PATCH] fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77) * fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws * fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause - Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open * fix(jellyfin): fix discovery loop, device identity, tray state, and Disc - Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting * docs(release): trim and consolidate prerelease notes for 0.15.0 - Remove breaking changes section and several redundant bullet points - Consolidate per-platform updater notes into a single entry - Normalize em-dash separators to hyphens in section headers * fix(config): remove trailing commas from config.example.jsonc - Strip trailing commas throughout both config.example.jsonc copies - Reformat inline arrays to multi-line for JSON strictness - Update Jellyfin subtitle preload and playback launch tests and impl * fix(tokenizer): preserve known-word highlight when POS filters suppress - Known-word cache matches now set isKnown=true even for tokens excluded by POS filters - POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate - Jellyfin subtitle preload continues after cleanup failures instead of aborting - Update config docs and option description to document the known-word bypass behavior * fix(jellyfin): send explicit hide/show overlay instead of toggle - Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known - Prevent paused Jellyfin playback from resuming on overlay hide - Fix subtitle cache cleanup to only remove dirs after successful cleanup * fix(jellyfin): fix remote progress sync, seek reporting, and startup sto - arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading * fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows * Fix CodeRabbit review feedback * fix(jellyfin): subtitle timing, resume progress, and overlay sync - Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX * test: update lifecycle cleanup assertion * 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 --- changes/config-settings-option-labels.md | 4 - .../fix-discord-presence-jellyfin-title.md | 4 + .../fix-jellyfin-discovery-playback-loop.md | 4 + changes/fix-jellyfin-discovery-resume.md | 4 + changes/fix-jellyfin-host-device-identity.md | 4 + changes/fix-jellyfin-overlay-toggle.md | 11 + changes/fix-jellyfin-remote-progress-sync.md | 4 + changes/fix-jellyfin-stats-title.md | 4 + changes/fix-jellyfin-subtitle-timing.md | 4 + ...ix-jellyfin-tray-discovery-active-state.md | 4 + ...x-jellyfin-tray-discovery-stale-session.md | 4 + changes/fix-jellyfin-visible-progress.md | 5 + changes/fix-known-words-decks-row-overflow.md | 4 - changes/fix-prerelease-update-channel.md | 4 + changes/fix-sidebar-yomitan-popup-pause.md | 4 + changes/fix-stats-dialog-layering.md | 4 + changes/jellyfin-picker-log-level.md | 4 + changes/jellyfin-visible-overlay.md | 5 + changes/macos-overlay-focus-handoff.md | 4 + changes/rename-config-window-to-settings.md | 7 - changes/settings-modal-layout.md | 4 - config.example.jsonc | 6 +- docs-site/configuration.md | 10 +- docs-site/jellyfin-integration.md | 253 ++--- docs-site/mining-workflow.md | 2 + docs-site/public/config.example.jsonc | 6 +- docs-site/troubleshooting.md | 2 +- launcher/jellyfin.ts | 24 +- launcher/main.test.ts | 22 + launcher/mpv.test.ts | 1 + package.json | 4 +- plugin/subminer/binary.lua | 7 + plugin/subminer/lifecycle.lua | 73 +- plugin/subminer/messages.lua | 11 + plugin/subminer/process.lua | 160 ++- plugin/subminer/session_bindings.lua | 5 + plugin/subminer/state.lua | 7 + release/prerelease-notes.md | 22 +- scripts/test-plugin-binary-windows.lua | 25 + scripts/test-plugin-process-start-retries.lua | 89 +- scripts/test-plugin-session-bindings.lua | 17 + scripts/test-plugin-start-gate.lua | 451 +++++++- src/config/config.test.ts | 13 +- .../definitions/defaults-integrations.ts | 4 - .../definitions/options-integrations.ts | 29 +- src/config/resolve/integrations.ts | 3 - src/config/settings/registry.test.ts | 4 - src/config/settings/registry.ts | 4 - src/core/services/app-lifecycle.test.ts | 38 + src/core/services/app-lifecycle.ts | 9 +- src/core/services/discord-presence.test.ts | 16 + src/core/services/discord-presence.ts | 14 +- .../immersion-tracker-service.test.ts | 92 ++ .../services/immersion-tracker-service.ts | 109 +- src/core/services/index.ts | 6 + src/core/services/jellyfin-remote.test.ts | 38 + src/core/services/jellyfin-remote.ts | 12 +- .../services/jellyfin-subtitle-delay.test.ts | 54 + src/core/services/jellyfin-subtitle-delay.ts | 66 ++ src/core/services/jellyfin.test.ts | 7 +- src/core/services/jellyfin.ts | 29 +- src/core/services/mpv-protocol.ts | 4 +- src/core/services/mpv.test.ts | 52 +- src/core/services/mpv.ts | 12 +- src/core/services/overlay-visibility.test.ts | 158 ++- src/core/services/overlay-visibility.ts | 27 +- src/core/services/stats-window-layer.ts | 29 + .../services/stats-window-lifecycle.test.ts | 23 + src/core/services/stats-window-runtime.ts | 50 +- src/core/services/stats-window.test.ts | 87 ++ src/core/services/stats-window.ts | 83 +- src/core/services/subsync.test.ts | 41 + src/core/services/subsync.ts | 1 + .../services/subtitle-delay-shift.test.ts | 34 + src/core/services/subtitle-delay-shift.ts | 9 +- .../services/subtitle-timing-offset.test.ts | 73 ++ src/core/services/subtitle-timing-offset.ts | 153 +++ src/core/services/tokenizer.test.ts | 20 +- .../tokenizer/annotation-stage.test.ts | 104 +- .../services/tokenizer/annotation-stage.ts | 12 +- src/main-entry-runtime.test.ts | 71 ++ src/main-entry-runtime.ts | 75 +- src/main-entry.ts | 63 +- src/main.ts | 202 +++- src/main/main-wiring.test.ts | 59 ++ src/main/overlay-runtime.test.ts | 18 +- src/main/overlay-runtime.ts | 2 +- src/main/overlay-visibility-runtime.ts | 2 + .../runtime/app-lifecycle-actions.test.ts | 48 +- src/main/runtime/app-lifecycle-actions.ts | 7 +- .../app-lifecycle-main-cleanup.test.ts | 4 + .../runtime/app-lifecycle-main-cleanup.ts | 2 + src/main/runtime/autoplay-ready-gate.test.ts | 81 +- src/main/runtime/autoplay-ready-gate.ts | 16 + .../composers/jellyfin-remote-composer.ts | 4 + .../jellyfin-runtime-composer.test.ts | 17 +- .../composers/jellyfin-runtime-composer.ts | 9 +- .../startup-lifecycle-composer.test.ts | 1 + src/main/runtime/domains/jellyfin.ts | 1 + src/main/runtime/jellyfin-cli-auth.test.ts | 40 +- src/main/runtime/jellyfin-cli-auth.ts | 9 - .../jellyfin-client-info-main-deps.test.ts | 9 +- .../runtime/jellyfin-client-info-main-deps.ts | 4 +- src/main/runtime/jellyfin-client-info.test.ts | 50 +- src/main/runtime/jellyfin-client-info.ts | 24 +- .../runtime/jellyfin-device-identity.test.ts | 24 + src/main/runtime/jellyfin-device-identity.ts | 18 + ...jellyfin-playback-launch-main-deps.test.ts | 15 +- .../jellyfin-playback-launch-main-deps.ts | 7 + .../runtime/jellyfin-playback-launch.test.ts | 619 ++++++++++- src/main/runtime/jellyfin-playback-launch.ts | 165 ++- .../runtime/jellyfin-remote-commands.test.ts | 101 +- src/main/runtime/jellyfin-remote-commands.ts | 15 +- ...llyfin-remote-connection-main-deps.test.ts | 10 + .../jellyfin-remote-connection-main-deps.ts | 2 + .../jellyfin-remote-connection.test.ts | 44 + .../runtime/jellyfin-remote-connection.ts | 11 + .../runtime/jellyfin-remote-main-deps.test.ts | 4 + src/main/runtime/jellyfin-remote-main-deps.ts | 6 + .../runtime/jellyfin-remote-playback.test.ts | 415 +++++++- src/main/runtime/jellyfin-remote-playback.ts | 176 +++- .../jellyfin-remote-session-lifecycle.test.ts | 139 ++- .../jellyfin-remote-session-lifecycle.ts | 36 +- .../jellyfin-remote-session-main-deps.test.ts | 28 +- .../jellyfin-remote-session-main-deps.ts | 4 + src/main/runtime/jellyfin-setup-window.ts | 149 ++- .../jellyfin-subtitle-cache-io.test.ts | 93 ++ .../runtime/jellyfin-subtitle-cache-io.ts | 75 ++ ...ellyfin-subtitle-preload-main-deps.test.ts | 37 +- .../jellyfin-subtitle-preload-main-deps.ts | 15 + .../runtime/jellyfin-subtitle-preload.test.ts | 989 +++++++++++++++++- src/main/runtime/jellyfin-subtitle-preload.ts | 380 ++++++- .../runtime/jellyfin-tray-discovery.test.ts | 118 +++ src/main/runtime/jellyfin-tray-discovery.ts | 34 +- .../runtime/local-subtitle-selection.test.ts | 54 + src/main/runtime/local-subtitle-selection.ts | 13 +- .../mpv-client-runtime-service-main-deps.ts | 4 + .../runtime/mpv-client-runtime-service.ts | 1 + .../runtime/mpv-jellyfin-defaults.test.ts | 7 +- src/main/runtime/mpv-jellyfin-defaults.ts | 7 +- .../runtime/mpv-main-event-actions.test.ts | 52 + src/main/runtime/mpv-main-event-actions.ts | 19 +- src/main/runtime/mpv-main-event-bindings.ts | 2 + src/main/runtime/mpv-main-event-main-deps.ts | 3 + .../runtime/overlay-modal-input-state.test.ts | 2 +- src/main/runtime/overlay-modal-input-state.ts | 2 +- .../overlay-visibility-actions-main-deps.ts | 3 + .../overlay-visibility-actions.test.ts | 7 + .../runtime/overlay-visibility-actions.ts | 4 + ...erlay-visibility-runtime-main-deps.test.ts | 2 + .../overlay-visibility-runtime-main-deps.ts | 1 + .../overlay-visibility-runtime.test.ts | 2 + .../runtime/overlay-visibility-runtime.ts | 7 +- src/main/runtime/subsync-open.test.ts | 1 + .../runtime/subtitle-prefetch-runtime.test.ts | 30 + src/main/runtime/subtitle-prefetch-runtime.ts | 13 + src/main/runtime/tray-lifecycle.test.ts | 57 + src/main/runtime/tray-main-actions.test.ts | 11 +- src/main/runtime/tray-main-actions.ts | 11 +- src/main/runtime/tray-main-deps.test.ts | 9 +- src/main/runtime/tray-main-deps.ts | 7 +- src/main/runtime/tray-runtime.test.ts | 32 +- src/main/runtime/tray-runtime.ts | 24 +- .../runtime/update/update-dialogs.test.ts | 51 + src/main/runtime/update/update-dialogs.ts | 19 +- .../runtime/update/update-service.test.ts | 31 + src/preload-stats.ts | 12 + src/renderer/handlers/keyboard.test.ts | 32 + src/renderer/handlers/keyboard.ts | 5 + src/renderer/modals/subsync.test.ts | 46 + src/renderer/modals/subsync.ts | 32 +- src/renderer/modals/subtitle-sidebar.test.ts | 132 +++ src/renderer/modals/subtitle-sidebar.ts | 58 + src/shared/ipc/contracts.ts | 3 + src/types/config.ts | 4 - src/types/integrations.ts | 4 - src/types/runtime.ts | 1 + src/window-trackers/base-tracker.ts | 4 + src/window-trackers/macos-tracker.test.ts | 29 + src/window-trackers/macos-tracker.ts | 28 +- stats/src/App.tsx | 2 + stats/src/components/anime/EpisodeDetail.tsx | 2 +- stats/src/components/anime/EpisodeList.tsx | 2 +- .../layout/DeleteConfirmDialog.test.tsx | 22 + .../components/layout/DeleteConfirmDialog.tsx | 96 ++ .../library/MediaDetailView.test.tsx | 37 + .../components/library/MediaDetailView.tsx | 32 +- stats/src/components/overview/OverviewTab.tsx | 6 +- .../components/sessions/SessionsTab.test.tsx | 28 + stats/src/components/sessions/SessionsTab.tsx | 13 +- stats/src/lib/delete-confirm.test.ts | 196 +++- stats/src/lib/delete-confirm.ts | 70 +- stats/src/lib/ipc-client.ts | 3 + 193 files changed, 7975 insertions(+), 771 deletions(-) delete mode 100644 changes/config-settings-option-labels.md create mode 100644 changes/fix-discord-presence-jellyfin-title.md create mode 100644 changes/fix-jellyfin-discovery-playback-loop.md create mode 100644 changes/fix-jellyfin-discovery-resume.md create mode 100644 changes/fix-jellyfin-host-device-identity.md create mode 100644 changes/fix-jellyfin-overlay-toggle.md create mode 100644 changes/fix-jellyfin-remote-progress-sync.md create mode 100644 changes/fix-jellyfin-stats-title.md create mode 100644 changes/fix-jellyfin-subtitle-timing.md create mode 100644 changes/fix-jellyfin-tray-discovery-active-state.md create mode 100644 changes/fix-jellyfin-tray-discovery-stale-session.md create mode 100644 changes/fix-jellyfin-visible-progress.md delete mode 100644 changes/fix-known-words-decks-row-overflow.md create mode 100644 changes/fix-prerelease-update-channel.md create mode 100644 changes/fix-sidebar-yomitan-popup-pause.md create mode 100644 changes/fix-stats-dialog-layering.md create mode 100644 changes/jellyfin-picker-log-level.md create mode 100644 changes/jellyfin-visible-overlay.md create mode 100644 changes/macos-overlay-focus-handoff.md delete mode 100644 changes/rename-config-window-to-settings.md delete mode 100644 changes/settings-modal-layout.md create mode 100644 src/core/services/jellyfin-subtitle-delay.test.ts create mode 100644 src/core/services/jellyfin-subtitle-delay.ts create mode 100644 src/core/services/stats-window-layer.ts create mode 100644 src/core/services/stats-window-lifecycle.test.ts create mode 100644 src/core/services/subtitle-timing-offset.test.ts create mode 100644 src/core/services/subtitle-timing-offset.ts create mode 100644 src/main/runtime/jellyfin-device-identity.test.ts create mode 100644 src/main/runtime/jellyfin-device-identity.ts create mode 100644 src/main/runtime/jellyfin-subtitle-cache-io.test.ts create mode 100644 src/main/runtime/jellyfin-subtitle-cache-io.ts create mode 100644 stats/src/components/layout/DeleteConfirmDialog.test.tsx create mode 100644 stats/src/components/layout/DeleteConfirmDialog.tsx diff --git a/changes/config-settings-option-labels.md b/changes/config-settings-option-labels.md deleted file mode 100644 index 1a7fb00e..00000000 --- a/changes/config-settings-option-labels.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: settings - -- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title. diff --git a/changes/fix-discord-presence-jellyfin-title.md b/changes/fix-discord-presence-jellyfin-title.md new file mode 100644 index 00000000..5320f61c --- /dev/null +++ b/changes/fix-discord-presence-jellyfin-title.md @@ -0,0 +1,4 @@ +type: fixed +area: integrations + +- Prevented Discord Rich Presence from falling back to Jellyfin stream URLs, and primed Jellyfin playback titles before loading tokenized streams so presence shows the show/episode title diff --git a/changes/fix-jellyfin-discovery-playback-loop.md b/changes/fix-jellyfin-discovery-playback-loop.md new file mode 100644 index 00000000..19f42947 --- /dev/null +++ b/changes/fix-jellyfin-discovery-playback-loop.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Prevented Jellyfin discovery playback from reloading the active item, misreporting paused mpv playback as still playing, retrying startup unpause after playback is paused again, unpausing after a manual `y-t` overlay toggle during startup, repeatedly restoring the overlay from duplicate ready signals, missing delayed Japanese subtitle selection on startup, letting later German/Russian subtitle loads steal the selected Japanese track, and spawning long-lived sidebar ffmpeg extractors against Jellyfin stream URLs. diff --git a/changes/fix-jellyfin-discovery-resume.md b/changes/fix-jellyfin-discovery-resume.md new file mode 100644 index 00000000..3f09f322 --- /dev/null +++ b/changes/fix-jellyfin-discovery-resume.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Fixed Jellyfin discovery resume playback when a remote play command sends `StartPositionTicks: 0` despite saved progress on the item. diff --git a/changes/fix-jellyfin-host-device-identity.md b/changes/fix-jellyfin-host-device-identity.md new file mode 100644 index 00000000..100ad4bc --- /dev/null +++ b/changes/fix-jellyfin-host-device-identity.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Derived Jellyfin cast device identity from the OS hostname, always reports the client as SubMiner, and ignores legacy configurable Jellyfin client/device identity fields so multiple SubMiner installs no longer share the same remote-session identity. diff --git a/changes/fix-jellyfin-overlay-toggle.md b/changes/fix-jellyfin-overlay-toggle.md new file mode 100644 index 00000000..8c1c97b9 --- /dev/null +++ b/changes/fix-jellyfin-overlay-toggle.md @@ -0,0 +1,11 @@ +type: fixed +area: jellyfin + +- Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes. +- Kept that manual hide sticky across Jellyfin stream redirects that change mpv's path, even when the redirected URL drops mpv's media title. +- Re-armed managed subtitle defaults during those path-changing redirects so Japanese primary subtitles can load on the redirected stream. +- Routed visible-overlay shortcuts and app-side visibility changes back through the mpv plugin so SubMiner overlay toggling stays independent of Jellyfin playback controls. +- Collapsed duplicate visible-overlay toggle events so Hyprland does not process one physical shortcut as hide-then-show. +- Kept passive Linux/Hyprland visible-overlay shows from taking keyboard focus away from mpv/Jellyfin. +- Made Jellyfin external subtitle selection tolerate transient mpv `track-list` read failures and numeric string track IDs so Japanese subtitles are selected after preload on Linux. +- Fixed AppImage-launched Jellyfin playback controls so mpv sends overlay commands to the running SubMiner app-control socket instead of the mounted Electron binary. diff --git a/changes/fix-jellyfin-remote-progress-sync.md b/changes/fix-jellyfin-remote-progress-sync.md new file mode 100644 index 00000000..30b7e211 --- /dev/null +++ b/changes/fix-jellyfin-remote-progress-sync.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. diff --git a/changes/fix-jellyfin-stats-title.md b/changes/fix-jellyfin-stats-title.md new file mode 100644 index 00000000..06a585c1 --- /dev/null +++ b/changes/fix-jellyfin-stats-title.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Grouped Jellyfin playback stats under Jellyfin item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names. diff --git a/changes/fix-jellyfin-subtitle-timing.md b/changes/fix-jellyfin-subtitle-timing.md new file mode 100644 index 00000000..e4d242fc --- /dev/null +++ b/changes/fix-jellyfin-subtitle-timing.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Improved Jellyfin subtitle timing behavior by preferring default embedded subtitle streams over external sidecars, stripping Jellyfin's server-selected subtitle stream from mpv playback URLs, suppressing mpv's subtitle auto-selection and plugin overlay auto-start while SubMiner stages managed tracks, automatically correcting clear Japanese-vs-English cue timeline offsets, and restoring saved per-stream subtitle delay shifts. diff --git a/changes/fix-jellyfin-tray-discovery-active-state.md b/changes/fix-jellyfin-tray-discovery-active-state.md new file mode 100644 index 00000000..5f829ea3 --- /dev/null +++ b/changes/fix-jellyfin-tray-discovery-active-state.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Keep the Jellyfin discovery tray checkbox in sync on Linux after tray, CLI, or startup remote-session changes, with a visible check mark when Linux tray hosts ignore native checkbox rendering. diff --git a/changes/fix-jellyfin-tray-discovery-stale-session.md b/changes/fix-jellyfin-tray-discovery-stale-session.md new file mode 100644 index 00000000..ec246042 --- /dev/null +++ b/changes/fix-jellyfin-tray-discovery-stale-session.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Restarted stale Jellyfin tray discovery sessions when the server no longer lists the SubMiner cast target, avoiding a needless Jellyfin re-login. diff --git a/changes/fix-jellyfin-visible-progress.md b/changes/fix-jellyfin-visible-progress.md new file mode 100644 index 00000000..dcd51e96 --- /dev/null +++ b/changes/fix-jellyfin-visible-progress.md @@ -0,0 +1,5 @@ +type: fixed +area: jellyfin + +- Preserved Jellyfin-visible resume progress when mpv resets its position during playback stop by reusing SubMiner's last known playback position for final progress and stopped reports. +- Kept Jellyfin remote Play and Resume distinct so normal Play starts from the beginning, while Resume starts at Jellyfin's requested position without an early mpv seek race. diff --git a/changes/fix-known-words-decks-row-overflow.md b/changes/fix-known-words-decks-row-overflow.md deleted file mode 100644 index 467a358d..00000000 --- a/changes/fix-known-words-decks-row-overflow.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: config - -- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated. diff --git a/changes/fix-prerelease-update-channel.md b/changes/fix-prerelease-update-channel.md new file mode 100644 index 00000000..adf2825c --- /dev/null +++ b/changes/fix-prerelease-update-channel.md @@ -0,0 +1,4 @@ +type: fixed +area: updater + +- Clarified that beta/RC update checks are controlled by `updates.channel`; set it to `"prerelease"` to receive beta/RC updates. diff --git a/changes/fix-sidebar-yomitan-popup-pause.md b/changes/fix-sidebar-yomitan-popup-pause.md new file mode 100644 index 00000000..fc0dd2ca --- /dev/null +++ b/changes/fix-sidebar-yomitan-popup-pause.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled. diff --git a/changes/fix-stats-dialog-layering.md b/changes/fix-stats-dialog-layering.md new file mode 100644 index 00000000..f5bc9677 --- /dev/null +++ b/changes/fix-stats-dialog-layering.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Stats: Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. diff --git a/changes/jellyfin-picker-log-level.md b/changes/jellyfin-picker-log-level.md new file mode 100644 index 00000000..903aa5da --- /dev/null +++ b/changes/jellyfin-picker-log-level.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Kept Jellyfin picker library discovery working when the running app log level is above info. diff --git a/changes/jellyfin-visible-overlay.md b/changes/jellyfin-visible-overlay.md new file mode 100644 index 00000000..5ce8af7d --- /dev/null +++ b/changes/jellyfin-visible-overlay.md @@ -0,0 +1,5 @@ +type: fixed +area: jellyfin + +- Showed the visible subtitle overlay automatically during Jellyfin playback so configured `subtitleStyle` appearance applies to Jellyfin subtitles. +- Injected the bundled mpv plugin when SubMiner auto-launches mpv for Jellyfin playback, restoring mpv-side keybindings without needing overlay focus. diff --git a/changes/macos-overlay-focus-handoff.md b/changes/macos-overlay-focus-handoff.md new file mode 100644 index 00000000..04902fe0 --- /dev/null +++ b/changes/macos-overlay-focus-handoff.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Kept the macOS visible overlay stable when clicking from the overlay back into mpv. diff --git a/changes/rename-config-window-to-settings.md b/changes/rename-config-window-to-settings.md deleted file mode 100644 index 2eb171b4..00000000 --- a/changes/rename-config-window-to-settings.md +++ /dev/null @@ -1,7 +0,0 @@ -type: changed -area: launcher -breaking: true - -- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage. -- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`. -- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings. diff --git a/changes/settings-modal-layout.md b/changes/settings-modal-layout.md deleted file mode 100644 index 8ec6c8b2..00000000 --- a/changes/settings-modal-layout.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: config - -- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal. diff --git a/config.example.jsonc b/config.example.jsonc index 31444de7..98e3e720 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -523,7 +523,7 @@ "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false - "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface + "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. }, // Known words setting. "behavior": { @@ -644,14 +644,10 @@ "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "username": "", // Default Jellyfin username used during CLI login. - "deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal. - "clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal. - "clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false - "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions. "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false diff --git a/docs-site/configuration.md b/docs-site/configuration.md index d3d1e7ce..d5eb7643 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1045,6 +1045,7 @@ Known-word cache policy: - Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - The cache is automatically invalidated when the configured scope changes (for example, when deck changes). - Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching. +- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token. - Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility. - Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid. - If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. @@ -1253,7 +1254,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner "remoteControlEnabled": true, "remoteControlAutoConnect": true, "autoAnnounce": false, - "remoteControlDeviceName": "SubMiner", "defaultLibraryId": "", "directPlayPreferred": true, "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], @@ -1268,21 +1268,17 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner | `serverUrl` | string (URL) | Jellyfin server base URL | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `username` | string | Default username used by `--jellyfin-login` | -| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) | -| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) | -| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | -| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | -Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. +Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=` on launcher/app invocations when needed. @@ -1299,6 +1295,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. +Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin. + When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session. ### Discord Rich Presence diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 6a63b0c5..2a60b24f 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -1,191 +1,118 @@ # Jellyfin Integration -[Jellyfin](https://jellyfin.org) is a free, self-hosted media server — think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can log in, browse it, and play episodes through mpv with the full mining overlay. +[Jellyfin](https://jellyfin.org) is a free, self-hosted media server — think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay. ::: tip Who needs this? -This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. Most of this integration is driven from the command line, so it is aimed at slightly more advanced users; the in-app setup window (`subminer jellyfin`) is the easiest starting point. +This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point. ::: -SubMiner includes an optional Jellyfin CLI integration for: +SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on…" / cast menu of any Jellyfin app — web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup. -- authenticating against a server -- listing libraries and media items -- launching item playback in the connected mpv instance -- receiving Jellyfin remote cast-to-device playback events in-app -- opening an in-app setup window for server URL and authentication -- toggling Jellyfin cast discovery from the tray once configured +This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end. ## Requirements -- Jellyfin server URL and user credentials -- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow) -- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=` to override. +- A Jellyfin server plus your username and password +- SubMiner installed and running (see [Installation](/installation)) +- On Linux, the session token is stored with `gnome-libsecret` by default -## Setup +## Quick start -1. Set base config values (`config.jsonc`): +### 1. Start SubMiner + +Launch SubMiner so it's running in the system tray. + +### 2. Sign in to your server + +Open the tray menu and click **Configure Jellyfin**. In the window that opens, enter your **Server URL** (for example `http://127.0.0.1:8096`), **Username**, and **Password**, then click **Login**. + +On success, SubMiner: + +- saves an encrypted session token — your password is never stored, +- turns the Jellyfin integration on, and +- remembers the server and username for next time. + +Reopen this window any time to switch servers or **Logout**. + +### 3. Turn on discovery + +Discovery is what makes SubMiner appear as a cast target. Two ways to enable it: + +- **For the current session** — open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.) +- **Automatically on every launch** — already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings). + +### 4. Cast from any Jellyfin app + +In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device — SubMiner appears there named after your computer's hostname. Playback opens in SubMiner. + +From then on, pause / resume / seek / stop and audio or subtitle track changes you make in the Jellyfin app are mirrored in SubMiner, and your watch progress syncs back to Jellyfin (now-playing and resume position). + +## What happens during playback + +- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away. +- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session. +- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load. +- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin. +- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match. +- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track. + +## Settings + +All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting: + +| Setting | Default | What it does | +| ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | +| **Enabled** | Off | Turns the Jellyfin integration on. Switched on for you when you sign in. | +| **Server Url** | — | Your Jellyfin server. Filled in when you sign in. | +| **Remote Control Enabled** | On | Lets SubMiner act as a cast target. | +| **Remote Control Auto Connect** | On | Connects to Jellyfin at startup so discovery is automatic. Turn off if you'd rather start it from the tray each time. | +| **Auto Announce** | Off | Re-broadcasts visibility on connect. Enable if your device is slow to appear in the cast menu. | + +Prefer editing the config file? The same keys live under `jellyfin` in `config.jsonc`: ```jsonc { "jellyfin": { "enabled": true, "serverUrl": "http://127.0.0.1:8096", - "recentServers": ["http://127.0.0.1:8096"], - "username": "your-user", "remoteControlEnabled": true, "remoteControlAutoConnect": true, - "autoAnnounce": false, - "remoteControlDeviceName": "SubMiner", - "defaultLibraryId": "", - "pullPictures": false, - "iconCacheDir": "/tmp/subminer-jellyfin-icons", - "directPlayPreferred": true, - "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], - "transcodeVideoCodec": "h264", }, } ``` -2. Authenticate: +See [Configuration](/configuration) for the full list (transcode codec, direct-play containers, default library, and more). + +## Troubleshooting + +**SubMiner doesn't appear in the cast menu** + +- Make sure SubMiner is running. +- Make sure you're signed in — reopen **Configure Jellyfin** and log in again if your token expired. +- Make sure discovery is on (tray **Jellyfin Discovery**, or **Remote Control Auto Connect** in settings). +- Make sure SubMiner and the Jellyfin client point at the same server. + +**Casting starts but nothing plays** + +- Confirm the item plays normally in another Jellyfin client. +- If mpv was closed, give it a moment — SubMiner launches it on demand and retries. + +**SubMiner keeps disconnecting** + +- Check server/network stability and whether the session token has expired. + +## Security notes + +- The Jellyfin session (access token + user ID) is kept in SubMiner's local encrypted token storage. Your password is used only to log in and is never saved. +- Treat the token storage and your `config.jsonc` as secrets — don't commit them. +- Advanced/headless: the `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` environment variables can supply a session without the sign-in window. + +## Launcher playback + +If you'd rather stay in the terminal, the `subminer` launcher can browse and play Jellyfin media directly, without casting from a Jellyfin app: ```bash -subminer jellyfin -subminer jellyfin -l \ - --server http://127.0.0.1:8096 \ - --username your-user \ - --password 'your-password' +subminer jellyfin -p # alias: subminer jf -p ``` -`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. - -3. List libraries: - -```bash -SubMiner.AppImage --jellyfin-libraries -``` - -Launcher wrapper equivalent for interactive playback flow: - -```bash -subminer jellyfin -p -``` - -Launcher wrapper for Jellyfin cast discovery mode (background app + tray): - -```bash -subminer jellyfin -d -``` - -After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart. - -Stop discovery session/app: - -```bash -subminer app --stop -``` - -`subminer jf ...` is an alias for `subminer jellyfin ...`. - -To clear saved session credentials: - -```bash -subminer jellyfin --logout -``` - -4. List items in a library: - -```bash -SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term -``` - -Optional listing controls: - -- `--jellyfin-recursive=true|false` (default: true) -- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...` - -These are used by the launcher picker flow to: - -- keep root search focused on shows/folders/movies (exclude episode rows) -- browse selected anime/show directories as folder-or-file lists -- recurse for playable files only after selecting a folder - -5. Start playback: - -```bash -SubMiner.AppImage --start -SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID -``` - -Optional stream overrides: - -- `--jellyfin-audio-stream-index N` -- `--jellyfin-subtitle-stream-index N` - -## Playback Behavior - -- Direct play is attempted first when: - - `jellyfin.directPlayPreferred=true` - - media source supports direct stream - - source container matches `jellyfin.directPlayContainers` -- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`. -- Resume position (`PlaybackPositionTicks`) is applied via mpv seek. -- Media title is set in mpv as `[Jellyfin/] `. - -## Cast To Device Mode (jellyfin-mpv-shim style) - -When SubMiner is running with a valid Jellyfin session, it can appear as a -remote playback target in Jellyfin's cast-to-device menu. - -### Requirements - -- `jellyfin.enabled=true` -- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session) -- `jellyfin.remoteControlEnabled=true` (default) -- `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect -- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect) - -### Behavior - -- SubMiner connects to Jellyfin remote websocket and posts playback capabilities. -- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled. -- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`. -- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback. -- `Playstate` events map to mpv pause/resume/seek/stop controls. -- Stream selection commands (`SetAudioStreamIndex`, `SetSubtitleStreamIndex`) are mapped to mpv track selection. -- SubMiner reports start/progress/stop timeline updates back to Jellyfin so now-playing and resume state stay synchronized. -- `--jellyfin-remote-announce` forces an immediate capability re-broadcast and logs whether server sessions can see the device. - -### Troubleshooting - -- Device not visible in Jellyfin cast menu: - - ensure SubMiner is running - - ensure session token is valid (`--jellyfin-login` again if needed) - - ensure `remoteControlEnabled` is true - - use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery -- Cast command received but playback does not start: - - verify mpv IPC can connect (`--start` flow) - - verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...` -- Frequent reconnects: - - check Jellyfin server/network stability and token expiration - -## Failure Handling - -User-visible errors are shown through CLI logs and mpv OSD for: - -- invalid credentials -- expired/invalid token -- server/network errors -- missing library/item identifiers -- no playable source -- mpv not connected for playback - -## Security Notes and Limitations - -- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup. -- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process. -- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`. -- Treat both token storage and config files as secrets and avoid committing them. -- Password is used only for login and is not stored. -- Optional setup UI is available via `--jellyfin`; all actions are also available via CLI flags. -- `subminer` wrapper uses Jellyfin subcommands (`subminer jellyfin ...`, alias `subminer jf ...`). Use `SubMiner.AppImage` for direct `--jellyfin-libraries` and `--jellyfin-items`. -- For direct app CLI usage (`SubMiner.AppImage ...`), `--jellyfin-server` can override server URL for login/play flows without editing config. +This opens an fzf picker (add `-R` for rofi) to browse your libraries and episodes, then plays the selected item in SubMiner's mpv with the same overlay, resume, and subtitle behavior described above. Sign in first (step 2) so the launcher can reach your server. See [Launcher Script](/launcher-script) for the rest of the launcher's features. diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 8ec85ca5..6ff53936 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -164,6 +164,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize 3. For alass, select a reference subtitle track from the video. 4. SubMiner runs the sync and reloads the corrected subtitle. +For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs. + Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. ## Texthooker diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 31444de7..98e3e720 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -523,7 +523,7 @@ "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false - "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface + "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. }, // Known words setting. "behavior": { @@ -644,14 +644,10 @@ "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "username": "", // Default Jellyfin username used during CLI login. - "deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal. - "clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal. - "clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false - "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions. "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 309d0495..0cd58aa5 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -114,7 +114,7 @@ Automatic checks log failures quietly so playback is not interrupted. **"SubMiner is up to date" but a prerelease exists** -SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. +SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. **Launcher update shows a sudo command** diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index 8b27beac..b7bf5fa5 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection( fail('Selected Jellyfin item is not playable.'); } +export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] { + const forwarded = [...appArgs]; + const serverOverride = sanitizeServerUrl(args.jellyfinServer || ''); + if (serverOverride) { + forwarded.push('--jellyfin-server', serverOverride); + } + if (args.passwordStore) { + forwarded.push('--password-store', args.passwordStore); + } + if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) { + forwarded.push('--log-level', args.logLevel); + } + return forwarded; +} + async function runAppJellyfinListCommand( appPath: string, args: Args, @@ -384,14 +399,7 @@ async function runAppJellyfinCommand( appArgs: string[], label: string, ): Promise<{ status: number; output: string; error: string; logOffset: number }> { - const forwardedBase = [...appArgs]; - const serverOverride = sanitizeServerUrl(args.jellyfinServer || ''); - if (serverOverride) { - forwardedBase.push('--jellyfin-server', serverOverride); - } - if (args.passwordStore) { - forwardedBase.push('--password-store', args.passwordStore); - } + const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs); const readLogAppendedSince = (offset: number): string => { const logPath = getMpvLogPath(); diff --git a/launcher/main.test.ts b/launcher/main.test.ts index b4ff4f53..6350aea4 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -17,6 +17,7 @@ import { parseEpisodePathFromDisplay, buildRootSearchGroups, classifyJellyfinChildSelection, + buildForwardedJellyfinAppArgs, } from './jellyfin.js'; type RunResult = { @@ -878,6 +879,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => { ]); }); +test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => { + const forwarded = buildForwardedJellyfinAppArgs( + { + jellyfinServer: 'https://jf.example.test/', + passwordStore: 'gnome-libsecret', + logLevel: 'info', + } as never, + ['--jellyfin-libraries'], + ); + + assert.deepEqual(forwarded, [ + '--jellyfin-libraries', + '--jellyfin-server', + 'https://jf.example.test', + '--password-store', + 'gnome-libsecret', + '--log-level', + 'info', + ]); +}); + test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { const parsed = parseJellyfinErrorFromAppOutput(` [subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 5f4b1232..f070efc1 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -819,6 +819,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy const { dir, socketPath } = createTempSocketPath(); const configDir = path.join(dir, 'launcher-config'); const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); + fs.mkdirSync(configDir, { recursive: true }); const appPath = path.join(dir, 'fake-subminer.sh'); const appInvocationsPath = path.join(dir, 'app-invocations.log'); const receivedControlArgv: string[][] = []; diff --git a/package.json b/package.json index ec223a59..d06117bb 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua index 9b231ebe..ce958441 100644 --- a/plugin/subminer/binary.lua +++ b/plugin/subminer/binary.lua @@ -114,6 +114,13 @@ function M.create(ctx) end end + if not environment.is_windows() then + local appimage_path = resolve_binary_candidate(os.getenv("APPIMAGE")) + if appimage_path and appimage_path ~= "" then + return appimage_path + end + end + return nil end diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 9f077618..2ddd25d5 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -33,6 +33,20 @@ function M.create(ctx) return nil end + local function resolve_media_title() + local media_title = mp.get_property("media-title") + if type(media_title) == "string" and media_title ~= "" then + return media_title + end + + local filename = mp.get_property("filename") + if type(filename) == "string" and filename ~= "" then + return filename + end + + return nil + end + local function is_reload_end_file(reason) return reason == "reload" or reason == "redirect" end @@ -71,6 +85,10 @@ function M.create(ctx) if not has_matching_subminer_socket() then return false end + if state.skip_managed_subtitle_rearm_once then + state.skip_managed_subtitle_rearm_once = false + return true + end mp.set_property_native("sub-auto", "fuzzy") mp.set_property_native("sid", "auto") mp.set_property_native("secondary-sid", "auto") @@ -125,6 +143,10 @@ function M.create(ctx) local function on_start_file() if state.pending_reload_media_identity ~= nil then + local media_identity = resolve_media_identity() + if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then + rearm_managed_subtitle_load_defaults() + end return end rearm_managed_subtitle_load_defaults() @@ -132,24 +154,56 @@ function M.create(ctx) local function on_file_loaded() local media_identity = resolve_media_identity() + local media_title = resolve_media_title() local retry_generation = next_auto_start_retry_generation() local previous_media_identity = state.current_media_identity + local pending_reload_title = state.pending_reload_media_title + local pending_reload_reason = state.pending_reload_reason local same_media_reload = ( media_identity ~= nil and state.pending_reload_media_identity ~= nil and media_identity == state.pending_reload_media_identity + ) or ( + state.pending_reload_media_identity ~= nil + and media_title ~= nil + and pending_reload_title ~= nil + and media_title == pending_reload_title + ) or ( + pending_reload_reason == "redirect" + and state.pending_reload_media_identity ~= nil ) local same_media_loaded = ( media_identity ~= nil and previous_media_identity ~= nil and media_identity == previous_media_identity ) + local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded state.pending_reload_media_identity = nil + state.pending_reload_media_title = nil + state.pending_reload_reason = nil state.current_media_identity = media_identity + state.current_media_title = media_title + if state.app_managed_playback_pending then + state.app_managed_playback_pending = false + state.app_managed_playback_active = true + elseif new_media_loaded then + state.app_managed_playback_active = false + end + if new_media_loaded then + state.suppress_ready_overlay_restore = false + end if same_media_reload then subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") - if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then + if state.app_managed_playback_active then + return + end + if + state.overlay_running + and not state.suppress_ready_overlay_restore + and resolve_auto_start_enabled() + and process.has_matching_mpv_ipc_socket(opts.socket_path) + then process.run_control_command_async("show-visible-overlay", { socket_path = opts.socket_path, }) @@ -167,6 +221,11 @@ function M.create(ctx) process.disarm_auto_play_ready_gate() end + if state.app_managed_playback_active then + subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload") + return + end + if should_auto_start then start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1) return @@ -182,7 +241,12 @@ function M.create(ctx) hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() state.current_media_identity = nil + state.current_media_title = nil state.pending_reload_media_identity = nil + state.pending_reload_media_title = nil + state.pending_reload_reason = nil + state.app_managed_playback_pending = false + state.app_managed_playback_active = false end local function register_lifecycle_hooks() @@ -198,11 +262,18 @@ function M.create(ctx) local reason = type(event) == "table" and event.reason or nil if is_reload_end_file(reason) then state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() + state.pending_reload_media_title = state.current_media_title or resolve_media_title() + state.pending_reload_reason = reason return end next_auto_start_retry_generation() state.current_media_identity = nil + state.current_media_title = nil state.pending_reload_media_identity = nil + state.pending_reload_media_title = nil + state.pending_reload_reason = nil + state.app_managed_playback_pending = false + state.app_managed_playback_active = false if state.overlay_running and reason ~= "quit" then process.hide_visible_overlay() end diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index a62824da..ce9c1bd2 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -6,6 +6,7 @@ function M.create(ctx) local aniskip = ctx.aniskip local hover = ctx.hover local ui = ctx.ui + local state = ctx.state local function register_script_messages() mp.register_script_message("subminer-start", function(...) @@ -17,6 +18,16 @@ function M.create(ctx) mp.register_script_message("subminer-toggle", function() process.toggle_overlay() end) + mp.register_script_message("subminer-visible-overlay-hidden", function() + process.record_visible_overlay_visibility(false) + end) + mp.register_script_message("subminer-visible-overlay-shown", function() + process.record_visible_overlay_visibility(true) + end) + mp.register_script_message("subminer-managed-subtitles-loading", function() + state.skip_managed_subtitle_rearm_once = true + state.app_managed_playback_pending = true + end) mp.register_script_message("subminer-menu", function() ui.show_menu() end) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index b0fcf3cc..eae53ff7 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -7,6 +7,7 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 +local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25 function M.create(ctx) local mp = ctx.mp @@ -31,6 +32,16 @@ function M.create(ctx) return options_helper.coerce_bool(raw_visible_overlay, false) end + local function resolve_auto_start_visibility_action() + if resolve_visible_overlay_startup() then + if state.suppress_ready_overlay_restore then + return nil + end + return "show-visible-overlay" + end + return "hide-visible-overlay" + end + local function resolve_pause_until_ready() local raw_pause_until_ready = opts.auto_start_pause_until_ready if raw_pause_until_ready == nil then @@ -67,6 +78,89 @@ function M.create(ctx) return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS end + local function record_visible_overlay_action(action) + if action == "show-visible-overlay" then + state.visible_overlay_requested = true + state.suppress_ready_overlay_restore = false + elseif action == "hide-visible-overlay" then + state.visible_overlay_requested = false + elseif action == "toggle-visible-overlay" and state.visible_overlay_requested ~= nil then + state.visible_overlay_requested = not state.visible_overlay_requested + if state.visible_overlay_requested then + state.suppress_ready_overlay_restore = false + end + end + end + + local function record_visible_overlay_visibility(visible) + if visible then + state.visible_overlay_requested = true + state.suppress_ready_overlay_restore = false + return + end + state.visible_overlay_requested = false + state.suppress_ready_overlay_restore = true + end + + local function record_start_visibility_args(args) + for _, arg in ipairs(args) do + if arg == "--show-visible-overlay" then + record_visible_overlay_action("show-visible-overlay") + return + end + if arg == "--hide-visible-overlay" then + record_visible_overlay_action("hide-visible-overlay") + return + end + end + end + + local function should_run_visibility_action(action) + if action == "show-visible-overlay" and state.visible_overlay_requested == true then + return false + end + if action == "hide-visible-overlay" and state.visible_overlay_requested == false then + return false + end + return true + end + + local function run_visibility_action_if_needed(action, overrides, callback) + if action == nil then + if callback then + callback(true) + end + return + end + if not should_run_visibility_action(action) then + subminer_log("debug", "process", "Skipping duplicate visible overlay action: " .. tostring(action)) + if callback then + callback(true) + end + return + end + run_control_command_async(action, overrides, callback) + end + + local function should_ignore_duplicate_visible_overlay_toggle() + if type(mp.get_time) ~= "function" then + return false + end + local now = mp.get_time() + if type(now) ~= "number" then + return false + end + + local previous = state.last_visible_overlay_toggle_time + state.last_visible_overlay_toggle_time = now + if type(previous) ~= "number" then + return false + end + + local elapsed = now - previous + return elapsed >= 0 and elapsed < DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS + end + local function normalize_socket_path(path) if type(path) ~= "string" then return nil @@ -129,7 +223,7 @@ function M.create(ctx) local function release_auto_play_ready_gate(reason) if not state.auto_play_ready_gate_armed then - return + return false end local should_resume_playback = state.auto_play_ready_should_resume_playback == true disarm_auto_play_ready_gate({ resume_playback = false }) @@ -140,6 +234,7 @@ function M.create(ctx) else subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready")) end + return true end local function arm_auto_play_ready_gate() @@ -179,9 +274,12 @@ function M.create(ctx) end local function notify_auto_play_ready() - release_auto_play_ready_gate("tokenization-ready") + local released_ready_gate = release_auto_play_ready_gate("tokenization-ready") local force_ready_overlay_restore = state.force_ready_overlay_restore == true state.force_ready_overlay_restore = false + if not released_ready_gate and not force_ready_overlay_restore then + return + end if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then return end @@ -189,7 +287,7 @@ function M.create(ctx) state.suppress_ready_overlay_restore = false end if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then - run_control_command_async("show-visible-overlay", { + run_visibility_action_if_needed("show-visible-overlay", { socket_path = opts.socket_path, }) end @@ -224,7 +322,7 @@ function M.create(ctx) local should_show_visible = overrides.show_visible_overlay if should_show_visible == nil then - should_show_visible = resolve_visible_overlay_startup() + should_show_visible = resolve_visible_overlay_startup() and not state.suppress_ready_overlay_restore end if should_show_visible then table.insert(args, "--show-visible-overlay") @@ -315,6 +413,9 @@ function M.create(ctx) capture_stderr = true, }, function(success, result, error) local ok = success and (result == nil or result.status == 0) + if ok then + record_visible_overlay_action(action) + end if callback then callback(ok, result, error) end @@ -399,9 +500,6 @@ function M.create(ctx) local function start_overlay(overrides) overrides = overrides or {} - if overrides.auto_start_trigger == true then - state.suppress_ready_overlay_restore = false - end if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") @@ -424,10 +522,8 @@ function M.create(ctx) elseif not state.auto_play_ready_gate_armed then disarm_auto_play_ready_gate() end - local visibility_action = resolve_visible_overlay_startup() - and "show-visible-overlay" - or "hide-visible-overlay" - run_control_command_async(visibility_action, { + local visibility_action = resolve_auto_start_visibility_action() + run_visibility_action_if_needed(visibility_action, { socket_path = socket_path, log_level = overrides.log_level, }) @@ -470,6 +566,7 @@ function M.create(ctx) state.overlay_running = true local command = build_subprocess_command(args) + record_start_visibility_args(args) mp.command_native_async({ name = "subprocess", args = command.args, @@ -495,13 +592,11 @@ function M.create(ctx) end if overrides.auto_start_trigger == true then - local visibility_action = resolve_visible_overlay_startup() - and "show-visible-overlay" - or "hide-visible-overlay" - run_control_command_async(visibility_action, { - socket_path = socket_path, - log_level = overrides.log_level, - }) + local visibility_action = resolve_auto_start_visibility_action() + run_visibility_action_if_needed(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) end end) @@ -546,7 +641,8 @@ function M.create(ctx) show_osd("Stopped") end - local function hide_visible_overlay() + local function hide_visible_overlay(options) + options = options or {} if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") return @@ -566,7 +662,9 @@ function M.create(ctx) end end) - disarm_auto_play_ready_gate() + disarm_auto_play_ready_gate({ + resume_playback = options.resume_playback ~= false, + }) end local function toggle_overlay() @@ -575,7 +673,28 @@ function M.create(ctx) show_osd("Error: binary not found") return end + if should_ignore_duplicate_visible_overlay_toggle() then + subminer_log("debug", "process", "Ignoring duplicate visible overlay toggle") + return + end + if state.visible_overlay_requested == true then + state.suppress_ready_overlay_restore = true + hide_visible_overlay({ resume_playback = false }) + return + end + if state.visible_overlay_requested == false then + state.suppress_ready_overlay_restore = false + disarm_auto_play_ready_gate({ resume_playback = false }) + run_control_command_async("show-visible-overlay", nil, function(ok) + if not ok then + subminer_log("warn", "process", "Show-visible-overlay command failed") + show_osd("Toggle failed") + end + end) + return + end state.suppress_ready_overlay_restore = true + disarm_auto_play_ready_gate({ resume_playback = false }) run_control_command_async("toggle-visible-overlay", nil, function(ok) if not ok then @@ -705,6 +824,7 @@ function M.create(ctx) build_command_args = build_command_args, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, run_control_command_async = run_control_command_async, + record_visible_overlay_visibility = record_visible_overlay_visibility, run_binary_command_async = run_binary_command_async, parse_start_script_message_overrides = parse_start_script_message_overrides, ensure_texthooker_running = ensure_texthooker_running, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 194aec56..d7fa2c5a 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -251,6 +251,11 @@ function M.create(ctx) return end + if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then + process.toggle_overlay() + return + end + invoke_cli_action(binding.actionId, binding.payload) end diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 7444e5ad..bdfc9d74 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -35,8 +35,15 @@ function M.new() auto_play_ready_osd_timer = nil, suppress_ready_overlay_restore = false, force_ready_overlay_restore = false, + visible_overlay_requested = nil, + last_visible_overlay_toggle_time = nil, current_media_identity = nil, + current_media_title = nil, pending_reload_media_identity = nil, + pending_reload_media_title = nil, + pending_reload_reason = nil, + app_managed_playback_pending = false, + app_managed_playback_active = false, auto_start_retry_generation = 0, session_binding_generation = 0, session_binding_names = {}, diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md index 3e6b63fd..eead18ac 100644 --- a/release/prerelease-notes.md +++ b/release/prerelease-notes.md @@ -1,10 +1,6 @@ > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. ## Highlights -### Breaking Changes - -- **Settings Window:** The Configuration window is now called the Settings window everywhere — UI, tray menu, docs, and CLI. `--config` and `subminer config` (no action) are replaced by `--settings` and `subminer settings`; `subminer config` now only accepts `path` or `show`. The `--settings` alias that previously opened the Yomitan settings popup is removed — use `--yomitan` instead. - ### Added - **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only. @@ -17,16 +13,12 @@ ### Changed -- **Settings Window:** Option rows no longer display raw config paths; live/restart status is shown inline beside each option title. Known-words deck rows are now cards with the deck name on a separate header line so long names remain readable. Playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls have been reorganized. - - **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. - **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set. - **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow. -- **Subsync:** Always opens the manual subtitle picker. The `subsync.defaultMode` config option has been removed. - - **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field. - **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry. @@ -47,17 +39,13 @@ - **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. -- **Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. Update check traffic is routed through `/usr/bin/curl` to avoid Electron network-service crashes during video startup. +- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater, `subminer -u` can update independently of the tray app, macOS update dialogs come to the front reliably, unsupported builds show a manual-install message, and Windows keeps the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups now avoid Electron networking on Linux and macOS. -- **Updater — macOS:** Update dialogs now reliably come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native `electron-updater`/Squirrel path; supplemental GitHub release lookups are routed through `/usr/bin/curl`, eliminating the last Electron-networking path from background update checks. - -- **Updater — Windows:** Automatic updates keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch. - -- **Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed — both return control to the terminal without requiring Ctrl+C. +- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed - both return control to the terminal without requiring Ctrl+C. - **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running. -- **Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently. +- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently. - **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. @@ -79,9 +67,7 @@ - **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. -- **Config:** User config files are preserved during legacy compatibility handling. The note-fields note-type picker now defaults to the configured Anki deck's note type, falling back to `Kiku`, then `Lapis`, then blank for manual selection. - -- **Build — Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation. +- **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation. ### Docs diff --git a/scripts/test-plugin-binary-windows.lua b/scripts/test-plugin-binary-windows.lua index c04e77f4..749aa5f2 100644 --- a/scripts/test-plugin-binary-windows.lua +++ b/scripts/test-plugin-binary-windows.lua @@ -68,6 +68,31 @@ local function create_binary_module(config) return binary end +do + local appimage_path = "/home/tester/.local/bin/SubMiner.AppImage" + local mounted_binary_path = "/tmp/.mount_SubMiner/SubMiner" + local resolved = with_env({ + APPIMAGE = appimage_path, + }, function() + local binary = create_binary_module({ + is_windows = false, + binary_path = mounted_binary_path, + entries = { + [appimage_path] = "file", + [mounted_binary_path] = "file", + }, + }) + + return binary.find_binary() + end) + + assert_equal( + resolved, + appimage_path, + "linux resolver should prefer APPIMAGE over the mounted AppImage inner binary" + ) +end + do local binary = create_binary_module({ is_windows = true, diff --git a/scripts/test-plugin-process-start-retries.lua b/scripts/test-plugin-process-start-retries.lua index cf5fca17..fb5b1d4e 100644 --- a/scripts/test-plugin-process-start-retries.lua +++ b/scripts/test-plugin-process-start-retries.lua @@ -83,10 +83,16 @@ local process = process_module.create({ return true end, }, - environment = { - detect_backend = function() - return "x11" - end, + environment = { + detect_backend = function() + return "x11" + end, + is_linux = function() + return false + end, + is_subminer_app_running_async = function(callback) + callback(false) + end, }, options_helper = { coerce_bool = function(value, default_value) @@ -125,4 +131,79 @@ for _, timeout_seconds in ipairs(recorded.timeouts) do end 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") diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 437cfa6a..88b81e21 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -23,6 +23,7 @@ local recorded = { async_calls = {}, mpv_commands = {}, osd = {}, + overlay_toggles = 0, } local mp = {} @@ -68,6 +69,14 @@ local ctx = { return { numericSelectionTimeoutMs = 3000, bindings = { + { + key = { + code = "KeyO", + modifiers = { "alt", "shift" }, + }, + actionType = "session-action", + actionId = "toggleVisibleOverlay", + }, { key = { code = "KeyS", @@ -253,6 +262,9 @@ local ctx = { run_binary_command_async = function(args) recorded.async_calls[#recorded.async_calls + 1] = args end, + toggle_overlay = function() + recorded.overlay_toggles = recorded.overlay_toggles + 1 + end, }, environment = { resolve_session_bindings_artifact_path = function() @@ -318,6 +330,11 @@ local expected_cli_bindings = { { keys = "w", flag = "--mark-watched" }, } +local visible_overlay_toggle = find_binding("Alt+O") +assert_true(visible_overlay_toggle ~= nil, "visible overlay session binding should register") +visible_overlay_toggle.fn() +assert_true(recorded.overlay_toggles == 1, "visible overlay session binding should use plugin toggle") + for _, expected in ipairs(expected_cli_bindings) do local binding = find_binding(expected.keys) assert_true(binding ~= nil, "default session action should register " .. expected.keys) diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 1476cdd4..81043e2c 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -201,7 +201,7 @@ local function run_plugin_scenario(config) end function mp.set_osd_ass(...) end function mp.get_time() - return 0 + return config.now or 0 end function mp.commandv(...) end function mp.set_property_native(name, value) @@ -623,16 +623,18 @@ local binary_path = "/tmp/subminer-binary" local appimage_path = "/tmp/SubMiner.AppImage" do - local recorded, err = run_plugin_scenario({ + local scenario = { process_list = "", option_overrides = { binary_path = binary_path, auto_start = "no", }, + now = 20, files = { [binary_path] = true, }, - }) + } + local recorded, err = run_plugin_scenario(scenario) assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err)) assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") recorded.script_messages["subminer-start"]("texthooker=no") @@ -643,6 +645,36 @@ do ) 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 local scenario = { process_list = "", @@ -683,6 +715,236 @@ do ) 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/jellyfin-app-toggle-initial.m3u8", + media_title = "Jellyfin App Toggle", + paused = true, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for app-side hide Jellyfin redirect: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-visible-overlay-hidden"]() + fire_event(recorded, "end-file", { reason = "redirect" }) + scenario.path = "/media/jellyfin-app-toggle-final.m3u8" + scenario.media_title = "" + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "app-side hide sync should suppress path-changing Jellyfin redirect visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "app-side hide sync followed by Jellyfin redirect should keep paused playback paused" + ) +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/jellyfin-duplicate-toggle.m3u8", + media_title = "Jellyfin Duplicate Toggle", + paused = true, + now = 10, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for duplicate visible overlay toggle: " .. tostring(err)) + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-toggle"]() + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1, + "duplicate same-tick visible overlay toggles should hide once" + ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "duplicate same-tick visible overlay toggles should not immediately show the overlay again" + ) + scenario.now = 10.5 + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "later visible overlay toggle should still show after duplicate suppression window" + ) +end + +do + local scenario = { + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + }, + now = 20, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for visible overlay state sync scenario: " .. tostring(err)) + assert_true( + recorded.script_messages["subminer-visible-overlay-hidden"] ~= nil, + "hidden visibility sync message should be registered" + ) + assert_true( + recorded.script_messages["subminer-visible-overlay-shown"] ~= nil, + "shown visibility sync message should be registered" + ) + recorded.script_messages["subminer-visible-overlay-hidden"]() + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "toggle after app-side hide should explicitly show SubMiner overlay through plugin state" + ) + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0, + "toggle after app-side hide should avoid app-side visible overlay toggle" + ) + scenario.now = 20.5 + recorded.script_messages["subminer-visible-overlay-shown"]() + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1, + "toggle after app-side show should explicitly hide SubMiner overlay through plugin state" + ) +end + +do + local recorded, err = run_plugin_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/jellyfin-stream.m3u8", + media_title = "Jellyfin Episode", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for y-t hide visible overlay scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + local toggle_binding = nil + for _, candidate in ipairs(recorded.key_bindings) do + if candidate.name == "subminer-toggle" then + toggle_binding = candidate + break + end + end + assert_true(toggle_binding ~= nil, "y-t toggle binding should be registered") + toggle_binding.fn() + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1, + "y-t should hide the known visible overlay explicitly instead of app-side toggle" + ) + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0, + "y-t should avoid app-side toggle when plugin knows the overlay is visible" + ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "manual y-t hide should suppress duplicate auto-start and ready-time visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual y-t hide should not resume paused Jellyfin playback" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "no", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Jellyfin Managed Playback", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for managed Jellyfin subtitle preload scenario: " .. tostring(err)) + assert_true( + recorded.script_messages["subminer-managed-subtitles-loading"] ~= nil, + "managed subtitle preload script message should be registered" + ) + recorded.script_messages["subminer-managed-subtitles-loading"]() + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + fire_event(recorded, "file-loaded") + assert_true( + not has_property_set(recorded.property_sets, "sid", "auto"), + "managed Jellyfin preload should not rearm primary subtitle auto-selection before app-selected subtitles load" + ) + assert_true( + not has_property_set(recorded.property_sets, "secondary-sid", "auto"), + "managed Jellyfin preload should not rearm secondary subtitle auto-selection before app-selected subtitles load" + ) + assert_true( + not has_property_set(recorded.property_sets, "sub-auto", "fuzzy"), + "managed Jellyfin preload should not re-enable subtitle autoloading before app-selected subtitles load" + ) + assert_true( + count_start_calls(recorded.async_calls) == 0, + "managed Jellyfin preload should let the app show the overlay after subtitle preload instead of plugin auto-start" + ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "managed Jellyfin preload should not reassert the visible overlay during duplicate file-loaded events" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 0, + "managed Jellyfin preload should not arm the plugin pause gate before app-selected subtitles load" + ) + fire_event(recorded, "end-file", { reason = "stop" }) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + assert_true( + count_property_set(recorded.property_sets, "sid", "auto") == 1, + "managed subtitle preload suppression should only apply to one playback lifecycle" + ) + assert_true( + count_start_calls(recorded.async_calls) == 1, + "plugin auto-start should resume after the managed Jellyfin lifecycle ends" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -932,8 +1194,8 @@ do recorded.script_messages["subminer-restart"]() recorded.script_messages["subminer-autoplay-ready"]() assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, - "manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled" + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "manual restart should avoid a second visible overlay restore after launch already requested visibility" ) end @@ -1328,8 +1590,8 @@ do "auto-start with visible overlay enabled should not include --hide-visible-overlay on --start" ) assert_true( - find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, - "auto-start with visible overlay enabled should issue a separate --show-visible-overlay command" + find_control_call(recorded.async_calls, "--show-visible-overlay") == nil, + "auto-start with visible overlay enabled should rely on the --start visibility flag instead of a separate --show-visible-overlay command" ) assert_true( not has_property_set(recorded.property_sets, "pause", true), @@ -1360,8 +1622,8 @@ do "duplicate file-loaded events should not issue duplicate --start commands while overlay is already running" ) assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, - "duplicate auto-start should re-assert visible overlay state when overlay is already running" + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "duplicate auto-start should not re-assert visible overlay state when it is already requested" ) assert_true( count_osd_message(recorded.osd, "SubMiner: Already running") == 0, @@ -1396,8 +1658,8 @@ do "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" ) assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4, - "duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load" + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "duplicate pause-until-ready auto-start should not re-assert visible overlay after the start command already requested it" ) assert_true( count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 1, @@ -1458,8 +1720,8 @@ do "autoplay-ready should show loaded OSD message" ) assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, - "autoplay-ready should re-assert visible overlay state" + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "autoplay-ready should not re-assert visible overlay state after the start command already requested it" ) assert_true( #recorded.periodic_timers == 1, @@ -1471,6 +1733,33 @@ do ) end +do + local recorded, err = run_plugin_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", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for duplicate autoplay-ready scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered") + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "duplicate autoplay-ready signals should not spawn visible overlay restore commands when start already requested visibility" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1523,14 +1812,22 @@ do assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") recorded.script_messages["subminer-toggle"]() assert_true( - count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, - "manual toggle should use explicit visible-overlay toggle command" + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1, + "manual toggle-off should hide a known visible overlay explicitly" + ) + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0, + "manual toggle-off should avoid app-side toggle when plugin knows the overlay is visible" ) recorded.script_messages["subminer-autoplay-ready"]() assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, "manual toggle-off before readiness should suppress ready-time visible overlay restore" ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off before readiness should not resume playback when readiness arrives" + ) end do @@ -1559,11 +1856,127 @@ do recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]() assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, "manual toggle-off should suppress repeated ready-time visible overlay restores for the same session" ) end +do + local recorded, err = run_plugin_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/jellyfin-stream.m3u8", + media_title = "Jellyfin Episode", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for manual hide duplicate auto-start scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") + recorded.script_messages["subminer-toggle"]() + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "manual toggle-off should suppress duplicate auto-start visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off followed by duplicate auto-start should keep paused playback paused" + ) +end + +do + local media_path = "/media/jellyfin-redirect.m3u8" + local recorded, err = run_plugin_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_path, + media_title = "Jellyfin Redirect", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for manual hide same-media reload scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-toggle"]() + fire_event(recorded, "end-file", { reason = "redirect" }) + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "manual toggle-off should suppress same-media reload visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off followed by same-media reload should keep paused playback paused" + ) +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/jellyfin-redirect-initial.m3u8", + media_title = "Jellyfin Redirect", + paused = true, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for manual hide path-changing Jellyfin redirect: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-toggle"]() + fire_event(recorded, "end-file", { reason = "redirect" }) + scenario.path = "/media/jellyfin-redirect-final.m3u8" + scenario.media_title = "" + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, + "manual toggle-off should suppress path-changing Jellyfin redirect visible overlay reassertion even if media-title drops" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off followed by path-changing Jellyfin reload should keep paused playback paused" + ) + assert_true( + count_property_set(recorded.property_sets, "sid", "auto") == 2, + "path-changing Jellyfin redirect should rearm primary subtitle selection before mpv loads tracks" + ) + assert_true( + count_property_set(recorded.property_sets, "secondary-sid", "auto") == 2, + "path-changing Jellyfin redirect should rearm secondary subtitle selection before mpv loads tracks" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1721,8 +2134,8 @@ do "auto-start with visible overlay disabled should not include --show-visible-overlay on --start" ) assert_true( - find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil, - "auto-start with visible overlay disabled should issue a separate --hide-visible-overlay command" + find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil, + "auto-start with visible overlay disabled should rely on the --start visibility flag instead of a separate --hide-visible-overlay command" ) end diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 51d6fe59..bb4fa7c0 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -75,7 +75,10 @@ test('loads defaults when config is missing', () => { assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, false); - assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); + assert.equal('clientName' in config.jellyfin, false); + assert.equal('remoteControlDeviceName' in config.jellyfin, false); + assert.equal('deviceId' in config.jellyfin, false); + assert.equal('clientVersion' in config.jellyfin, false); assert.equal(config.ai.enabled, false); assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.texthooker.openBrowser, false); @@ -832,7 +835,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns ); }); -test('parses jellyfin remote control fields', () => { +test('parses jellyfin remote control fields and ignores legacy identity fields', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), @@ -843,6 +846,7 @@ test('parses jellyfin remote control fields', () => { "remoteControlEnabled": true, "remoteControlAutoConnect": true, "autoAnnounce": true, + "clientName": "Custom Client", "remoteControlDeviceName": "SubMiner" } }`, @@ -857,7 +861,8 @@ test('parses jellyfin remote control fields', () => { assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, true); - assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); + assert.equal('clientName' in config.jellyfin, false); + assert.equal('remoteControlDeviceName' in config.jellyfin, false); }); test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => { @@ -2469,6 +2474,8 @@ test('template generator includes known keys', () => { assert.match(output, /"startupWarmups":/); assert.match(output, /"updates":/); assert.match(output, /"youtube":/); + assert.doesNotMatch(output, /"deviceId":/); + assert.doesNotMatch(output, /"clientVersion":/); assert.doesNotMatch(output, /"youtubeSubgen":/); assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"preserveLineBreaks": false/); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 1815daec..3a4cc8f0 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -130,14 +130,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< serverUrl: '', recentServers: [], username: '', - deviceId: 'subminer', - clientName: 'SubMiner', - clientVersion: '0.1.0', defaultLibraryId: '', remoteControlEnabled: true, remoteControlAutoConnect: true, autoAnnounce: false, - remoteControlDeviceName: 'SubMiner', pullPictures: false, iconCacheDir: '/tmp/subminer-jellyfin-icons', directPlayPreferred: true, diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 2cf6ca7d..1d68a5cb 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -265,7 +265,8 @@ export function buildIntegrationConfigOptionRegistry( kind: 'enum', enumValues: ['headword', 'surface'], defaultValue: defaultConfig.ankiConnect.knownWords.matchMode, - description: 'Known-word matching strategy for subtitle annotations.', + description: + 'Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types.', }, { path: 'ankiConnect.knownWords.highlightEnabled', @@ -548,26 +549,6 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.jellyfin.username, description: 'Default Jellyfin username used during CLI login.', }, - { - path: 'jellyfin.deviceId', - kind: 'string', - defaultValue: defaultConfig.jellyfin.deviceId, - description: - 'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.', - }, - { - path: 'jellyfin.clientName', - kind: 'string', - defaultValue: defaultConfig.jellyfin.clientName, - description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.', - }, - { - path: 'jellyfin.clientVersion', - kind: 'string', - defaultValue: defaultConfig.jellyfin.clientVersion, - description: - 'Client version sent on the Jellyfin authentication handshake; primarily internal.', - }, { path: 'jellyfin.defaultLibraryId', kind: 'string', @@ -593,12 +574,6 @@ export function buildIntegrationConfigOptionRegistry( description: 'When enabled, automatically trigger remote announce/visibility check on websocket connect.', }, - { - path: 'jellyfin.remoteControlDeviceName', - kind: 'string', - defaultValue: defaultConfig.jellyfin.remoteControlDeviceName, - description: 'Device name reported for Jellyfin remote control sessions.', - }, { path: 'jellyfin.pullPictures', kind: 'boolean', diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 8793c9ef..b4b03751 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -371,9 +371,6 @@ export function applyIntegrationConfig(context: ResolveContext): void { const stringKeys = [ 'serverUrl', 'username', - 'deviceId', - 'clientName', - 'clientVersion', 'defaultLibraryId', 'iconCacheDir', 'transcodeVideoCodec', diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index bb84cc5d..e9daf06b 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -59,7 +59,6 @@ test('settings registry hides removed modal-only fields', () => { 'shortcuts.multiCopyTimeoutMs', 'anilist.characterDictionary.profileScope', 'jellyfin.directPlayContainers', - 'jellyfin.remoteControlDeviceName', ]) { assert.equal( fields.some((candidate) => candidate.configPath === path), @@ -246,10 +245,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => { 'controller.preferredGamepadLabel', 'controller.profiles', 'youtubeSubgen.whisperBin', - 'jellyfin.clientVersion', 'jellyfin.defaultLibraryId', - 'jellyfin.deviceId', - 'jellyfin.clientName', 'subtitleSidebar.toggleKey', 'jellyfin.recentServers', ]) { diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index c8309ae2..60faed9c 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'anilist.characterDictionary.profileScope', 'jellyfin.accessToken', 'jellyfin.userId', - 'jellyfin.clientName', - 'jellyfin.clientVersion', 'jellyfin.defaultLibraryId', - 'jellyfin.deviceId', 'jellyfin.directPlayContainers', - 'jellyfin.remoteControlDeviceName', 'controller.buttonIndices', 'shortcuts.multiCopyTimeoutMs', 'subtitleSidebar.toggleKey', diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 651f292e..8d6df14c 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']); }); +test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => { + const handled: string[] = []; + let controlArgvHandler: ((argv: string[]) => void) | null = null; + let readyHandler: (() => Promise<void>) | null = null; + + const { deps } = createDeps({ + shouldStartApp: () => true, + parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }), + handleCliCommand: (args, source) => { + handled.push(`${source}:${args.start ? 'start' : 'other'}`); + }, + startControlServer: (handler) => { + controlArgvHandler = handler; + }, + whenReady: (handler) => { + readyHandler = handler; + }, + onReady: async () => { + handled.push('ready'); + throw new Error('ready failed'); + }, + }); + + startAppLifecycle(makeArgs({ background: true }), deps); + + assert.ok(controlArgvHandler); + (controlArgvHandler as (argv: string[]) => void)(['--start']); + assert.deepEqual(handled, []); + + assert.ok(readyHandler); + await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/); + + assert.deepEqual(handled, ['ready', 'second-instance:start']); + + (controlArgvHandler as (argv: string[]) => void)(['--start']); + assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']); +}); + test('startAppLifecycle quits macOS config-only launch when all windows close', () => { let windowAllClosedHandler: (() => void) | null = null; const { deps, calls } = createDeps({ diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index cabf8f5c..c82adfde 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic } deps.whenReady(async () => { - await deps.onReady(); - appReadyRuntimeComplete = true; - flushPendingSecondInstanceCommands(); + try { + await deps.onReady(); + } finally { + appReadyRuntimeComplete = true; + flushPendingSecondInstanceCommands(); + } }); deps.onWindowAllClosed(() => { diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts index 68745fea..4932555f 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () => } }); +test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => { + const payload = buildDiscordPresenceActivity(baseConfig, { + ...baseSnapshot, + mediaTitle: null, + mediaPath: + 'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1', + }); + + assert.equal(payload.details, 'Unknown media'); + assert.equal(payload.state, 'Playing 01:35 / 24:10'); + const serialized = JSON.stringify(payload); + assert.equal(serialized.includes('api_key'), false); + assert.equal(serialized.includes('secret-token'), false); + assert.equal(serialized.includes('/Videos/item-1/stream'), false); +}); + test('service deduplicates identical updates and sends changed timeline', async () => { const sent: DiscordActivityPayload[] = []; const timers = new Map<number, () => void>(); diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts index 9b211a4f..991eb2c4 100644 --- a/src/core/services/discord-presence.ts +++ b/src/core/services/discord-presence.ts @@ -106,6 +106,15 @@ function basename(filePath: string | null): string { return parts[parts.length - 1] ?? ''; } +function fallbackTitleFromMediaPath(mediaPath: string | null): string { + const trimmed = mediaPath?.trim(); + if (!trimmed) return ''; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) { + return ''; + } + return basename(trimmed).split(/[?#]/)[0] ?? ''; +} + function buildStatus(snapshot: DiscordPresenceSnapshot): string { if (!snapshot.connected || !snapshot.mediaPath) return 'Idle'; if (snapshot.paused) return 'Paused'; @@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity( ): DiscordActivityPayload { const style = resolvePresenceStyle(config.presenceStyle); const status = buildStatus(snapshot); - const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); + const title = sanitizeText( + snapshot.mediaTitle, + fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media', + ); const details = snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 3134f129..01439d44 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi } }); +test('Jellyfin playback metadata links stream videos to existing series title', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1'); + await waitForPendingAnimeMetadata(tracker); + tracker.destroy(); + tracker = null; + + tracker = new Ctor({ dbPath }); + tracker.recordJellyfinPlaybackMetadata({ + mediaPath: + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1', + displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring', + itemTitle: 'The Princess Begins Adventuring', + seriesTitle: 'The Beginning After the End', + seasonNumber: 2, + episodeNumber: 2, + itemId: 'item-2', + }); + tracker.handleMediaChange( + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1', + 'The Beginning After the End S02E02 The Princess Begins Adventuring', + ); + tracker.handleMediaChange(null, null); + tracker.recordJellyfinPlaybackMetadata({ + mediaPath: + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000', + displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring', + itemTitle: 'The Princess Begins Adventuring', + seriesTitle: 'The Beginning After the End', + seasonNumber: 2, + episodeNumber: 2, + itemId: 'item-2', + }); + tracker.handleMediaChange( + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000', + 'The Beginning After the End S02E02 The Princess Begins Adventuring', + ); + + const privateApi = tracker as unknown as { db: DatabaseSync }; + const rows = privateApi.db + .prepare( + ` + SELECT + v.source_url, + v.canonical_title AS video_title, + v.parsed_title, + v.parsed_season, + v.parsed_episode, + v.parser_source, + a.canonical_title AS anime_title + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + ORDER BY v.video_id + `, + ) + .all() as Array<{ + source_url: string | null; + video_title: string; + parsed_title: string | null; + parsed_season: number | null; + parsed_episode: number | null; + parser_source: string | null; + anime_title: string; + }>; + + assert.equal(rows.length, 2); + assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1); + const jellyfinRow = rows.find( + (row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2', + ); + assert.ok(jellyfinRow); + assert.equal( + jellyfinRow.video_title, + 'The Beginning After the End S02E02 The Princess Begins Adventuring', + ); + assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End'); + assert.equal(jellyfinRow.parsed_season, 2); + assert.equal(jellyfinRow.parsed_episode, 2); + assert.equal(jellyfinRow.parser_source, 'jellyfin'); + assert.equal(jellyfinRow.anime_title, 'The Beginning After the End'); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('applies configurable queue, flush, and retention policy', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 7aa393c7..55bb17bf 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -301,6 +301,33 @@ export type { VocabularyStatsRow, } from './immersion-tracker/types'; +export interface JellyfinPlaybackMetadataInput { + mediaPath: string; + displayTitle: string; + itemTitle: string; + seriesTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + itemId: string; +} + +function normalizeMetadataInt(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) ? value : null; +} + +function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string { + const normalizedItemId = normalizeText(itemId); + if (!normalizedItemId) { + return mediaPath; + } + try { + const parsed = new URL(mediaPath); + return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`; + } catch { + return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`; + } +} + export class ImmersionTrackerService { private readonly logger = createLogger('main:immersion-tracker'); private readonly db: DatabaseSync; @@ -337,6 +364,7 @@ export class ImmersionTrackerService { private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>(); private readonly recordedSubtitleKeys = new Set<string>(); private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>(); + private readonly mediaPathAliases = new Map<string, string>(); private readonly resolveLegacyVocabularyPos: | ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>) | undefined; @@ -1115,8 +1143,85 @@ export class ImmersionTrackerService { rebuildLifetimeSummaryTables(this.db); } + recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void { + const rawPath = normalizeMediaPath(metadata.mediaPath); + if (!rawPath) { + return; + } + const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId); + this.mediaPathAliases.set(rawPath, normalizedPath); + + const displayTitle = + normalizeText(metadata.displayTitle) || + normalizeText(metadata.itemTitle) || + deriveCanonicalTitle(normalizedPath); + const itemTitle = normalizeText(metadata.itemTitle) || displayTitle; + const seriesTitle = normalizeText(metadata.seriesTitle); + const libraryTitle = seriesTitle || itemTitle; + if (!libraryTitle) { + return; + } + + const videoId = getOrCreateVideoRecord( + this.db, + buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE), + { + canonicalTitle: displayTitle, + sourcePath: null, + sourceUrl: normalizedPath, + sourceType: SOURCE_TYPE_REMOTE, + }, + ); + const previousLink = this.db + .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?') + .get(videoId) as { animeId: number | null } | null; + const metadataJson = JSON.stringify({ + source: 'jellyfin', + itemId: normalizeText(metadata.itemId) || null, + itemTitle, + seriesTitle: seriesTitle || null, + displayTitle, + seasonNumber: normalizeMetadataInt(metadata.seasonNumber), + episodeNumber: normalizeMetadataInt(metadata.episodeNumber), + }); + const animeId = getOrCreateAnimeRecord(this.db, { + parsedTitle: libraryTitle, + canonicalTitle: libraryTitle, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson, + }); + linkVideoToAnimeRecord(this.db, videoId, { + animeId, + parsedBasename: null, + parsedTitle: libraryTitle, + parsedSeason: normalizeMetadataInt(metadata.seasonNumber), + parsedEpisode: normalizeMetadataInt(metadata.episodeNumber), + parserSource: 'jellyfin', + parserConfidence: 1, + parseMetadataJson: metadataJson, + }); + + const hasLifetimeMedia = Boolean( + this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId), + ); + if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) { + rebuildLifetimeSummaryTables(this.db); + } + } + + private hasJellyfinMetadata(videoId: number): boolean { + const row = this.db + .prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?') + .get(videoId) as { parserSource: string | null } | null; + return row?.parserSource === 'jellyfin'; + } + handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { - const normalizedPath = normalizeMediaPath(mediaPath); + const rawPath = normalizeMediaPath(mediaPath); + const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath; const normalizedTitle = normalizeText(mediaTitle); this.logger.info( `handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`, @@ -1164,7 +1269,7 @@ export class ImmersionTrackerService { if (youtubeVideoId) { void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); - } else { + } else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) { this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); } this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index f3d193ed..61da4011 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -116,6 +116,12 @@ export { resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime, ticksToSeconds as jellyfinTicksToSecondsRuntime, } from './jellyfin'; +export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay'; +export { + estimateSubtitleTimingOffset, + type SubtitleTimingOffsetOptions, + type SubtitleTimingOffsetResult, +} from './subtitle-timing-offset'; export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote'; export { broadcastRuntimeOptionsChangedRuntime, diff --git a/src/core/services/jellyfin-remote.test.ts b/src/core/services/jellyfin-remote.test.ts index a8899933..22b2e6b6 100644 --- a/src/core/services/jellyfin-remote.test.ts +++ b/src/core/services/jellyfin-remote.test.ts @@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload); }); +test('timeline payload omits websocket-only event names', () => { + const payload = buildJellyfinTimelinePayload({ + itemId: 'movie-2', + positionTicks: 123456, + eventName: 'TimeUpdate', + }); + + assert.equal('EventName' in payload, false); +}); + +test('reportStopped posts final position and explicit non-failed state', async () => { + const fetchCalls: Array<{ input: string; init: RequestInit }> = []; + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-stop-payload', + deviceId: 'device-stop-payload', + webSocketFactory: () => new FakeWebSocket() as unknown as any, + fetchImpl: (async (input, init) => { + fetchCalls.push({ input: String(input), init: init ?? {} }); + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + + const ok = await service.reportStopped({ + itemId: 'movie-stop', + positionTicks: 7654321, + failed: false, + }); + + const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped')); + assert.equal(ok, true); + assert.ok(stoppedCall); + assert.ok(typeof stoppedCall.init.body === 'string'); + const posted = JSON.parse(String(stoppedCall.init.body)); + assert.equal(posted.PositionTicks, 7654321); + assert.equal(posted.Failed, false); +}); + test('advertiseNow validates server registration using Sessions endpoint', async () => { const sockets: FakeWebSocket[] = []; const calls: string[] = []; diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts index 8f720508..ef0c301b 100644 --- a/src/core/services/jellyfin-remote.ts +++ b/src/core/services/jellyfin-remote.ts @@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState { subtitleStreamIndex?: number | null; playlistItemId?: string | null; eventName?: string; + failed?: boolean; } export interface JellyfinTimelinePayload { @@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload { AudioStreamIndex?: number | null; SubtitleStreamIndex?: number | null; PlaylistItemId?: string | null; - EventName: string; + Failed?: boolean; } interface JellyfinRemoteSocket { @@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload( AudioStreamIndex: asNullableInteger(state.audioStreamIndex), SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex), PlaylistItemId: state.playlistItemId, - EventName: state.eventName || 'timeupdate', + Failed: state.failed, }; } @@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService { } public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> { - return this.postTimeline('/Sessions/Playing', { - ...buildJellyfinTimelinePayload(state), - EventName: state.eventName || 'start', - }); + return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state)); } public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> { @@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService { public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> { return this.postTimeline('/Sessions/Playing/Stopped', { ...buildJellyfinTimelinePayload(state), - EventName: state.eventName || 'stop', + Failed: state.failed === true, }); } diff --git a/src/core/services/jellyfin-subtitle-delay.test.ts b/src/core/services/jellyfin-subtitle-delay.test.ts new file mode 100644 index 00000000..6f844d49 --- /dev/null +++ b/src/core/services/jellyfin-subtitle-delay.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import test from 'node:test'; +import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay'; + +function statePath(name: string): string { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name); +} + +test('jellyfin subtitle delay store saves and loads delay by item and stream', () => { + const filePath = statePath('delays.json'); + + assert.equal( + saveJellyfinSubtitleDelay({ + filePath, + itemId: 'episode-1', + streamIndex: 3, + delaySeconds: 1.25, + }), + true, + ); + + assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25); + assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null); +}); + +test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => { + const filePath = statePath('delays.json'); + + saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 }); + saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 }); + saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 }); + + assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2); + assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5); +}); + +test('jellyfin subtitle delay store ignores invalid files and values', () => { + const filePath = statePath('delays.json'); + fs.writeFileSync(filePath, '{'); + + assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null); + assert.equal( + saveJellyfinSubtitleDelay({ + filePath, + itemId: 'episode-1', + streamIndex: 3, + delaySeconds: Number.NaN, + }), + false, + ); +}); diff --git a/src/core/services/jellyfin-subtitle-delay.ts b/src/core/services/jellyfin-subtitle-delay.ts new file mode 100644 index 00000000..18bf8b3b --- /dev/null +++ b/src/core/services/jellyfin-subtitle-delay.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +type JellyfinSubtitleDelayStore = { + version?: unknown; + delays?: unknown; +}; + +type JellyfinSubtitleDelayParams = { + filePath: string; + itemId: string; + streamIndex: number; +}; + +type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & { + delaySeconds: number; +}; + +function storeKey(itemId: string, streamIndex: number): string { + return JSON.stringify([itemId, streamIndex]); +} + +function readDelayMap(filePath: string): Record<string, number> { + try { + if (!fs.existsSync(filePath)) return {}; + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore; + if ( + !parsed || + typeof parsed !== 'object' || + !parsed.delays || + typeof parsed.delays !== 'object' + ) { + return {}; + } + const delays: Record<string, number> = {}; + for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) { + if (typeof value === 'number' && Number.isFinite(value)) { + delays[key] = value; + } + } + return delays; + } catch { + return {}; + } +} + +export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null { + const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)]; + return typeof delay === 'number' && Number.isFinite(delay) ? delay : null; +} + +export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean { + if (!Number.isFinite(params.delaySeconds)) return false; + try { + const delays = readDelayMap(params.filePath); + delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds; + const dir = path.dirname(params.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2)); + return true; + } catch { + return false; + } +} diff --git a/src/core/services/jellyfin.test.ts b/src/core/services/jellyfin.test.ts index c8384f77..19e71a4d 100644 --- a/src/core/services/jellyfin.test.ts +++ b/src/core/services/jellyfin.test.ts @@ -229,6 +229,7 @@ test('resolvePlaybackPlan chooses direct play when allowed', async () => { assert.equal(plan.mode, 'direct'); assert.match(plan.url, /Videos\/movie-1\/stream\?/); assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/); + assert.equal(new URL(plan.url).searchParams.get('StartTimeTicks'), null); assert.equal(plan.subtitleStreamIndex, null); assert.equal(ticksToSeconds(plan.startTimeTicks), 2); } finally { @@ -560,13 +561,17 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu assert.equal(plan.mode, 'direct'); assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope'); + assert.equal(plan.itemTitle, 'A New Hope'); + assert.equal(plan.seriesTitle, 'Galaxy Quest'); + assert.equal(plan.seasonNumber, 2); + assert.equal(plan.episodeNumber, 7); assert.equal(plan.audioStreamIndex, 6); assert.equal(plan.subtitleStreamIndex, 9); assert.equal(plan.startTimeTicks, 35_000_000); const url = new URL(plan.url); assert.equal(url.searchParams.get('AudioStreamIndex'), '6'); assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9'); - assert.equal(url.searchParams.get('StartTimeTicks'), '35000000'); + assert.equal(url.searchParams.get('StartTimeTicks'), null); } finally { globalThis.fetch = originalFetch; } diff --git a/src/core/services/jellyfin.ts b/src/core/services/jellyfin.ts index 46de6809..a09577e6 100644 --- a/src/core/services/jellyfin.ts +++ b/src/core/services/jellyfin.ts @@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan { mode: 'direct' | 'transcode'; url: string; title: string; + itemTitle: string; + seriesTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; startTimeTicks: number; audioStreamIndex: number | null; subtitleStreamIndex: number | null; @@ -229,9 +233,6 @@ function createDirectPlayUrl( if (plan.subtitleStreamIndex !== null) { query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex)); } - if (plan.startTimeTicks > 0) { - query.set('StartTimeTicks', String(plan.startTimeTicks)); - } return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`; } @@ -292,14 +293,24 @@ function getStreamDefaults(source: JellyfinMediaSource): { }; } +function getItemTitle(item: JellyfinItem): string { + return ensureString(item.Name).trim() || 'Jellyfin Item'; +} + +function getSeriesTitle(item: JellyfinItem): string | null { + return ensureString(item.SeriesName).trim() || null; +} + function getDisplayTitle(item: JellyfinItem): string { + const itemTitle = getItemTitle(item); if (item.Type === 'Episode') { const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0; const episode = asIntegerOrNull(item.IndexNumber) ?? 0; - const prefix = item.SeriesName ? `${item.SeriesName} ` : ''; - return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim(); + const seriesTitle = getSeriesTitle(item); + const prefix = seriesTitle ? `${seriesTitle} ` : ''; + return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim(); } - return ensureString(item.Name).trim() || 'Jellyfin Item'; + return itemTitle; } function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean { @@ -521,10 +532,16 @@ export async function resolvePlaybackPlan( const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null; const subtitleStreamIndex = selection.subtitleStreamIndex ?? null; const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0); + const itemTitle = getItemTitle(item); + const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null; const basePlan: JellyfinPlaybackPlan = { mode: 'transcode', url: '', title: getDisplayTitle(item), + itemTitle, + seriesTitle, + seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null, + episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null, startTimeTicks, audioStreamIndex, subtitleStreamIndex, diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 3751a17e..4b499916 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -78,7 +78,7 @@ export interface MpvProtocolHandleMessageDeps { setPendingPauseAtSubEnd: (value: boolean) => void; getPauseAtTime: () => number | null; setPauseAtTime: (value: number | null) => void; - autoLoadSecondarySubTrack: () => void; + autoLoadSecondarySubTrack: (path: string) => void; setCurrentVideoPath: (value: string) => void; emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; setPreviousSecondarySubVisibility: (visible: boolean) => void; @@ -303,7 +303,7 @@ export async function dispatchMpvProtocolMessage( const path = (msg.data as string) || ''; deps.setCurrentVideoPath(path); deps.emitMediaPathChange({ path }); - deps.autoLoadSecondarySubTrack(); + deps.autoLoadSecondarySubTrack(path); deps.syncCurrentAudioStreamIndex(); } else if (msg.name === 'sub-pos') { deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 184193b1..eb0acce6 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -6,7 +6,10 @@ import { MpvIpcClientProtocolDeps, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, } from './mpv'; -import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol'; +import { + MPV_REQUEST_ID_TRACK_LIST_AUDIO, + MPV_REQUEST_ID_TRACK_LIST_SECONDARY, +} from './mpv-protocol'; function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps { return { @@ -93,6 +96,53 @@ test('MpvIpcClient clears cached media title when media path changes', async () assert.equal(client.currentMediaTitle, null); }); +test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => { + const commands: unknown[] = []; + const originalSetTimeout = globalThis.setTimeout; + const client = new MpvIpcClient( + '/tmp/mpv.sock', + makeDeps({ + getResolvedConfig: () => + ({ + secondarySub: { + autoLoadSecondarySub: true, + secondarySubLanguages: ['en'], + }, + }) as any, + shouldAutoLoadSecondarySubTrack: () => false, + } as any), + ); + (client as any).send = (command: unknown) => { + commands.push(command); + return true; + }; + (globalThis as any).setTimeout = (callback: () => void) => { + callback(); + return 0; + }; + + try { + await invokeHandleMessage(client, { + event: 'property-change', + name: 'path', + data: 'http://pve-main:8096/Videos/item/stream', + }); + } finally { + globalThis.setTimeout = originalSetTimeout; + } + + assert.equal( + commands.some( + (command) => + Array.isArray((command as { command?: unknown[] }).command) && + (command as { command: unknown[] }).command[0] === 'get_property' && + (command as { command: unknown[] }).command[1] === 'track-list' && + (command as { request_id?: number }).request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + ), + false, + ); +}); + test('MpvIpcClient parses JSON line protocol in processBuffer', () => { const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const seen: Array<Record<string, unknown>> = []; diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index bbabf357..10d3f0a7 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -105,6 +105,7 @@ export interface MpvIpcClientProtocolDeps { isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType<typeof setTimeout> | null; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void; + shouldAutoLoadSecondarySubTrack?: (path: string) => boolean; shouldQuitOnMpvShutdown?: () => boolean; requestAppQuit?: () => void; } @@ -404,8 +405,8 @@ export class MpvIpcClient implements MpvClient { setPauseAtTime: (value: number | null) => { this.pauseAtTime = value; }, - autoLoadSecondarySubTrack: () => { - this.autoLoadSecondarySubTrack(); + autoLoadSecondarySubTrack: (path: string) => { + this.autoLoadSecondarySubTrack(path); }, setCurrentVideoPath: (value: string) => { this.currentVideoPath = value; @@ -429,7 +430,12 @@ export class MpvIpcClient implements MpvClient { }; } - private autoLoadSecondarySubTrack(): void { + private autoLoadSecondarySubTrack(path: string): void { + const normalizedPath = path.trim(); + if (!normalizedPath) return; + if (this.deps.shouldAutoLoadSecondarySubTrack?.(normalizedPath) === false) { + return; + } const config = this.deps.getResolvedConfig(); if (!config.secondarySub?.autoLoadSecondarySub) return; const languages = config.secondarySub.secondarySubLanguages; diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 6948a97a..b5a4d13a 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -197,6 +197,68 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () => 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', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { @@ -244,7 +306,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', () assert.ok(!calls.includes('focus')); }); -test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => { +test('untracked non-macOS overlay shows passively when no tracker exists', () => { const { window, calls } = createMainWindowRecorder(); let trackerWarning = false; @@ -279,11 +341,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke } as never); assert.equal(trackerWarning, false); - assert.ok(calls.includes('show')); - assert.ok(calls.includes('focus')); + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('osd')); }); +test('passive Linux visible overlay does not take keyboard focus', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker 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, + } as never); + + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('focus')); +}); + test('tracked non-macOS overlay reapplies bounds after first show', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { @@ -317,8 +417,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => { } as never); assert.deepEqual( - calls.filter((call) => call === 'update-bounds' || call === 'show'), - ['update-bounds', 'show', 'update-bounds'], + calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'), + ['update-bounds', 'show-inactive', 'update-bounds'], ); }); @@ -1260,6 +1360,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => { assert.ok(!calls.includes('show')); }); +test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + window.show(); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker 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: true, + isWindowsPlatform: false, + macOSForegroundProbeActive: true, + } as never); + + assert.ok(calls.includes('update-bounds')); + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('ensure-level')); + assert.ok(calls.includes('enforce-order')); + assert.ok(calls.includes('sync-shortcuts')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('hide')); +}); + test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => { const { window, calls, setFocused } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 69f2f3d9..ec1785ea 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -71,6 +71,7 @@ export function updateVisibleOverlayVisibility(args: { lastKnownWindowsForegroundProcessName?: string | null; windowsOverlayProcessName?: string | null; windowsFocusHandoffGraceActive?: boolean; + macOSForegroundProbeActive?: boolean; trackerNotReadyWarningShown: boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; @@ -128,6 +129,12 @@ export function updateVisibleOverlayVisibility(args: { const isTrackedMacOSTargetMinimized = canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.(); + const shouldPreserveMacOSOverlayDuringForegroundProbe = + args.isMacOSPlatform && + args.macOSForegroundProbeActive === true && + !!windowTracker && + !isTrackedMacOSTargetMinimized && + (windowTracker.isTracking() || windowTracker.getGeometry() !== null); const hasTransientMacOSTrackerLoss = args.isMacOSPlatform && canReportMacOSTargetMinimized && @@ -137,7 +144,10 @@ export function updateVisibleOverlayVisibility(args: { trackedMacOSTargetFocused !== false && mainWindow.isVisible(); const isTrackedMacOSTargetFocused = - hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker + hasTransientMacOSTrackerLoss || + shouldPreserveMacOSOverlayDuringForegroundProbe || + !args.isMacOSPlatform || + !args.windowTracker ? true : (trackedMacOSTargetFocused ?? true); const shouldReleaseMacOSOverlayLevel = @@ -171,9 +181,12 @@ export function updateVisibleOverlayVisibility(args: { !isTrackedWindowsTargetMinimized && (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible; + const isNonNativePassiveOverlay = + !args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive; const shouldIgnoreMouseEvents = shouldUseMacOSMousePassthrough || forceMousePassthrough || + isNonNativePassiveOverlay || (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldKeepTrackedWindowsOverlayTopmost = @@ -217,7 +230,10 @@ export function updateVisibleOverlayVisibility(args: { // skip — ready-to-show hasn't fired yet; the onWindowContentReady // callback will trigger another visibility update when the renderer // has painted its first frame. - } else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { + } else if ( + ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) || + isNonNativePassiveOverlay + ) { if (args.isWindowsPlatform) { setOverlayWindowOpacity(mainWindow, 0); } @@ -261,7 +277,12 @@ export function updateVisibleOverlayVisibility(args: { mainWindow.focus(); } - if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { + if ( + !args.isWindowsPlatform && + !args.isMacOSPlatform && + !forceMousePassthrough && + overlayInteractionActive + ) { mainWindow.focus(); } diff --git a/src/core/services/stats-window-layer.ts b/src/core/services/stats-window-layer.ts new file mode 100644 index 00000000..5fb9ca57 --- /dev/null +++ b/src/core/services/stats-window-layer.ts @@ -0,0 +1,29 @@ +export type StatsWindowLayerSuspensionState = { + count: number; +}; + +export function createStatsWindowLayerSuspensionState(): StatsWindowLayerSuspensionState { + return { count: 0 }; +} + +export function isStatsWindowLayerSuspended(state: StatsWindowLayerSuspensionState): boolean { + return state.count > 0; +} + +export function suspendStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean { + state.count += 1; + return state.count === 1; +} + +export function restoreStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean { + if (state.count <= 0) { + return false; + } + + state.count -= 1; + return state.count === 0; +} + +export function resetStatsWindowLayerSuspension(state: StatsWindowLayerSuspensionState): void { + state.count = 0; +} diff --git a/src/core/services/stats-window-lifecycle.test.ts b/src/core/services/stats-window-lifecycle.test.ts new file mode 100644 index 00000000..78dee9bc --- /dev/null +++ b/src/core/services/stats-window-lifecycle.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createStatsWindowLayerSuspensionState, + isStatsWindowLayerSuspended, + resetStatsWindowLayerSuspension, + restoreStatsWindowLayer, + suspendStatsWindowLayer, +} from './stats-window-layer'; + +test('stats window layer suspension reset clears missed native dialog closes', () => { + const state = createStatsWindowLayerSuspensionState(); + + assert.equal(suspendStatsWindowLayer(state), true); + assert.equal(suspendStatsWindowLayer(state), false); + assert.equal(isStatsWindowLayerSuspended(state), true); + + resetStatsWindowLayerSuspension(state); + + assert.equal(isStatsWindowLayerSuspended(state), false); + assert.equal(restoreStatsWindowLayer(state), false); + assert.equal(suspendStatsWindowLayer(state), true); +}); diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index f08760a3..e3195f9e 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -1,4 +1,8 @@ -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + MessageBoxSyncOptions, +} from 'electron'; import type { WindowGeometry } from '../../types'; const DEFAULT_STATS_WINDOW_WIDTH = 900; @@ -9,6 +13,15 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; type VisibleStatsWindowLevelController = StatsWindowLevelController & Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>; +type VisibleStatsWindowDialogLayerController = Pick< + BrowserWindow, + 'isDestroyed' | 'isVisible' | 'setAlwaysOnTop' +>; +type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>; +type StatsNativeConfirmDialogPresenter<WindowT> = { + showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number; + showWithoutParent: (options: MessageBoxSyncOptions) => number; +}; type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>; type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> & @@ -124,6 +137,41 @@ export function promoteVisibleStatsWindowAboveOverlay( return true; } +export function demoteVisibleStatsWindowBelowDialogs( + window: VisibleStatsWindowDialogLayerController, +): boolean { + if (window.isDestroyed() || !window.isVisible()) { + return false; + } + + window.setAlwaysOnTop(false); + return true; +} + +export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions { + return { + type: 'warning', + message, + buttons: ['Delete', 'Cancel'], + defaultId: 1, + cancelId: 1, + noLink: true, + }; +} + +export function showStatsNativeConfirmDialog<WindowT extends StatsNativeConfirmDialogWindow>( + window: WindowT | null, + message: string, + presenter: StatsNativeConfirmDialogPresenter<WindowT>, +): boolean { + const options = buildStatsNativeConfirmDialogOptions(message); + const response = + window && !window.isDestroyed() + ? presenter.showWithParent(window, options) + : presenter.showWithoutParent(options); + return response === 0; +} + export function presentStatsWindow( window: StatsWindowPresentationController, platform: NodeJS.Platform = process.platform, diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index befb88a2..be4d3de3 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -3,10 +3,13 @@ import test from 'node:test'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + buildStatsNativeConfirmDialogOptions, + demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, promoteVisibleStatsWindowAboveOverlay, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, + showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, } from './stats-window-runtime'; @@ -274,6 +277,90 @@ test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => { assert.deepEqual(calls, []); }); +test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => { + const calls: string[] = []; + const demoted = demoteVisibleStatsWindowBelowDialogs({ + isDestroyed: () => false, + isVisible: () => true, + setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { + calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`); + }, + } as never); + + assert.equal(demoted, true); + assert.deepEqual(calls, ['always-on-top:false:none:0']); +}); + +test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => { + const calls: string[] = []; + const demoted = demoteVisibleStatsWindowBelowDialogs({ + isDestroyed: () => false, + isVisible: () => false, + setAlwaysOnTop: () => calls.push('always-on-top'), + } as never); + + assert.equal(demoted, false); + assert.deepEqual(calls, []); +}); + +test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => { + assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), { + type: 'warning', + message: 'Delete this session?', + buttons: ['Delete', 'Cancel'], + defaultId: 1, + cancelId: 1, + noLink: true, + }); +}); + +test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => { + const calls: string[] = []; + const parent = { isDestroyed: () => false }; + + const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', { + showWithParent: (window, options) => { + assert.equal(window, parent); + calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`); + return 0; + }, + showWithoutParent: () => { + calls.push('unparented'); + return 1; + }, + }); + + assert.equal(confirmed, true); + assert.deepEqual(calls, ['Delete this session?:1:1']); +}); + +test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => { + const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', { + showWithParent: () => 1, + showWithoutParent: () => 0, + }); + + assert.equal(confirmed, false); +}); + +test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => { + const calls: string[] = []; + + const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', { + showWithParent: () => { + calls.push('parented'); + return 0; + }, + showWithoutParent: (options) => { + calls.push(options.message); + return 0; + }, + }); + + assert.equal(confirmed, true); + assert.deepEqual(calls, ['Delete?']); +}); + test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => { const calls: string[] = []; diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index 58c17e20..e99f20d3 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -1,21 +1,32 @@ -import { BrowserWindow, ipcMain } from 'electron'; +import { BrowserWindow, dialog, ipcMain } from 'electron'; import * as path from 'path'; import type { WindowGeometry } from '../../types.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, promoteStatsWindowLevel, promoteVisibleStatsWindowAboveOverlay, resolveStatsWindowOuterBoundsForContent, + showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, STATS_WINDOW_TITLE, } from './stats-window-runtime.js'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; +import { + createStatsWindowLayerSuspensionState, + isStatsWindowLayerSuspended, + resetStatsWindowLayerSuspension, + restoreStatsWindowLayer, + suspendStatsWindowLayer, +} from './stats-window-layer.js'; let statsWindow: BrowserWindow | null = null; let toggleRegistered = false; +let nativeDialogLayerRegistered = false; +const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState(); export interface StatsWindowOptions { /** Absolute path to stats/dist/ directory */ @@ -63,6 +74,10 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo } export function promoteStatsOverlayAbovePlayback(): boolean { + if (isStatsWindowLayerSuspended(nativeDialogLayerSuspension)) { + return false; + } + if (!statsWindow) { return false; } @@ -74,6 +89,69 @@ export function promoteStatsOverlayAbovePlayback(): boolean { }); } +export function demoteStatsOverlayBelowDialogs(): boolean { + if (!statsWindow) { + return false; + } + + return demoteVisibleStatsWindowBelowDialogs(statsWindow); +} + +export function suspendStatsWindowLayerForNativeDialog(): void { + if (!suspendStatsWindowLayer(nativeDialogLayerSuspension)) { + return; + } + + demoteStatsOverlayBelowDialogs(); +} + +export function restoreStatsWindowLayerAfterNativeDialog(): void { + if (restoreStatsWindowLayer(nativeDialogLayerSuspension)) { + promoteStatsOverlayAbovePlayback(); + } +} + +function resetStatsWindowLayerAfterLifecycleEnd(): void { + resetStatsWindowLayerSuspension(nativeDialogLayerSuspension); +} + +export async function withStatsWindowLayerSuspendedForNativeDialog<T>( + showDialog: () => Promise<T>, +): Promise<T> { + suspendStatsWindowLayerForNativeDialog(); + try { + return await showDialog(); + } finally { + restoreStatsWindowLayerAfterNativeDialog(); + } +} + +function confirmStatsNativeDialog(message: unknown): boolean { + const dialogMessage = + typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?'; + + return showStatsNativeConfirmDialog(statsWindow, dialogMessage, { + showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options), + showWithoutParent: (options) => dialog.showMessageBoxSync(options), + }); +} + +function registerStatsNativeDialogLayerHandlers(): void { + if (nativeDialogLayerRegistered) return; + nativeDialogLayerRegistered = true; + + ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => { + event.returnValue = confirmStatsNativeDialog(message); + }); + ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => { + suspendStatsWindowLayerForNativeDialog(); + event.returnValue = true; + }); + ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => { + restoreStatsWindowLayerAfterNativeDialog(); + }); +} + /** * Toggle the stats overlay window: create on first call, then show/hide. * The React app stays mounted across toggles — state is preserved. @@ -99,6 +177,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { statsWindow.on('closed', () => { options.onVisibilityChanged?.(false); statsWindow = null; + resetStatsWindowLayerAfterLifecycleEnd(); }); statsWindow.webContents.on('before-input-event', (event, input) => { @@ -132,6 +211,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { * Call this once during app initialization. */ export function registerStatsOverlayToggle(options: StatsWindowOptions): void { + registerStatsNativeDialogLayerHandlers(); if (toggleRegistered) return; toggleRegistered = true; ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { @@ -148,4 +228,5 @@ export function destroyStatsWindow(): void { statsWindow.destroy(); statsWindow = null; } + resetStatsWindowLayerAfterLifecycleEnd(); } diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index 8b38fe16..e86bf2b2 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async () test('triggerSubsyncFromConfig opens manual picker', async () => { const osd: string[] = []; let payloadTrackCount = 0; + let ffsubsyncAvailable: boolean | null = null; let inProgressState: boolean | null = null; await triggerSubsyncFromConfig( makeDeps({ openManualPicker: (payload) => { payloadTrackCount = payload.sourceTracks.length; + ffsubsyncAvailable = payload.ffsubsyncAvailable; }, showMpvOsd: (text) => { osd.push(text); @@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => { ); assert.equal(payloadTrackCount, 1); + assert.equal(ffsubsyncAvailable, true); assert.ok(osd.includes('Subsync: choose engine and source')); assert.equal(inProgressState, false); }); +test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => { + let ffsubsyncAvailable: boolean | null = null; + + await triggerSubsyncFromConfig( + makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv'; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { id: 1, type: 'sub', selected: true, lang: 'jpn' }, + { + id: 2, + type: 'sub', + selected: false, + external: true, + lang: 'eng', + 'external-filename': 'https://jellyfin.example/subs/eng.srt', + }, + ]; + } + return null; + }, + }), + openManualPicker: (payload) => { + ffsubsyncAvailable = payload.ffsubsyncAvailable; + }, + }), + ); + + assert.equal(ffsubsyncAvailable, false); +}); + test('triggerSubsyncFromConfig does not run automatic sync', async () => { const osd: string[] = []; let payloadTrackCount = 0; diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index 480e50ce..053076f2 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps const client = getMpvClientForSubsync(deps); const context = await gatherSubsyncContext(client); const payload: SubsyncManualPayload = { + ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath), sourceTracks: context.sourceTracks .filter((track) => typeof track.id === 'number') .map((track) => ({ diff --git a/src/core/services/subtitle-delay-shift.test.ts b/src/core/services/subtitle-delay-shift.test.ts index 242742c0..60b44586 100644 --- a/src/core/services/subtitle-delay-shift.test.ts +++ b/src/core/services/subtitle-delay-shift.test.ts @@ -89,6 +89,40 @@ Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`, assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true); }); +test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => { + const shiftedDelays: number[] = []; + const handler = createShiftSubtitleDelayToAdjacentCueHandler({ + getMpvClient: () => + createMpvClient({ + 'track-list': [ + { + type: 'sub', + id: 2, + external: true, + 'external-filename': '/tmp/subs.srt', + }, + ], + sid: 2, + 'sub-start': 3.0, + 'sub-delay': 0.5, + }), + loadSubtitleSourceText: async () => `1 +00:00:03,000 --> 00:00:04,000 +line-1 + +2 +00:00:05,000 --> 00:00:06,000 +line-2`, + sendMpvCommand: () => {}, + showMpvOsd: () => {}, + onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay), + }); + + await handler('next'); + + assert.deepEqual(shiftedDelays, [2.5]); +}); + test('shift subtitle delay throws when no next cue exists', async () => { const handler = createShiftSubtitleDelayToAdjacentCueHandler({ getMpvClient: () => diff --git a/src/core/services/subtitle-delay-shift.ts b/src/core/services/subtitle-delay-shift.ts index 797620a1..9a49f8c7 100644 --- a/src/core/services/subtitle-delay-shift.ts +++ b/src/core/services/subtitle-delay-shift.ts @@ -21,6 +21,7 @@ type SubtitleDelayShiftDeps = { loadSubtitleSourceText: (source: string) => Promise<string>; sendMpvCommand: (command: Array<string | number>) => void; showMpvOsd: (text: string) => void; + onSubtitleDelayShifted?: (delaySeconds: number) => void; }; function asTrackId(value: unknown): number | null { @@ -175,10 +176,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay throw new Error('MPV not connected.'); } - const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([ + const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([ client.requestProperty('track-list'), client.requestProperty('sid'), client.requestProperty('sub-start'), + client.requestProperty('sub-delay'), ]); const currentStart = @@ -198,6 +200,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction); const delta = targetStart - currentStart; deps.sendMpvCommand(['add', 'sub-delay', delta]); + const currentDelay = + typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0; + try { + deps.onSubtitleDelayShifted?.(currentDelay + delta); + } catch {} deps.showMpvOsd('Subtitle delay: ${sub-delay}'); }; } diff --git a/src/core/services/subtitle-timing-offset.test.ts b/src/core/services/subtitle-timing-offset.test.ts new file mode 100644 index 00000000..15cad6e6 --- /dev/null +++ b/src/core/services/subtitle-timing-offset.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { estimateSubtitleTimingOffset } from './subtitle-timing-offset'; + +function cue(startTime: number) { + return { startTime, endTime: startTime + 1, text: `cue ${startTime}` }; +} + +test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => { + const primary = [ + 34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814, + 87.988, 90.991, 94.094, 97.097, + ].map(cue); + const reference = [ + 3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, + ].map(cue); + + const result = estimateSubtitleTimingOffset(primary, reference); + + assert.ok(result); + assert.ok(result.offsetSeconds > -32); + assert.ok(result.offsetSeconds < -31); + assert.ok(result.matchCount >= 8); + assert.ok(result.meanErrorSeconds <= 0.75); +}); + +test('estimate subtitle timing offset favors the early episode timeline', () => { + const primary = [ + 34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814, + 87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778, + 246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407, + 277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942, + 312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422, + 357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957, + 396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26, + 435.602, 440.44, 442.942, 445.445, 449.783, + ].map(cue); + const reference = [ + 3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81, + 176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65, + 232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01, + 280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82, + 333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43, + 388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31, + 450.57, 457.62, 463.41, 465.85, 473.79, 480.59, + ].map(cue); + + const result = estimateSubtitleTimingOffset(primary, reference); + + assert.ok(result); + assert.ok(result.offsetSeconds > -32); + assert.ok(result.offsetSeconds < -31); +}); + +test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => { + const starts = [1, 5, 9, 14, 20, 25, 31, 38]; + + const result = estimateSubtitleTimingOffset( + starts.map(cue), + starts.map((start) => cue(start + 0.04)), + ); + + assert.equal(result, null); +}); + +test('estimate subtitle timing offset rejects weak timeline matches', () => { + const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue); + const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue); + + const result = estimateSubtitleTimingOffset(primary, reference); + + assert.equal(result, null); +}); diff --git a/src/core/services/subtitle-timing-offset.ts b/src/core/services/subtitle-timing-offset.ts new file mode 100644 index 00000000..e52ec79d --- /dev/null +++ b/src/core/services/subtitle-timing-offset.ts @@ -0,0 +1,153 @@ +import type { SubtitleCue } from './subtitle-cue-parser'; + +export type SubtitleTimingOffsetResult = { + offsetSeconds: number; + matchCount: number; + meanErrorSeconds: number; + maxErrorSeconds: number; +}; + +export type SubtitleTimingOffsetOptions = { + maxCueCount?: number; + maxOffsetSeconds?: number; + matchThresholdSeconds?: number; + maxMeanErrorSeconds?: number; + minMatchCount?: number; + minMatchRatio?: number; + minUsefulOffsetSeconds?: number; +}; + +type OffsetScore = SubtitleTimingOffsetResult; + +const DEFAULT_MAX_CUE_COUNT = 60; +const DEFAULT_MAX_OFFSET_SECONDS = 180; +const DEFAULT_MATCH_THRESHOLD_SECONDS = 1; +const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75; +const DEFAULT_MIN_MATCH_COUNT = 8; +const DEFAULT_MIN_MATCH_RATIO = 0.25; +const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25; + +function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] { + const starts = cues + .map((cue) => cue.startTime) + .filter((start) => Number.isFinite(start) && start >= 0) + .sort((a, b) => a - b); + const deduped: number[] = []; + for (const start of starts) { + const previous = deduped[deduped.length - 1]; + if (previous === undefined || Math.abs(start - previous) > 0.05) { + deduped.push(start); + } + if (deduped.length >= maxCueCount) { + break; + } + } + return deduped; +} + +function roundToMillis(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function scoreOffset( + primaryStarts: number[], + referenceStarts: number[], + offsetSeconds: number, + matchThresholdSeconds: number, +): OffsetScore { + let primaryIndex = 0; + let referenceIndex = 0; + let matchCount = 0; + let totalErrorSeconds = 0; + let maxErrorSeconds = 0; + + while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) { + const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds; + const reference = referenceStarts[referenceIndex]!; + const errorSeconds = Math.abs(shiftedPrimary - reference); + if (errorSeconds <= matchThresholdSeconds) { + matchCount += 1; + totalErrorSeconds += errorSeconds; + maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds); + primaryIndex += 1; + referenceIndex += 1; + continue; + } + + if (shiftedPrimary < reference) { + primaryIndex += 1; + } else { + referenceIndex += 1; + } + } + + return { + offsetSeconds, + matchCount, + meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY, + maxErrorSeconds, + }; +} + +function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean { + if (current === null) return true; + if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount; + if (next.meanErrorSeconds !== current.meanErrorSeconds) { + return next.meanErrorSeconds < current.meanErrorSeconds; + } + return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds); +} + +export function estimateSubtitleTimingOffset( + primaryCues: SubtitleCue[], + referenceCues: SubtitleCue[], + options: SubtitleTimingOffsetOptions = {}, +): SubtitleTimingOffsetResult | null { + const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT; + const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS; + const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS; + const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS; + const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT; + const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO; + const minUsefulOffsetSeconds = + options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS; + + const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount); + const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount); + const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length); + if (comparableCueCount < minMatchCount) { + return null; + } + + const candidates = new Set<number>(); + for (const primaryStart of primaryStarts) { + for (const referenceStart of referenceStarts) { + const offsetSeconds = roundToMillis(referenceStart - primaryStart); + if (Math.abs(offsetSeconds) <= maxOffsetSeconds) { + candidates.add(offsetSeconds); + } + } + } + + let best: OffsetScore | null = null; + for (const offsetSeconds of candidates) { + if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) { + continue; + } + const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds); + if (score.matchCount < minMatchCount) { + continue; + } + if (score.matchCount / comparableCueCount < minMatchRatio) { + continue; + } + if (score.meanErrorSeconds > maxMeanErrorSeconds) { + continue; + } + if (isBetterScore(score, best)) { + best = score; + } + } + + return best; +} diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 15f46871..71c31811 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -129,7 +129,7 @@ test('tokenizeSubtitle splits same-line grammar endings before applying annotati assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); assert.equal(result.tokens?.[0]?.frequencyRank, 40); assert.equal(result.tokens?.[1]?.surface, 'です'); - assert.equal(result.tokens?.[1]?.isKnown, false); + assert.equal(result.tokens?.[1]?.isKnown, true); assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[1]?.jlptLevel, undefined); @@ -3365,7 +3365,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); }); -test('tokenizeSubtitle clears known-word highlight for exact non-independent kanji noun tokens', async () => { +test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanji noun tokens', async () => { const result = await tokenizeSubtitle( 'その点', makeDepsFromYomitanTokens( @@ -3413,7 +3413,7 @@ test('tokenizeSubtitle clears known-word highlight for exact non-independent kan assert.equal(result.tokens?.length, 2); assert.equal(result.tokens?.[0]?.isKnown, false); assert.equal(result.tokens?.[1]?.surface, '点'); - assert.equal(result.tokens?.[1]?.isKnown, false); + assert.equal(result.tokens?.[1]?.isKnown, true); assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[1]?.jlptLevel, undefined); @@ -4028,7 +4028,7 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper { surface: 'これで', headword: 'これ', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, @@ -4143,7 +4143,7 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings' { surface: 'のかな', headword: 'の', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, @@ -4672,7 +4672,7 @@ test('tokenizeSubtitle clears annotations for ja-nai explanatory endings and aru { surface: 'ある', headword: 'ある', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, @@ -4717,7 +4717,7 @@ test('tokenizeSubtitle clears annotations for standalone polite copula endings w { surface: 'ですよ', headword: 'です', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, @@ -5044,7 +5044,7 @@ test('tokenizeSubtitle clears annotations for auxiliary inflection fragments whi { surface: 'れた', headword: 'れる', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, @@ -5181,7 +5181,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans', { surface: 'てく', headword: 'てく', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, @@ -5192,7 +5192,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans', { surface: 'れた', headword: 'れる', - isKnown: false, + isKnown: true, isNPlusOneTarget: false, frequencyRank: undefined, jlptLevel: undefined, diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index 544147ad..6dde5087 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -425,6 +425,21 @@ test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside exp assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); }); +test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-independent kanji nouns from non-known annotations', () => { + const token = makeToken({ + surface: '以外', + headword: '以外', + reading: 'イガイ', + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '非自立', + pos3: '副詞可能', + }); + + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); + assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true); +}); + test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => { const tokens = [ makeToken({ @@ -971,8 +986,8 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens ); assert.equal(result[0]?.isKnown, false); - assert.equal(result[1]?.isKnown, false); - assert.equal(result[2]?.isKnown, false); + assert.equal(result[1]?.isKnown, true); + assert.equal(result[2]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); }); @@ -1186,7 +1201,7 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+ assert.equal(result[0]?.isNPlusOneTarget, false); }); -test('annotateTokens clears known-word status for non-independent kanji noun tokens', () => { +test('annotateTokens keeps known-word status for non-independent kanji noun tokens', () => { const tokens = [ makeToken({ surface: '点', @@ -1211,12 +1226,41 @@ test('annotateTokens clears known-word status for non-independent kanji noun tok { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); +test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => { + const tokens = [ + makeToken({ + surface: '以外', + reading: 'イガイ', + headword: '以外', + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '非自立', + pos3: '副詞可能', + startPos: 2, + endPos: 4, + frequencyRank: 437, + }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === '以外', + }), + { minSentenceWordsForNPlusOne: 1 }, + ); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.frequencyRank, undefined); + assert.equal(result[0]?.isNPlusOneTarget, false); +}); + test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => { const tokens = [ makeToken({ @@ -1401,7 +1445,7 @@ test('annotateTokens excludes composite tokens when all component pos tags are e assert.equal(result[0]?.isNPlusOneTarget, false); }); -test('annotateTokens applies one shared exclusion gate across known N+1 frequency and JLPT', () => { +test('annotateTokens lets known words bypass the shared exclusion gate for known status only', () => { const tokens = [ makeToken({ surface: 'これで', @@ -1425,13 +1469,13 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for kana-only non-independent noun helper merges', () => { +test('annotateTokens keeps known status while clearing other annotations for kana-only non-independent noun helper merges', () => { const tokens = [ makeToken({ surface: 'ことに', @@ -1455,13 +1499,13 @@ test('annotateTokens clears known status and other annotations for kana-only non { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for standalone auxiliary inflection fragments', () => { +test('annotateTokens keeps known status while clearing other annotations for standalone auxiliary inflection fragments', () => { const tokens = [ makeToken({ surface: 'れる', @@ -1497,14 +1541,14 @@ test('annotateTokens clears known status and other annotations for standalone au ); for (const token of result) { - assert.equal(token.isKnown, false, token.surface); + assert.equal(token.isKnown, true, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface); } }); -test('annotateTokens clears known status and other annotations for auxiliary-only te-kureru helper spans', () => { +test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => { const tokens = [ makeToken({ surface: 'てく', @@ -1540,7 +1584,7 @@ test('annotateTokens clears known status and other annotations for auxiliary-onl ); for (const token of result) { - assert.equal(token.isKnown, false, token.surface); + assert.equal(token.isKnown, true, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface); @@ -1576,7 +1620,7 @@ test('annotateTokens keeps lexical くれる forms eligible for annotation', () assert.equal(result[0]?.jlptLevel, 'N4'); }); -test('annotateTokens clears known status and other annotations for standalone して helper fragments', () => { +test('annotateTokens keeps known status while clearing other annotations for standalone して helper fragments', () => { const tokens = [ makeToken({ surface: 'してる', @@ -1600,13 +1644,13 @@ test('annotateTokens clears known status and other annotations for standalone { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for standalone particle fragments without POS tags', () => { +test('annotateTokens keeps known status while clearing other annotations for standalone particle fragments without POS tags', () => { const tokens = [ makeToken({ surface: 'と', @@ -1630,13 +1674,13 @@ test('annotateTokens clears known status and other annotations for standalone pa { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status from standalone particles even when the known-word cache contains them', () => { +test('annotateTokens keeps known status on standalone particles when the known-word cache contains them', () => { const tokens = [ makeToken({ surface: 'に', @@ -1671,7 +1715,7 @@ test('annotateTokens clears known status from standalone particles even when the { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); @@ -1728,7 +1772,7 @@ test('annotateTokens does not mark standalone connective particles as N+1', () = assert.equal(result[1]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for rhetorical もんか grammar particle phrases', () => { +test('annotateTokens keeps known status while clearing other annotations for rhetorical もんか grammar particle phrases', () => { const tokens = [ makeToken({ surface: 'もんか', @@ -1752,13 +1796,13 @@ test('annotateTokens clears known status and other annotations for rhetorical { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for bare くれ auxiliary fragments', () => { +test('annotateTokens keeps known status while clearing other annotations for bare くれ auxiliary fragments', () => { const tokens = [ makeToken({ surface: 'くれ', @@ -1782,13 +1826,13 @@ test('annotateTokens clears known status and other annotations for bare くれ a { minSentenceWordsForNPlusOne: 1 }, ); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for aru existence verbs', () => { +test('annotateTokens keeps known status while clearing other annotations for aru existence verbs', () => { const tokens = [ makeToken({ surface: '有る', @@ -1818,14 +1862,14 @@ test('annotateTokens clears known status and other annotations for aru existence assert.equal(result[0]?.surface, '有る'); assert.equal(result[0]?.headword, '有る'); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNameMatch, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears known status and other annotations for standalone quote particle and auxiliary grammar terms', () => { +test('annotateTokens keeps known status while clearing other annotations for standalone quote particle and auxiliary grammar terms', () => { const tokens = [ makeToken({ surface: 'って', @@ -1861,14 +1905,14 @@ test('annotateTokens clears known status and other annotations for standalone qu ); for (const token of result) { - assert.equal(token.isKnown, false, token.surface); + assert.equal(token.isKnown, true, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface); } }); -test('annotateTokens clears known status and other annotations from standalone あ interjections without POS tags', () => { +test('annotateTokens keeps known status while clearing other annotations from standalone あ interjections without POS tags', () => { const tokens = [ makeToken({ surface: 'あ', @@ -1898,13 +1942,13 @@ test('annotateTokens clears known status and other annotations from standalone assert.equal(result[0]?.surface, 'あ'); assert.equal(result[0]?.headword, 'あ'); assert.equal(result[0]?.reading, 'あ'); - assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isKnown, true); assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens clears all annotations from expressive subtitle interjections without POS tags', () => { +test('annotateTokens keeps known status while clearing other annotations from expressive subtitle interjections without POS tags', () => { const tokens = [ makeToken({ surface: 'ハァ', @@ -1960,7 +2004,7 @@ test('annotateTokens clears all annotations from expressive subtitle interjectio ); for (const token of result.slice(0, 2)) { - assert.equal(token.isKnown, false, token.surface); + assert.equal(token.isKnown, true, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface); diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 73313d05..6098b881 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -680,6 +680,11 @@ export function annotateTokens( // Single pass: compute known word status, frequency filtering, and JLPT level together const annotated = tokens.map((token, index) => { + const isKnownForMatching = shouldComputeKnownStatus + ? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode) + : false; + nPlusOneKnownStatuses[index] = isKnownForMatching; + if ( sharedShouldExcludeTokenFromSubtitleAnnotations(token, { pos1Exclusions, @@ -690,18 +695,13 @@ export function annotateTokens( pos1Exclusions, pos2Exclusions, }); - nPlusOneKnownStatuses[index] = false; return { ...strippedToken, - isKnown: false, + isKnown: knownWordsEnabled ? isKnownForMatching : false, }; } const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true; - const isKnownForMatching = shouldComputeKnownStatus - ? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode) - : false; - nPlusOneKnownStatuses[index] = isKnownForMatching; const frequencyRank = frequencyEnabled && !prioritizedNameMatch diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 9bd4b9f2..760de82f 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -15,6 +15,9 @@ import { shouldHandleLaunchMpvAtEntry, shouldHandleStatsDaemonCommandAtEntry, hasTransportedStartupArgs, + shouldForwardStartupArgvViaAppControl, + applyEarlyLinuxCommandLineSwitches, + resolveLinuxPasswordStoreValue, } from './main-entry-runtime'; test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { @@ -106,6 +109,74 @@ test('hasTransportedStartupArgs detects env-carried app args', () => { assert.equal(hasTransportedStartupArgs({}), false); }); +test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => { + assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret'); + assert.equal( + resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'), + 'gnome-libsecret', + ); + assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null); +}); + +test('resolveLinuxPasswordStoreValue keeps scanning after a bare password-store flag', () => { + assert.equal( + resolveLinuxPasswordStoreValue( + ['SubMiner.AppImage', '--password-store', '--start', '--password-store=kwallet6'], + 'linux', + ), + 'kwallet6', + ); +}); + +test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => { + const switches: Array<[string, string | undefined]> = []; + applyEarlyLinuxCommandLineSwitches( + { + appendSwitch: (name, value) => { + switches.push([name, value]); + }, + }, + ['SubMiner.AppImage', '--password-store=kwallet6'], + 'linux', + ); + + assert.deepEqual(switches, [ + ['enable-features', 'GlobalShortcutsPortal'], + ['password-store', 'kwallet6'], + ]); +}); + +test('transported AppImage visibility commands should forward through app control', () => { + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], { + SUBMINER_APP_ARGC: '1', + SUBMINER_APP_ARG_0: '--hide-visible-overlay', + }), + true, + ); +}); + +test('app control forwarding is only for transported runtime commands', () => { + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}), + false, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], { + SUBMINER_APP_ARGC: '1', + SUBMINER_APP_ARG_0: '--app-ping', + }), + false, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], { + SUBMINER_APP_ARGC: '1', + SUBMINER_APP_ARG_0: '--launch-mpv', + }), + false, + ); +}); + test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 51695af2..de4e2447 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -1,11 +1,12 @@ import fs from 'node:fs'; import os from 'node:os'; -import { CliArgs, parseArgs, shouldStartApp } from './cli/args'; +import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args'; import { resolveConfigDir } from './config/path-resolution'; const BACKGROUND_ARG = '--background'; const START_ARG = '--start'; const PASSWORD_STORE_ARG = '--password-store'; +const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret'; const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; @@ -34,6 +35,10 @@ type EarlyAppLike = { setPath: (name: 'userData', value: string) => void; }; +type CommandLineLike = { + appendSwitch: (name: string, value?: string) => void; +}; + type EarlyAppPathOptions = { platform?: NodeJS.Platform; appDataDir?: string; @@ -73,6 +78,60 @@ function removePassiveStartupArgs(argv: string[]): string[] { return filtered; } +function getPasswordStoreArg(argv: string[]): string | null { + let resolved: string | null = null; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg?.startsWith(PASSWORD_STORE_ARG)) { + continue; + } + + if (arg === PASSWORD_STORE_ARG) { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + resolved = value.trim(); + i += 1; + } + continue; + } + + const [prefix, value] = arg.split('=', 2); + if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) { + resolved = value.trim(); + } + } + return resolved; +} + +function normalizePasswordStoreArg(value: string): string { + const normalized = value.trim(); + if (normalized.toLowerCase() === 'gnome') { + return DEFAULT_LINUX_PASSWORD_STORE; + } + return normalized; +} + +export function resolveLinuxPasswordStoreValue( + argv: string[], + platform: NodeJS.Platform = process.platform, +): string | null { + if (platform !== 'linux') return null; + return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE); +} + +export function applyEarlyLinuxCommandLineSwitches( + commandLine: CommandLineLike, + argv: string[], + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== 'linux') return; + commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); + commandLine.appendSwitch( + 'password-store', + resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE, + ); +} + function consumesLaunchMpvValue(token: string): boolean { return ( token.startsWith('--') && @@ -90,6 +149,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean { return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string'; } +export function shouldForwardStartupArgvViaAppControl( + argv: string[], + env: NodeJS.ProcessEnv, +): boolean { + if (env.ELECTRON_RUN_AS_NODE === '1') return false; + if (!hasTransportedStartupArgs(env)) return false; + + const args = parseCliArgs(argv); + if (args.help || args.appPing || args.launchMpv) return false; + if (resolveStatsDaemonCommandAction(argv) !== null) return false; + + return hasExplicitCommand(args); +} + function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; if (rawCount === undefined) { diff --git a/src/main-entry.ts b/src/main-entry.ts index 6af7f1e1..76d1f28b 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -9,17 +9,20 @@ import { normalizeLaunchMpvExtraArgs, normalizeLaunchMpvTargets, normalizeStartupArgv, + applyEarlyLinuxCommandLineSwitches, sanitizeStartupEnv, sanitizeBackgroundEnv, sanitizeHelpEnv, sanitizeLaunchMpvEnv, hasTransportedStartupArgs, + shouldForwardStartupArgvViaAppControl, shouldDetachBackgroundLaunch, shouldHandleHelpOnlyAtEntry, shouldHandleLaunchMpvAtEntry, shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; import { requestSingleInstanceLockEarly } from './main/early-single-instance'; +import { sendAppControlCommand } from './shared/app-control-client'; import { detectInstalledFirstRunPluginCandidates, detectInstalledMpvPlugin, @@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): { } process.argv = normalizeStartupArgv(process.argv, process.env); +applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv); applySanitizedEnv(sanitizeStartupEnv(process.env)); const userDataPath = configureEarlyAppPaths(app); const reportFatalError = createFatalErrorReporter({ @@ -184,6 +188,44 @@ registerFatalErrorHandlers({ exit: (code) => app.exit(code), }); +function startMainProcess(): void { + const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); + if (!gotSingleInstanceLock) { + app.exit(0); + return; + } + try { + require('./main.js'); + } catch (error) { + reportFatalError(error, { + title: 'SubMiner startup failed', + context: 'SubMiner failed while loading the main process.', + }); + app.exit(1); + } +} + +async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> { + if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) { + return false; + } + + const result = await sendAppControlCommand(process.argv, { + configDir: userDataPath, + timeoutMs: 500, + }); + if (result.ok) { + app.exit(0); + return true; + } + if (!result.unavailable) { + console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`); + app.exit(1); + return true; + } + return false; +} + if (shouldDetachBackgroundLaunch(process.argv, process.env)) { const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); const child = spawn(process.execPath, childArgs, { @@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { app.exit(exitCode); }); } else { - const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); - if (!gotSingleInstanceLock) { - app.exit(0); - } - try { - require('./main.js'); - } catch (error) { - reportFatalError(error, { - title: 'SubMiner startup failed', - context: 'SubMiner failed while loading the main process.', + void forwardStartupArgvViaAppControlIfAvailable() + .then((forwarded) => { + if (!forwarded) { + startMainProcess(); + } + }) + .catch((error) => { + console.error('SubMiner app-control handoff failed:', error); + startMainProcess(); }); - app.exit(1); - } } diff --git a/src/main.ts b/src/main.ts index f9781bbb..39851182 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,10 @@ import { applyControllerConfigUpdate } from './main/controller-config-update.js' import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { startAppControlServer } from './main/runtime/app-control-server'; +import { + markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState, + shouldAutoLoadSecondarySubTrackForJellyfinPlayback, +} from './main/runtime/jellyfin-remote-playback'; import { getAppControlSocketPath } from './shared/app-control'; import { type CancelLinuxMpvFullscreenOverlayRefreshBurst, @@ -44,6 +48,7 @@ import { import { mergeAiConfig } from './ai/config'; function getPasswordStoreArg(argv: string[]): string | null { + let resolved: string | null = null; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith('--password-store')) { @@ -53,17 +58,18 @@ function getPasswordStoreArg(argv: string[]): string | null { if (arg === '--password-store') { const value = argv[i + 1]; if (value && !value.startsWith('--')) { - return value; + resolved = value.trim(); + i += 1; } - return null; + continue; } const [prefix, value] = arg.split('=', 2); if (prefix === '--password-store' && value && value.trim().length > 0) { - return value.trim(); + resolved = value.trim(); } } - return null; + return resolved; } function normalizePasswordStoreArg(value: string): string { @@ -319,6 +325,7 @@ import { listJellyfinItemsRuntime, listJellyfinLibrariesRuntime, listJellyfinSubtitleTracksRuntime, + loadJellyfinSubtitleDelay, loadSubtitlePosition as loadSubtitlePositionCore, loadYomitanExtension as loadYomitanExtensionCore, markLastCardAsAudioCard as markLastCardAsAudioCardCore, @@ -329,6 +336,7 @@ import { replayCurrentSubtitleRuntime, resolveJellyfinPlaybackPlanRuntime, runStartupBootstrapRuntime, + saveJellyfinSubtitleDelay, saveSubtitlePosition as saveSubtitlePositionCore, addYomitanNoteViaSearch, clearYomitanParserCachesForWindow, @@ -356,6 +364,7 @@ import { promoteStatsOverlayAbovePlayback, registerStatsOverlayToggle, toggleStatsOverlay as toggleStatsOverlayWindow, + withStatsWindowLayerSuspendedForNativeDialog, } from './core/services/stats-window.js'; import { createFirstRunSetupService, @@ -403,6 +412,11 @@ import { launchWindowsMpv, } from './main/runtime/windows-mpv-launch'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; +import { + DEFAULT_JELLYFIN_CLIENT_NAME, + DEFAULT_JELLYFIN_CLIENT_VERSION, + createHostDerivedJellyfinDeviceId, +} from './main/runtime/jellyfin-device-identity'; import { clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime, isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, @@ -508,6 +522,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; +import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createElectronAppUpdater, @@ -619,6 +634,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({ appDataDir: process.env.APPDATA, }); const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; +const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({ + tmpDir: () => os.tmpdir(), + makeTempDir: (prefix) => fs.promises.mkdtemp(prefix), + writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes), + removeDir: (dir, options) => { + fs.rmSync(dir, options); + }, + fetch: (url) => fetch(url), +}); const ANILIST_SETUP_RESPONSE_TYPE = 'token'; const ANILIST_DEFAULT_CLIENT_ID = '36084'; const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; @@ -639,6 +663,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; +const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000; const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; @@ -647,16 +672,18 @@ const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000; const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best'; const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b'; const MPV_JELLYFIN_DEFAULT_ARGS = [ - '--sub-auto=fuzzy', + '--sub-auto=no', '--sub-file-paths=.;subs;subtitles', - '--sid=auto', - '--secondary-sid=auto', + '--sid=no', + '--secondary-sid=no', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', ] as const; let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; +let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null; let jellyfinRemoteLastProgressAtMs = 0; let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null; let backgroundWarmupsStarted = false; @@ -1803,12 +1830,13 @@ async function refreshSubtitleSidebarFromSource( if (!normalizedSourcePath) { return; } - appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); + const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); await subtitlePrefetchInitController.initSubtitlePrefetch( normalizedSourcePath, lastObservedTimePos, normalizedSourcePath, ); + appState.activeParsedSubtitleMediaPath = nextMediaPath; } const refreshSubtitlePrefetchFromActiveTrackHandler = createRefreshSubtitlePrefetchFromActiveTrackHandler({ @@ -2115,6 +2143,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); +const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json'); const mediaRuntime = createMediaRuntimeService( createBuildMediaRuntimeMainDepsHandler({ @@ -2280,6 +2309,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), + getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; @@ -2323,6 +2353,7 @@ const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; +const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; @@ -2331,6 +2362,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; let visibleOverlayInteractionActive = false; +let macOSVisibleOverlayForegroundProbeActive = false; +let macOSVisibleOverlayForegroundProbeToken = 0; +let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null; const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({ setStatsOverlayVisibleState: (visible) => { @@ -2357,6 +2391,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { windowsVisibleOverlayZOrderRetryTimeouts = []; } +function finishMacOSVisibleOverlayForegroundProbe(token: number): void { + if (token !== macOSVisibleOverlayForegroundProbeToken) { + return; + } + if (macOSVisibleOverlayForegroundProbeTimeout !== null) { + clearTimeout(macOSVisibleOverlayForegroundProbeTimeout); + macOSVisibleOverlayForegroundProbeTimeout = null; + } + if (!macOSVisibleOverlayForegroundProbeActive) { + return; + } + macOSVisibleOverlayForegroundProbeActive = false; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); +} + +function startMacOSVisibleOverlayForegroundProbe(): void { + if (process.platform !== 'darwin') { + return; + } + const tracker = appState.windowTracker; + if (!tracker) { + return; + } + + macOSVisibleOverlayForegroundProbeActive = true; + const token = ++macOSVisibleOverlayForegroundProbeToken; + if (macOSVisibleOverlayForegroundProbeTimeout !== null) { + clearTimeout(macOSVisibleOverlayForegroundProbeTimeout); + } + macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => { + finishMacOSVisibleOverlayForegroundProbe(token); + }, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS); + + void tracker + .refreshNow() + .catch((error) => { + logger.warn('Failed to refresh macOS frontmost app after overlay blur', error); + }) + .finally(() => { + finishMacOSVisibleOverlayForegroundProbe(token); + }); +} + function getWindowsNativeWindowHandle(window: BrowserWindow): string { const handle = window.getNativeWindowHandle(); return handle.length >= 8 @@ -2555,6 +2632,7 @@ function scheduleVisibleOverlayBlurRefresh(): void { if (process.platform === 'win32') { lastWindowsVisibleOverlayBlurredAtMs = Date.now(); } + startMacOSVisibleOverlayForegroundProbe(); clearVisibleOverlayBlurRefreshTimeouts(); for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { const refreshTimeout = setTimeout(() => { @@ -2801,6 +2879,7 @@ const { reportJellyfinRemoteStopped, startJellyfinRemoteSession, stopJellyfinRemoteSession, + cleanupJellyfinSubtitleCache, runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, @@ -2812,7 +2891,9 @@ const { }, getJellyfinClientInfoMainDeps: { getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), - getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, + getHostName: () => os.hostname(), + defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME, + defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION, }, waitForMpvConnectedMainDeps: { getMpvClient: () => appState.mpvClient, @@ -2824,6 +2905,15 @@ const { getLaunchMode: () => getResolvedConfig().mpv.launchMode, platform: process.platform, execPath: process.execPath, + getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(), + getInstalledPluginDetection: () => + detectInstalledMpvPlugin({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appDataDir: app.getPath('appData'), + mpvExecutablePath: getResolvedConfig().mpv.executablePath, + }), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, @@ -2859,6 +2949,25 @@ const { sendMpvCommandRuntime(appState.mpvClient, command); }, wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), + cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track), + cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs), + getSavedSubtitleDelay: (itemId, streamIndex) => + loadJellyfinSubtitleDelay({ + filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, + itemId, + streamIndex, + }), + setActiveSubtitleDelayKey: (key) => { + activeJellyfinSubtitleDelayKey = key; + }, + loadSubtitleSourceText, + saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => + saveJellyfinSubtitleDelay({ + filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, + itemId, + streamIndex, + delaySeconds, + }), logDebug: (message, error) => { logger.debug(message, error); }, @@ -2877,6 +2986,7 @@ const { }, ), applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), + showVisibleOverlay: () => setVisibleOverlayVisible(true), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), armQuitOnDisconnect: () => { jellyfinPlayQuitOnDisconnectArmed = false; @@ -2889,7 +2999,11 @@ const { }, convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), setActivePlayback: (state) => { - activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; + activeJellyfinRemotePlayback = { + ...(state as ActiveJellyfinRemotePlaybackState), + stopReportsAfterMs: + state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS, + }; }, setLastProgressAtMs: (value) => { jellyfinRemoteLastProgressAtMs = value; @@ -2900,6 +3014,13 @@ const { showMpvOsd: (text) => { showMpvOsd(text); }, + updateCurrentMediaTitle: (title) => { + mediaRuntime.updateCurrentMediaTitle(title); + }, + recordJellyfinPlaybackMetadata: (metadata) => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata); + }, }, remoteComposerOptions: { getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), @@ -2910,6 +3031,7 @@ const { getActivePlayback: () => activeJellyfinRemotePlayback, clearActivePlayback: () => { activeJellyfinRemotePlayback = null; + activeJellyfinSubtitleDelayKey = null; }, getSession: () => appState.jellyfinRemoteSession, getNow: () => Date.now(), @@ -2960,11 +3082,13 @@ const { appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; }, createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), - defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, - defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, - defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, + getHostName: () => os.hostname(), + defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()), + defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME, + defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION, logInfo: (message) => logger.info(message), logWarn: (message, details) => logger.warn(message, details), + onSessionStateChanged: () => refreshTrayMenuIfPresent(), }, stopJellyfinRemoteSessionMainDeps: { getCurrentSession: () => appState.jellyfinRemoteSession, @@ -2974,6 +3098,7 @@ const { clearActivePlayback: () => { activeJellyfinRemotePlayback = null; }, + onSessionStateChanged: () => refreshTrayMenuIfPresent(), }, runJellyfinCommandMainDeps: { defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, @@ -2994,7 +3119,6 @@ const { clearStoredSession: () => clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()), patchJellyfinConfig: (session) => { - const clientInfo = getJellyfinClientInfo(); const recentServers = mergeJellyfinRecentServers( session.serverUrl, getResolvedConfig().jellyfin.recentServers || [], @@ -3004,9 +3128,6 @@ const { enabled: true, serverUrl: session.serverUrl, username: session.username, - deviceId: clientInfo.deviceId, - clientName: clientInfo.clientName, - clientVersion: clientInfo.clientVersion, recentServers, }, }); @@ -3670,6 +3791,7 @@ const { }, stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), + cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => { void appState.discordPresenceService?.stop(); appState.discordPresenceService = null; @@ -4331,11 +4453,12 @@ const { appState.activeParsedSubtitleSource = null; appState.activeParsedSubtitleMediaPath = null; } + activeJellyfinSubtitleDelayKey = null; broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); + autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); } - autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); currentMediaTokenizationGate.updateCurrentMediaPath(path); managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); startupOsdSequencer.reset(); @@ -4372,6 +4495,9 @@ const { immersionMediaRuntime.syncFromCurrentMediaState(); }, signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), + markJellyfinRemotePlaybackLoaded: (path) => { + markJellyfinRemotePlaybackLoadedState(activeJellyfinRemotePlayback, path); + }, scheduleCharacterDictionarySync: () => { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { return; @@ -4435,6 +4561,8 @@ const { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { appState.reconnectTimer = timer; }, + shouldAutoLoadSecondarySubTrack: (path: string) => + shouldAutoLoadSecondarySubTrackForJellyfinPlayback(activeJellyfinRemotePlayback, path), shouldQuitOnMpvShutdown: () => shouldQuitOnMpvShutdownForTrayState({ managedPlayback: appState.initialArgs?.managedPlayback === true, @@ -5088,6 +5216,8 @@ function getUpdateService() { }); app.focus({ steal: true }); }, + withStatsWindowLayerSuspended: (showDialog) => + withStatsWindowLayerSuspendedForNativeDialog(showDialog), showMessageBox: (options) => dialog.showMessageBox(options), }); updateService = createUpdateService({ @@ -5418,6 +5548,19 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen getMpvClient: () => appState.mpvClient, loadSubtitleSourceText, sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), + onSubtitleDelayShifted: (delaySeconds) => { + const key = activeJellyfinSubtitleDelayKey; + if (!key) return; + const saved = saveJellyfinSubtitleDelay({ + filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, + itemId: key.itemId, + streamIndex: key.streamIndex, + delaySeconds, + }); + if (!saved) { + logger.warn('Failed to save Jellyfin subtitle delay.'); + } + }, showMpvOsd: (text) => showMpvOsd(text), }); @@ -6062,6 +6205,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = }, buildTrayMenuTemplateDeps: { buildTrayMenuTemplateRuntime, + platform: process.platform, initializeOverlayRuntime: () => initializeOverlayRuntime(), isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, openSessionHelpModal: () => openSessionHelpOverlay(), @@ -6077,8 +6221,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = isJellyfinConfigured: () => isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), - toggleJellyfinDiscovery: () => - toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()), + toggleJellyfinDiscovery: (checked: boolean) => + toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), { + desiredActive: checked, + }), openAnilistSetupWindow: () => openAnilistSetupWindow(), checkForUpdates: () => { void getUpdateService().checkForUpdates({ source: 'manual' }); @@ -6307,36 +6453,50 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void { } } +function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void { + sendMpvCommandRuntime(appState.mpvClient, [ + 'script-message', + visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden', + ]); +} + function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); if (!visible) { + autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { void ensureOverlayMpvSubtitlesHidden(); } setVisibleOverlayVisibleHandler(visible); + notifyMpvPluginVisibleOverlayVisibility(visible); syncOverlayMpvSubtitleSuppression(); } function toggleVisibleOverlay(): void { ensureOverlayWindowsReadyForVisibilityActions(); - if (overlayManager.getVisibleOverlayVisible()) { + const nextVisible = !overlayManager.getVisibleOverlayVisible(); + if (!nextVisible) { + autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } else { void ensureOverlayMpvSubtitlesHidden(); } toggleVisibleOverlayHandler(); + notifyMpvPluginVisibleOverlayVisibility(nextVisible); syncOverlayMpvSubtitleSuppression(); } function setOverlayVisible(visible: boolean): void { if (!visible) { + autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { void ensureOverlayMpvSubtitlesHidden(); } setOverlayVisibleHandler(visible); + notifyMpvPluginVisibleOverlayVisibility(visible); syncOverlayMpvSubtitleSuppression(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 7946bf40..08bf882f 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -46,6 +46,65 @@ test('media path changes clear rendered subtitle state without clearing same-you ); }); +test('same media path updates do not reset autoplay ready fallback state', () => { + const source = readMainSource(); + const actionBlock = source.match( + /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/, + )?.groups?.body; + + assert.ok(actionBlock); + assert.match( + actionBlock, + /annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/, + ); +}); + +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, + /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('subtitlePrefetchInitController.initSubtitlePrefetch') < + actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'), + ); +}); + +test('manual visible overlay changes notify mpv plugin visibility state', () => { + const source = readMainSource(); + const setBlock = source.match( + /function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/, + )?.groups?.body; + const toggleBlock = source.match( + /function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(setBlock); + assert.ok(toggleBlock); + assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/); + assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/); + assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/); +}); + test('main process uses one shared mpv plugin runtime config helper', () => { const source = readMainSource(); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index a9a80035..5d2f56a0 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -9,6 +9,7 @@ type MockWindow = { ignoreMouseEvents: boolean; forwardedIgnoreMouseEvents: boolean; webContentsFocused: boolean; + alwaysOnTopCalls: string[]; showCount: number; hideCount: number; sent: unknown[][]; @@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & { ignoreMouseEvents: false, forwardedIgnoreMouseEvents: false, webContentsFocused: false, + alwaysOnTopCalls: [], showCount: 0, hideCount: 0, sent: [], @@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & { state.ignoreMouseEvents = ignore; state.forwardedIgnoreMouseEvents = options?.forward === true; }, - setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, + setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { + state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); + }, moveTop: () => {}, getShowCount: () => state.showCount, getHideCount: () => state.hideCount, @@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & { }, }); + Object.defineProperty(window, 'alwaysOnTopCalls', { + get: () => state.alwaysOnTopCalls, + set: (value: string[]) => { + state.alwaysOnTopCalls = value; + }, + }); + Object.defineProperty(window, 'url', { get: () => state.url, set: (value: string) => { @@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac runtime.notifyOverlayModalOpened('runtime-options'); assert.equal(window.getShowCount(), 1); assert.equal(window.isFocused(), true); + assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']); assert.deepEqual(window.sent, [['runtime-options:open']]); }); @@ -313,7 +325,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals }); runtime.sendToActiveOverlayWindow( 'subsync:open-manual', - { sourceTracks: [] }, + { ffsubsyncAvailable: true, sourceTracks: [] }, { restoreOnModalClose: 'subsync', }, @@ -459,7 +471,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact }); runtime.sendToActiveOverlayWindow( 'subsync:open-manual', - { sourceTracks: [] }, + { ffsubsyncAvailable: true, sourceTracks: [] }, { restoreOnModalClose: 'subsync', }, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 0bb60978..47f59912 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService( const elevateModalWindow = (window: BrowserWindow): void => { if (window.isDestroyed()) return; - window.setAlwaysOnTop(true, 'screen-saver', 1); + window.setAlwaysOnTop(true, 'screen-saver', 3); window.moveTop(); }; diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 0adffe51..7a43e30c 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -17,6 +17,7 @@ export interface OverlayVisibilityRuntimeDeps { getLastKnownWindowsForegroundProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null; getWindowsFocusHandoffGraceActive?: () => boolean; + getMacOSForegroundProbeActive?: () => boolean; getTrackerNotReadyWarningShown: () => boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; @@ -59,6 +60,7 @@ export function createOverlayVisibilityRuntimeService( lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null, windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false, + macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false, trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => { deps.setTrackerNotReadyWarningShown(shown); diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index eae95178..1ebbac34 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -41,19 +41,65 @@ test('on will quit cleanup handler runs all cleanup steps', () => { clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); cleanup(); - assert.equal(calls.length, 31); + assert.equal(calls.length, 32); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); + assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); }); +test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => { + const calls: string[] = []; + const cleanup = createOnWillQuitCleanupHandler({ + destroyTray: () => {}, + stopConfigHotReload: () => {}, + restorePreviousSecondarySubVisibility: () => {}, + restoreMpvSubVisibility: () => {}, + unregisterAllGlobalShortcuts: () => {}, + stopSubtitleWebsocket: () => {}, + stopTexthookerService: () => {}, + clearWindowsVisibleOverlayForegroundPollLoop: () => {}, + clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, + destroyMainOverlayWindow: () => {}, + destroyModalOverlayWindow: () => {}, + destroyYomitanParserWindow: () => {}, + clearYomitanParserState: () => {}, + stopWindowTracker: () => {}, + flushMpvLog: () => {}, + destroyMpvSocket: () => {}, + clearReconnectTimer: () => {}, + destroySubtitleTimingTracker: () => {}, + destroyImmersionTracker: () => {}, + destroyAnkiIntegration: () => {}, + destroyAnilistSetupWindow: () => {}, + clearAnilistSetupWindow: () => {}, + destroyJellyfinSetupWindow: () => {}, + clearJellyfinSetupWindow: () => {}, + destroyFirstRunSetupWindow: () => {}, + clearFirstRunSetupWindow: () => {}, + destroyYomitanSettingsWindow: () => {}, + clearYomitanSettingsWindow: () => {}, + stopJellyfinRemoteSession: () => { + calls.push('stop-jellyfin-remote'); + throw new Error('stop failed'); + }, + cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), + stopDiscordPresenceService: () => calls.push('stop-discord-presence'), + }); + + assert.throws(() => cleanup(), /stop failed/); + assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']); +}); + test('should restore windows on activate requires initialized runtime and no windows', () => { let initialized = false; let windowCount = 1; diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 1978c571..9ccc8259 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: { clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; cleanupYoutubeSubtitleTempDirs: () => void; + cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { return (): void => { @@ -60,7 +61,11 @@ export function createOnWillQuitCleanupHandler(deps: { deps.clearFirstRunSetupWindow(); deps.destroyYomitanSettingsWindow(); deps.clearYomitanSettingsWindow(); - deps.stopJellyfinRemoteSession(); + try { + deps.stopJellyfinRemoteSession(); + } finally { + deps.cleanupJellyfinSubtitleCache(); + } deps.cleanupYoutubeSubtitleTempDirs(); deps.stopDiscordPresenceService(); }; diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 0d57d6d9..a7067f6d 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); @@ -91,6 +92,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('cleanup-youtube-subtitles')); + assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); @@ -145,6 +147,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }); @@ -194,6 +197,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () = clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index f6d42da2..9693ac05 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { stopJellyfinRemoteSession: () => void; cleanupYoutubeSubtitleTempDirs: () => void; + cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { return () => ({ @@ -141,6 +142,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), + cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), }); } diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index 202f1441..34aa1a1f 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media' (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, ), ); - assert.equal(scheduled.length > 0, true); }); test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { @@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t ); }); +test('autoplay ready gate cancels release retries after playback is paused again', async () => { + const commands: Array<Array<string | boolean>> = []; + const scheduled: Array<() => void> = []; + let playbackPaused = true; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => playbackPaused, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => playbackPaused, + send: ({ command }: { command: Array<string | boolean> }) => { + commands.push(command); + if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) { + playbackPaused = false; + } + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + playbackPaused = true; + const retry = scheduled.shift(); + retry?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + commands.filter( + (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ).length, + 1, + ); +}); + +test('autoplay ready gate suppresses release after manual current-media dismissal', async () => { + const commands: Array<Array<string | boolean>> = []; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => true, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => true, + send: ({ command }: { command: Array<string | boolean> }) => { + commands.push(command); + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback) => { + queueMicrotask(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.markCurrentMediaAutoplayReady(); + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands, []); +}); + test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => { const commands: Array<Array<string | boolean>> = []; let targetReady = false; diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index b5044d8d..d704df73 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { const getSignalMediaPath = (): string => deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__'; + const markCurrentMediaAutoplayReady = (): void => { + pendingAutoplayReadySignal = null; + autoPlayReadySignalMediaPath = getSignalMediaPath(); + autoPlayReadySignalGeneration += 1; + }; + const maybeSignalPluginAutoplayReady = ( payload: SubtitleData, options?: { forceWhilePaused?: boolean }, @@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { forceWhilePaused: options?.forceWhilePaused === true, retryDelayMs: releaseRetryDelayMs, }); + let releaseUnpauseSent = false; const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => { try { @@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { return; } + if (releaseUnpauseSent && deps.getPlaybackPaused() === true) { + deps.logDebug( + `[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`, + ); + return; + } + const shouldUnpause = await isPlaybackPaused(mpvClient); if (!shouldUnpause) { return; } mpvClient.send({ command: ['set_property', 'pause', false] }); + releaseUnpauseSent = true; if (attempt < maxReleaseAttempts) { deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); } @@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { flushPendingAutoplayReadySignal, getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath, invalidatePendingAutoplayReadyFallbacks, + markCurrentMediaAutoplayReady, maybeSignalPluginAutoplayReady, }; } diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index a759b88f..2de85547 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers( getActivePlayback: options.getActivePlayback, clearActivePlayback: options.clearActivePlayback, getSession: options.getSession, + getMpvClient: options.getMpvClient, + getNow: options.getNow, + ticksPerSecond: options.ticksPerSecond, logDebug: options.logDebug, }); const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( @@ -101,6 +104,7 @@ export function composeJellyfinRemoteHandlers( getConfiguredSession: options.getConfiguredSession, getClientInfo: options.getClientInfo, getJellyfinConfig: options.getJellyfinConfig, + getActivePlayback: options.getActivePlayback, playJellyfinItem: options.playJellyfinItem, logWarn: options.logWarn, }); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index 273d8691..3304e755 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' }, getJellyfinClientInfoMainDeps: { getResolvedJellyfinConfig: () => ({}) as never, - getDefaultJellyfinConfig: () => ({ - clientName: 'SubMiner', - clientVersion: 'test', - deviceId: 'dev', - }), + getHostName: () => 'workstation', + defaultClientName: 'SubMiner', + defaultClientVersion: 'test', }, waitForMpvConnectedMainDeps: { getMpvClient: () => null, @@ -50,6 +48,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' getMpvClient: () => null, sendMpvCommand: () => {}, wait: async () => {}, + cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }), + cleanupCachedSubtitles: () => {}, logDebug: () => {}, }, playJellyfinItemInMpvMainDeps: { @@ -58,11 +58,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' mode: 'direct', url: 'https://example.test/video.m3u8', title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }), applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, sendMpvCommand: () => {}, armQuitOnDisconnect: () => {}, schedule: () => undefined, @@ -133,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' defaultDeviceId: 'dev', defaultClientName: 'SubMiner', defaultClientVersion: 'test', + getHostName: () => 'workstation', logInfo: () => {}, logWarn: () => {}, }, @@ -189,6 +195,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); assert.equal(typeof composed.playJellyfinItemInMpv, 'function'); + assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function'); assert.equal(typeof composed.startJellyfinRemoteSession, 'function'); assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); assert.equal(typeof composed.runJellyfinCommand, 'function'); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts index ee8d5b2b..30892744 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{ >; startJellyfinRemoteSessionMainDeps: Omit< StartRemoteSessionMainDeps, - 'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand' + | 'getJellyfinConfig' + | 'getClientInfo' + | 'handlePlay' + | 'handlePlaystate' + | 'handleGeneralCommand' >; stopJellyfinRemoteSessionMainDeps: Parameters< typeof createBuildStopJellyfinRemoteSessionMainDepsHandler @@ -142,6 +146,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{ typeof composeJellyfinRemoteHandlers >['handleJellyfinRemoteGeneralCommand']; playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>; + cleanupJellyfinSubtitleCache: () => void; startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>; stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>; runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>; @@ -235,6 +240,7 @@ export function composeJellyfinRuntimeHandlers( createBuildStartJellyfinRemoteSessionMainDepsHandler({ ...options.startJellyfinRemoteSessionMainDeps, getJellyfinConfig: () => getResolvedJellyfinConfig(), + getClientInfo: () => getJellyfinClientInfo(), handlePlay: (payload) => handleJellyfinRemotePlay(payload), handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), @@ -280,6 +286,7 @@ export function composeJellyfinRuntimeHandlers( handleJellyfinRemotePlaystate, handleJellyfinRemoteGeneralCommand, playJellyfinItemInMpv, + cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(), startJellyfinRemoteSession, stopJellyfinRemoteSession, runJellyfinCommand, diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 5bea06e4..62e58076 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: async () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }, shouldRestoreWindowsOnActivateMainDeps: { diff --git a/src/main/runtime/domains/jellyfin.ts b/src/main/runtime/domains/jellyfin.ts index db75837e..9c5f9d33 100644 --- a/src/main/runtime/domains/jellyfin.ts +++ b/src/main/runtime/domains/jellyfin.ts @@ -7,6 +7,7 @@ export * from '../jellyfin-client-info'; export * from '../jellyfin-client-info-main-deps'; export * from '../jellyfin-command-dispatch'; export * from '../jellyfin-command-dispatch-main-deps'; +export * from '../jellyfin-device-identity'; export * from '../jellyfin-playback-launch'; export * from '../jellyfin-playback-launch-main-deps'; export * from '../jellyfin-remote-commands'; diff --git a/src/main/runtime/jellyfin-cli-auth.test.ts b/src/main/runtime/jellyfin-cli-auth.test.ts index 94faee83..b659fecf 100644 --- a/src/main/runtime/jellyfin-cli-auth.test.ts +++ b/src/main/runtime/jellyfin-cli-auth.test.ts @@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => { enabled: true, serverUrl: 'http://localhost', username: 'user', - deviceId: 'd1', - clientName: 'SubMiner', - clientVersion: '1.0', recentServers: ['http://localhost'], }, }); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); }); -test('persistJellyfinAuthSession stores client metadata and recent servers', () => { +test('persistJellyfinAuthSession stores session config and recent servers', () => { let patchPayload: unknown = null; let storedSession: unknown = null; @@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', () enabled: true, serverUrl: 'http://localhost:8096', username: 'alice', - deviceId: 'device-1', - clientName: 'SubMiner', - clientVersion: '1.0', recentServers: [ 'http://localhost:8096', 'http://old.example:8096', @@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', () }); }); +test('persistJellyfinAuthSession does not write generated local device id to config', () => { + let patchPayload: unknown = null; + + persistJellyfinAuthSession({ + session: { + serverUrl: 'http://localhost:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }, + clientInfo: { + deviceId: 'subminer-local-pc', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + existingRecentServers: [], + saveStoredSession: () => {}, + patchRawConfig: (patch) => { + patchPayload = patch; + }, + }); + + assert.deepEqual(patchPayload, { + jellyfin: { + enabled: true, + serverUrl: 'http://localhost:8096', + username: 'alice', + recentServers: ['http://localhost:8096'], + }, + }); +}); + test('jellyfin auth handler no-ops when no auth command', async () => { const handleAuth = createHandleJellyfinAuthCommands({ patchRawConfig: () => {}, diff --git a/src/main/runtime/jellyfin-cli-auth.ts b/src/main/runtime/jellyfin-cli-auth.ts index 9368b6fc..57a87eb6 100644 --- a/src/main/runtime/jellyfin-cli-auth.ts +++ b/src/main/runtime/jellyfin-cli-auth.ts @@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: { enabled: boolean; serverUrl: string; username: string; - deviceId: string; - clientName: string; - clientVersion: string; recentServers: string[]; }>; }) => void; @@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: { enabled: true, serverUrl: deps.session.serverUrl, username: deps.session.username, - deviceId: deps.clientInfo.deviceId, - clientName: deps.clientInfo.clientName, - clientVersion: deps.clientInfo.clientVersion, recentServers: mergeJellyfinRecentServers( deps.session.serverUrl, deps.existingRecentServers || [], @@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: { enabled: boolean; serverUrl: string; username: string; - deviceId: string; - clientName: string; - clientVersion: string; }>; }) => void; authenticateWithPassword: ( diff --git a/src/main/runtime/jellyfin-client-info-main-deps.test.ts b/src/main/runtime/jellyfin-client-info-main-deps.test.ts index 8fa98869..e32c177e 100644 --- a/src/main/runtime/jellyfin-client-info-main-deps.test.ts +++ b/src/main/runtime/jellyfin-client-info-main-deps.test.ts @@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => { test('get jellyfin client info main deps builder maps callbacks', () => { const configured = { clientName: 'Configured' }; - const defaults = { clientName: 'Default' }; const deps = createBuildGetJellyfinClientInfoMainDepsHandler({ getResolvedJellyfinConfig: () => configured as never, - getDefaultJellyfinConfig: () => defaults as never, + getHostName: () => 'workstation', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', })(); assert.equal(deps.getResolvedJellyfinConfig(), configured); - assert.equal(deps.getDefaultJellyfinConfig(), defaults); + assert.equal(deps.getHostName?.(), 'workstation'); + assert.equal(deps.defaultClientName, 'SubMiner'); + assert.equal(deps.defaultClientVersion, '1.0.0'); }); diff --git a/src/main/runtime/jellyfin-client-info-main-deps.ts b/src/main/runtime/jellyfin-client-info-main-deps.ts index 343ee216..c6fbee0f 100644 --- a/src/main/runtime/jellyfin-client-info-main-deps.ts +++ b/src/main/runtime/jellyfin-client-info-main-deps.ts @@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler( ) { return (): GetJellyfinClientInfoMainDeps => ({ getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), - getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(), + getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined, + defaultClientName: deps.defaultClientName, + defaultClientVersion: deps.defaultClientVersion, }); } diff --git a/src/main/runtime/jellyfin-client-info.test.ts b/src/main/runtime/jellyfin-client-info.test.ts index 08ffc5ac..1183e42b 100644 --- a/src/main/runtime/jellyfin-client-info.test.ts +++ b/src/main/runtime/jellyfin-client-info.test.ts @@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou test('jellyfin client info resolves defaults when fields are missing', () => { const getClientInfo = createGetJellyfinClientInfoHandler({ - getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never, - getDefaultJellyfinConfig: () => - ({ - clientName: 'SubMiner', - clientVersion: '1.0.0', - deviceId: 'default-device', - }) as never, + getResolvedJellyfinConfig: () => ({ clientName: '' }) as never, + getHostName: () => 'workstation', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', }); assert.deepEqual(getClientInfo(), { clientName: 'SubMiner', clientVersion: '1.0.0', - deviceId: 'default-device', + deviceId: 'workstation', }); }); -test('jellyfin client info keeps explicit config values', () => { +test('jellyfin client info ignores legacy configured client name, device id, and version', () => { const getClientInfo = createGetJellyfinClientInfoHandler({ getResolvedJellyfinConfig: () => ({ @@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => { clientVersion: '2.3.4', deviceId: 'custom-device', }) as never, - getDefaultJellyfinConfig: () => - ({ - clientName: 'SubMiner', - clientVersion: '1.0.0', - deviceId: 'default-device', - }) as never, + getHostName: () => 'Kyle-PC', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', }); assert.deepEqual(getClientInfo(), { - clientName: 'Custom', - clientVersion: '2.3.4', - deviceId: 'custom-device', + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'Kyle-PC', + }); +}); + +test('jellyfin client info ignores legacy configured device id and client version', () => { + const getClientInfo = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => + ({ + clientName: 'SubMiner', + clientVersion: '9.9.9', + deviceId: 'custom-device', + }) as never, + getHostName: () => 'media-box', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', + }); + + assert.deepEqual(getClientInfo(), { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'media-box', }); }); diff --git a/src/main/runtime/jellyfin-client-info.ts b/src/main/runtime/jellyfin-client-info.ts index 24ce3673..b611e3cb 100644 --- a/src/main/runtime/jellyfin-client-info.ts +++ b/src/main/runtime/jellyfin-client-info.ts @@ -1,5 +1,10 @@ import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store'; import type { ResolvedConfig } from '../../types'; +import { + DEFAULT_JELLYFIN_CLIENT_NAME, + DEFAULT_JELLYFIN_CLIENT_VERSION, + createHostDerivedJellyfinDeviceId, +} from './jellyfin-device-identity'; type ResolvedJellyfinConfig = ResolvedConfig['jellyfin']; type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & { @@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: { } export function createGetJellyfinClientInfoHandler(deps: { - getResolvedJellyfinConfig: () => Partial< - Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'> - >; - getDefaultJellyfinConfig: () => Partial< - Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'> - >; + getResolvedJellyfinConfig: () => unknown; + getHostName?: () => string; + defaultClientName?: string; + defaultClientVersion?: string; }) { return ( - config = deps.getResolvedJellyfinConfig(), + _config = deps.getResolvedJellyfinConfig(), ): { clientName: string; clientVersion: string; deviceId: string; } => { - const defaults = deps.getDefaultJellyfinConfig(); return { - clientName: config.clientName || defaults.clientName || '', - clientVersion: config.clientVersion || defaults.clientVersion || '', - deviceId: config.deviceId || defaults.deviceId || '', + clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME, + clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION, + deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''), }; }; } diff --git a/src/main/runtime/jellyfin-device-identity.test.ts b/src/main/runtime/jellyfin-device-identity.test.ts new file mode 100644 index 00000000..5c223c00 --- /dev/null +++ b/src/main/runtime/jellyfin-device-identity.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createHostDerivedJellyfinDeviceId, + resolveJellyfinRemoteDeviceName, +} from './jellyfin-device-identity'; + +test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => { + assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local'); + assert.equal(createHostDerivedJellyfinDeviceId(''), 'device'); +}); + +test('resolveJellyfinRemoteDeviceName uses hostname by default', () => { + assert.equal( + resolveJellyfinRemoteDeviceName({ + hostName: 'kyle-pc', + }), + 'kyle-pc', + ); +}); + +test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => { + assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device'); +}); diff --git a/src/main/runtime/jellyfin-device-identity.ts b/src/main/runtime/jellyfin-device-identity.ts new file mode 100644 index 00000000..d87f1eb1 --- /dev/null +++ b/src/main/runtime/jellyfin-device-identity.ts @@ -0,0 +1,18 @@ +export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0'; +export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner'; + +export function normalizeJellyfinHostName(value: string): string { + return value.trim(); +} + +export function createHostDerivedJellyfinDeviceId(hostName: string): string { + return normalizeJellyfinHostName(hostName) || 'device'; +} + +export function resolveJellyfinDeviceId(params: { hostName: string }): string { + return createHostDerivedJellyfinDeviceId(params.hostName); +} + +export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string { + return normalizeJellyfinHostName(params.hostName) || 'device'; +} diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts index 0b1a98e0..ceeed7a7 100644 --- a/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts @@ -11,16 +11,23 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { url: 'u', mode: 'direct', title: 't', + itemTitle: 't', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }), applyJellyfinMpvDefaults: () => calls.push('defaults'), + showVisibleOverlay: () => calls.push('visible-overlay'), sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`), armQuitOnDisconnect: () => calls.push('arm'), schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`), convertTicksToSeconds: (ticks) => ticks / 10_000_000, - preloadExternalSubtitles: () => calls.push('preload'), + preloadExternalSubtitles: () => { + calls.push('preload'); + }, setActivePlayback: () => calls.push('active'), setLastProgressAtMs: () => calls.push('progress'), reportPlaying: () => calls.push('report'), @@ -49,12 +56,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { url: 'u', mode: 'direct', title: 't', + itemTitle: 't', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }, ); deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} }); + deps.showVisibleOverlay(); deps.sendMpvCommand(['show-text', 'x']); deps.armQuitOnDisconnect(); deps.schedule(() => {}, 500); @@ -85,6 +97,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { assert.deepEqual(calls, [ 'defaults', + 'visible-overlay', 'cmd:show-text', 'arm', 'schedule:500', diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.ts index a0d15257..17597acc 100644 --- a/src/main/runtime/jellyfin-playback-launch-main-deps.ts +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.ts @@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler( getMpvClient: () => deps.getMpvClient(), resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params), applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient), + showVisibleOverlay: () => deps.showVisibleOverlay(), sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command), armQuitOnDisconnect: () => deps.armQuitOnDisconnect(), schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs), @@ -19,5 +20,11 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler( setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value), reportPlaying: (payload) => deps.reportPlaying(payload), showMpvOsd: (text: string) => deps.showMpvOsd(text), + recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata + ? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata) + : undefined, + updateCurrentMediaTitle: deps.updateCurrentMediaTitle + ? (title) => deps.updateCurrentMediaTitle!(title) + : undefined, }); } diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index 17dfc78c..f6d2916c 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => { throw new Error('unreachable'); }, applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, sendMpvCommand: () => {}, armQuitOnDisconnect: () => {}, schedule: () => {}, @@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => { const calls: string[] = []; const activeStates: Array<Record<string, unknown>> = []; const reportPayloads: Array<Record<string, unknown>> = []; + const statsMetadata: Array<Record<string, unknown>> = []; const handler = createPlayJellyfinItemInMpvHandler({ ensureMpvConnectedForPlayback: async () => true, getMpvClient: () => ({ connected: true, send: () => {} }), @@ -59,22 +61,32 @@ test('playback handler drives mpv commands and playback state', async () => { url: 'https://stream.example/video.m3u8', mode: 'direct', title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: 'Show Title', + seasonNumber: 1, + episodeNumber: 1, startTimeTicks: 12_000_000, audioStreamIndex: 1, subtitleStreamIndex: 2, }), applyJellyfinMpvDefaults: () => calls.push('defaults'), + showVisibleOverlay: () => calls.push('visible-overlay'), sendMpvCommand: (command) => commands.push(command), armQuitOnDisconnect: () => calls.push('arm'), schedule: (callback, delayMs) => { scheduled.push({ delay: delayMs, callback }); }, convertTicksToSeconds: (ticks) => ticks / 10_000_000, - preloadExternalSubtitles: () => calls.push('preload'), + preloadExternalSubtitles: () => { + calls.push('preload'); + }, setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>), setLastProgressAtMs: (value) => calls.push(`progress:${value}`), reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>), showMpvOsd: (text) => calls.push(`osd:${text}`), + recordJellyfinPlaybackMetadata: (metadata) => { + statsMetadata.push(metadata as Record<string, unknown>); + }, }); await handler({ @@ -84,19 +96,34 @@ test('playback handler drives mpv commands and playback state', async () => { itemId: 'item-1', }); - assert.deepEqual(commands.slice(0, 5), [ + assert.deepEqual(commands.slice(0, 8), [ ['set_property', 'sub-auto', 'no'], - ['loadfile', 'https://stream.example/video.m3u8', 'replace'], - ['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'], ['set_property', 'sid', 'no'], - ['seek', 1.2, 'absolute+exact'], + ['set_property', 'secondary-sid', 'no'], + ['set_property', 'sub-visibility', 'no'], + ['set_property', 'secondary-sub-visibility', 'no'], + ['script-message', 'subminer-managed-subtitles-loading'], + [ + 'loadfile', + 'https://stream.example/video.m3u8', + 'replace', + -1, + 'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2', + ], + ['set_property', 'force-media-title', 'Episode 1'], ]); - assert.equal(scheduled.length, 1); - assert.equal(scheduled[0]?.delay, 500); - scheduled[0]?.callback(); - assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); + assert.equal(scheduled.length, 0); + assert.equal( + commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length, + 1, + ); assert.ok(calls.includes('defaults')); + assert.ok( + calls.indexOf('preload') < calls.indexOf('visible-overlay'), + 'visible overlay should be shown after Jellyfin subtitles are selected', + ); + assert.ok(calls.includes('visible-overlay')); assert.ok(calls.includes('arm')); assert.ok(calls.includes('preload')); assert.ok(calls.includes('progress:0')); @@ -104,8 +131,354 @@ test('playback handler drives mpv commands and playback state', async () => { assert.equal(activeStates.length, 1); assert.equal(activeStates[0]?.playMethod, 'DirectPlay'); + assert.equal(activeStates[0]?.lastKnownPositionSeconds, 1.2); assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads[0]?.eventName, 'start'); + assert.equal(reportPayloads[0]?.positionTicks, 12_000_000); + assert.equal(reportPayloads[0]?.isPaused, false); + assert.deepEqual(statsMetadata, [ + { + mediaPath: 'https://stream.example/video.m3u8', + displayTitle: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: 'Show Title', + seasonNumber: 1, + episodeNumber: 1, + itemId: 'item-1', + }, + ]); +}); + +test('playback handler waits for Jellyfin subtitle preload before showing visible overlay', async () => { + const calls: string[] = []; + let resolvePreload!: () => void; + const preloadComplete = new Promise<void>((resolve) => { + resolvePreload = resolve; + }); + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: 'Show Title', + seasonNumber: 1, + episodeNumber: 1, + startTimeTicks: 0, + audioStreamIndex: 1, + subtitleStreamIndex: 2, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => calls.push('visible-overlay'), + sendMpvCommand: () => {}, + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: async () => { + calls.push('preload-start'); + await preloadComplete; + calls.push('preload-done'); + }, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }); + + const playback = handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }); + for (let i = 0; i < 5 && calls.length === 0; i += 1) { + await Promise.resolve(); + } + + assert.equal(calls.length, 1); + assert.equal(calls[0], 'preload-start'); + resolvePreload(); + await playback; + + assert.deepEqual(calls, ['preload-start', 'preload-done', 'visible-overlay']); +}); + +test('playback handler strips Jellyfin subtitle stream from mpv load URL', async () => { + const commands: Array<Array<string | number>> = []; + const reports: Array<Record<string, unknown>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4', + mode: 'direct', + title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: 3, + subtitleStreamIndex: 4, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: (payload) => reports.push(payload), + showMpvOsd: () => {}, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'ep-1', + }); + + const loadCommand = commands.find((command) => command[0] === 'loadfile'); + assert.ok(loadCommand); + const url = new URL(String(loadCommand[1])); + assert.equal(url.searchParams.get('AudioStreamIndex'), '3'); + assert.equal(url.searchParams.has('SubtitleStreamIndex'), false); + assert.equal(reports[0]?.subtitleStreamIndex, 4); +}); + +test('playback handler starts remote Play from beginning when requested despite saved plan progress', async () => { + const commands: Array<Array<string | number>> = []; + const reportPayloads: Array<Record<string, unknown>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000', + mode: 'transcode', + title: 'Episode 2', + itemTitle: 'Episode 2', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 35_000_000, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>), + showMpvOsd: () => {}, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-2', + startTimeTicksOverride: 0, + fallbackToPlanStartTimeOnZeroOverride: false, + }); + + const loadCommand = commands.find((command) => command[0] === 'loadfile'); + assert.ok(loadCommand); + const loadedUrl = String(loadCommand[1] ?? ''); + const parsed = new URL(loadedUrl); + assert.equal(parsed.searchParams.get('StartTimeTicks'), null); + assert.equal( + commands.some((command) => command[0] === 'seek'), + false, + ); + assert.equal(reportPayloads[0]?.positionTicks, 0); +}); + +test('playback handler disables mpv subtitle selection before Jellyfin media loads', async () => { + const commands: Array<Array<string | number>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }); + + const loadIndex = commands.findIndex((command) => command[0] === 'loadfile'); + assert.ok(loadIndex > 0); + assert.ok( + commands.findIndex( + (command, index) => + index < loadIndex && + command[0] === 'script-message' && + command[1] === 'subminer-managed-subtitles-loading', + ) >= 0, + ); + assert.ok( + commands.findIndex( + (command, index) => + index < loadIndex && + command[0] === 'set_property' && + command[1] === 'sid' && + command[2] === 'no', + ) >= 0, + ); + assert.ok( + commands.findIndex( + (command, index) => + index < loadIndex && + command[0] === 'set_property' && + command[1] === 'secondary-sid' && + command[2] === 'no', + ) >= 0, + ); + assert.ok( + commands.findIndex( + (command, index) => + index < loadIndex && + command[0] === 'set_property' && + command[1] === 'sub-visibility' && + command[2] === 'no', + ) >= 0, + ); + assert.ok( + commands.findIndex( + (command, index) => + index < loadIndex && + command[0] === 'set_property' && + command[1] === 'secondary-sub-visibility' && + command[2] === 'no', + ) >= 0, + ); + assert.equal( + commands[loadIndex]?.[4], + 'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no', + ); +}); + +test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => { + const timeline: string[] = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1', + mode: 'direct', + title: 'Galaxy Quest S02E07 A New Hope', + itemTitle: 'A New Hope', + seriesTitle: 'Galaxy Quest', + seasonNumber: 2, + episodeNumber: 7, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}:${String(command[1] ?? '')}`), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + updateCurrentMediaTitle: (title) => { + timeline.push(`title:${title}`); + }, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'ep-1', + }); + + const titleIndex = timeline.indexOf('title:Galaxy Quest S02E07 A New Hope'); + const loadIndex = timeline.findIndex((entry) => entry.startsWith('cmd:loadfile:')); + assert.ok(titleIndex >= 0); + assert.ok(loadIndex >= 0); + assert.ok(titleIndex < loadIndex); + assert.equal(timeline[titleIndex]?.includes('api_key'), false); +}); + +test('playback handler arms unloaded active playback before loading mpv media', async () => { + const timeline: string[] = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}`), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: (state) => timeline.push(`active:${String(state.loadedMediaPath)}`), + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }); + + assert.ok(timeline.indexOf('active:null') >= 0); + assert.ok(timeline.indexOf('active:null') < timeline.indexOf('cmd:loadfile')); }); test('playback handler applies start override to stream url for remote resume', async () => { @@ -117,11 +490,16 @@ test('playback handler applies start override to stream url for remote resume', url: 'https://stream.example/video.m3u8?api_key=token', mode: 'transcode', title: 'Episode 2', + itemTitle: 'Episode 2', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }), applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, sendMpvCommand: (command) => commands.push(command), armQuitOnDisconnect: () => {}, schedule: () => {}, @@ -141,9 +519,226 @@ test('playback handler applies start override to stream url for remote resume', startTimeTicksOverride: 55_000_000, }); - assert.equal(commands[1]?.[0], 'loadfile'); - const loadedUrl = String(commands[1]?.[1] ?? ''); + const loadCommand = commands.find((command) => command[0] === 'loadfile'); + assert.ok(loadCommand); + const loadedUrl = String(loadCommand[1] ?? ''); const parsed = new URL(loadedUrl); assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000'); - assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']); + assert.equal( + commands.some((command) => command[0] === 'seek'), + false, + ); +}); + +test('playback handler keeps Jellyfin resume ticks when remote start override is zero', async () => { + const commands: Array<Array<string | number>> = []; + const reportPayloads: Array<Record<string, unknown>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000', + mode: 'transcode', + title: 'Episode 2', + itemTitle: 'Episode 2', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 35_000_000, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>), + showMpvOsd: () => {}, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-2', + startTimeTicksOverride: 0, + fallbackToPlanStartTimeOnZeroOverride: true, + }); + + const loadCommand = commands.find((command) => command[0] === 'loadfile'); + assert.ok(loadCommand); + const loadedUrl = String(loadCommand[1] ?? ''); + const parsed = new URL(loadedUrl); + assert.equal(parsed.searchParams.get('StartTimeTicks'), '35000000'); + assert.equal( + commands.some((command) => command[0] === 'seek'), + false, + ); + assert.equal(reportPayloads[0]?.positionTicks, 35_000_000); +}); + +test('playback handler does not let stats metadata failures block playback startup', async () => { + const commands: Array<Array<string | number>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 3', + itemTitle: 'Episode 3', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + recordJellyfinPlaybackMetadata: () => { + throw new Error('stats db unavailable'); + }, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-3', + }); + + assert.deepEqual( + commands.find((command) => command[0] === 'loadfile'), + [ + 'loadfile', + 'https://stream.example/video.m3u8', + 'replace', + -1, + 'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no', + ], + ); +}); + +test('playback handler does not let media title failures block playback startup', async () => { + const commands: Array<Array<string | number>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 4', + itemTitle: 'Episode 4', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + updateCurrentMediaTitle: () => { + throw new Error('title state unavailable'); + }, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-4', + }); + + assert.deepEqual( + commands.find((command) => command[0] === 'loadfile'), + [ + 'loadfile', + 'https://stream.example/video.m3u8', + 'replace', + -1, + 'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no', + ], + ); +}); + +test('playback handler handles rejected best-effort hook promises', async () => { + const commands: Array<Array<string | number>> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 5', + itemTitle: 'Episode 5', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + updateCurrentMediaTitle: async () => { + throw new Error('title async unavailable'); + }, + recordJellyfinPlaybackMetadata: async () => { + throw new Error('stats async unavailable'); + }, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-5', + }); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual( + commands.find((command) => command[0] === 'loadfile'), + [ + 'loadfile', + 'https://stream.example/video.m3u8', + 'replace', + -1, + 'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no', + ], + ); }); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index ed681bb4..9b1ad344 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -14,8 +14,45 @@ type ActivePlaybackState = { audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; playMethod: 'DirectPlay' | 'Transcode'; + loadedMediaPath?: string | null; + stopReportsAfterMs?: number; + lastKnownPositionSeconds?: number; }; +export type JellyfinPlaybackStatsMetadata = { + mediaPath: string; + displayTitle: string; + itemTitle: string; + seriesTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + itemId: string; +}; + +const JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS = [ + 'sid=no', + 'secondary-sid=no', + 'sub-auto=no', + 'sub-visibility=no', + 'secondary-sub-visibility=no', +]; + +function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void { + try { + void Promise.resolve(callback()).catch(() => {}); + } catch { + // Best-effort metadata/title hooks must not block playback startup. + } +} + +async function awaitBestEffortPlaybackHook(callback: () => void | Promise<void>): Promise<void> { + try { + await Promise.resolve(callback()); + } catch { + // Best-effort startup hooks must not block playback startup. + } +} + function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string { if (typeof startTimeTicksOverride !== 'number') return url; try { @@ -31,6 +68,48 @@ function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: } } +function stripStartTimeTicksFromPlaybackUrl(url: string): string { + try { + const resolved = new URL(url); + resolved.searchParams.delete('StartTimeTicks'); + return resolved.toString(); + } catch { + return url; + } +} + +function stripManagedSubtitleStreamFromPlaybackUrl(url: string): string { + try { + const resolved = new URL(url); + resolved.searchParams.delete('SubtitleStreamIndex'); + return resolved.toString(); + } catch { + return url; + } +} + +function resolveEffectiveStartTimeTicks( + planStartTimeTicks: number, + startTimeTicksOverride?: number, + fallbackToPlanStartTimeOnZeroOverride = false, +) { + if (typeof startTimeTicksOverride === 'number' && startTimeTicksOverride > 0) { + return Math.max(0, startTimeTicksOverride); + } + if (typeof startTimeTicksOverride === 'number') { + return fallbackToPlanStartTimeOnZeroOverride ? Math.max(0, planStartTimeTicks) : 0; + } + return Math.max(0, planStartTimeTicks); +} + +function buildJellyfinLoadfileOptions(plan: JellyfinPlaybackPlan, startSeconds: number): string { + const options = [...JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS]; + if (plan.mode === 'direct' && startSeconds > 0) { + options.push(`start=${startSeconds}`); + } + return options.join(','); +} + export function createPlayJellyfinItemInMpvHandler(deps: { ensureMpvConnectedForPlayback: () => Promise<boolean>; getMpvClient: () => MpvRuntimeClientLike | null; @@ -43,6 +122,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { subtitleStreamIndex?: number | null; }) => Promise<JellyfinPlaybackPlan>; applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void; + showVisibleOverlay: () => void; sendMpvCommand: (command: Array<string | number>) => void; armQuitOnDisconnect: () => void; schedule: (callback: () => void, delayMs: number) => void; @@ -51,18 +131,24 @@ export function createPlayJellyfinItemInMpvHandler(deps: { session: JellyfinAuthSession; clientInfo: JellyfinClientInfo; itemId: string; - }) => void; + }) => void | Promise<void>; setActivePlayback: (state: ActivePlaybackState) => void; setLastProgressAtMs: (value: number) => void; reportPlaying: (payload: { itemId: string; mediaSourceId: undefined; playMethod: 'DirectPlay' | 'Transcode'; + positionTicks?: number; + isPaused?: boolean; audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; eventName: 'start'; }) => void; showMpvOsd: (text: string) => void; + recordJellyfinPlaybackMetadata?: ( + metadata: JellyfinPlaybackStatsMetadata, + ) => void | Promise<void>; + updateCurrentMediaTitle?: (title: string) => void | Promise<void>; }) { return async (params: { session: JellyfinAuthSession; @@ -72,6 +158,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; startTimeTicksOverride?: number; + fallbackToPlanStartTimeOnZeroOverride?: boolean; setQuitOnDisconnectArm?: boolean; }): Promise<void> => { const connected = await deps.ensureMpvConnectedForPlayback(); @@ -93,48 +180,68 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.applyJellyfinMpvDefaults(mpvClient); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); - const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); - deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); - if (params.setQuitOnDisconnectArm !== false) { - deps.armQuitOnDisconnect(); - } - deps.sendMpvCommand([ - 'set_property', - 'force-media-title', - `[Jellyfin/${plan.mode}] ${plan.title}`, - ]); deps.sendMpvCommand(['set_property', 'sid', 'no']); - deps.schedule(() => { - deps.sendMpvCommand(['set_property', 'sid', 'no']); - }, 500); - - const startTimeTicks = - typeof params.startTimeTicksOverride === 'number' - ? Math.max(0, params.startTimeTicksOverride) - : plan.startTimeTicks; - if (startTimeTicks > 0) { - deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']); - } - - deps.preloadExternalSubtitles({ - session: params.session, - clientInfo: params.clientInfo, - itemId: params.itemId, - }); - + deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']); + deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']); + deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']); + const startTimeTicks = resolveEffectiveStartTimeTicks( + plan.startTimeTicks, + params.startTimeTicksOverride, + params.fallbackToPlanStartTimeOnZeroOverride, + ); + const startSeconds = + startTimeTicks > 0 ? Math.max(0, deps.convertTicksToSeconds(startTimeTicks)) : 0; + const playbackUrlBase = + plan.mode === 'direct' + ? stripStartTimeTicksFromPlaybackUrl(plan.url) + : applyStartTimeTicksToPlaybackUrl(plan.url, startTimeTicks); + const playbackUrl = stripManagedSubtitleStreamFromPlaybackUrl(playbackUrlBase); + const loadfileOptions = buildJellyfinLoadfileOptions(plan, startSeconds); const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode'; + runBestEffortPlaybackHook(() => deps.updateCurrentMediaTitle?.(plan.title)); + runBestEffortPlaybackHook(() => + deps.recordJellyfinPlaybackMetadata?.({ + mediaPath: playbackUrl, + displayTitle: plan.title, + itemTitle: plan.itemTitle, + seriesTitle: plan.seriesTitle, + seasonNumber: plan.seasonNumber, + episodeNumber: plan.episodeNumber, + itemId: params.itemId, + }), + ); deps.setActivePlayback({ itemId: params.itemId, mediaSourceId: undefined, audioStreamIndex: plan.audioStreamIndex, subtitleStreamIndex: plan.subtitleStreamIndex, playMethod, + loadedMediaPath: null, + lastKnownPositionSeconds: startSeconds > 0 ? startSeconds : undefined, }); deps.setLastProgressAtMs(0); + deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']); + deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]); + if (params.setQuitOnDisconnectArm !== false) { + deps.armQuitOnDisconnect(); + } + deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]); + + await awaitBestEffortPlaybackHook(() => + deps.preloadExternalSubtitles({ + session: params.session, + clientInfo: params.clientInfo, + itemId: params.itemId, + }), + ); + deps.showVisibleOverlay(); + deps.reportPlaying({ itemId: params.itemId, mediaSourceId: undefined, playMethod, + positionTicks: startTimeTicks, + isPaused: false, audioStreamIndex: plan.audioStreamIndex, subtitleStreamIndex: plan.subtitleStreamIndex, eventName: 'start', diff --git a/src/main/runtime/jellyfin-remote-commands.test.ts b/src/main/runtime/jellyfin-remote-commands.test.ts index 1cffeef9..52f0df00 100644 --- a/src/main/runtime/jellyfin-remote-commands.test.ts +++ b/src/main/runtime/jellyfin-remote-commands.test.ts @@ -21,7 +21,13 @@ test('getConfiguredJellyfinSession returns null for incomplete config', () => { }); test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => { - const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = []; + const calls: Array<{ + itemId: string; + audio?: number; + subtitle?: number; + start?: number; + fallback?: boolean; + }> = []; const handlePlay = createHandleJellyfinRemotePlay({ getConfiguredSession: () => ({ serverUrl: 'https://jellyfin.local', @@ -37,6 +43,7 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a audio: params.audioStreamIndex, subtitle: params.subtitleStreamIndex, start: params.startTimeTicksOverride, + fallback: params.fallbackToPlanStartTimeOnZeroOverride, }); }, logWarn: () => {}, @@ -49,11 +56,13 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a StartPositionTicks: 1000, }); - assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]); + assert.deepEqual(calls, [ + { itemId: 'item-1', audio: 3, subtitle: 7, start: 1000, fallback: true }, + ]); }); test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => { - const calls: Array<{ itemId: string; start?: number }> = []; + const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = []; const handlePlay = createHandleJellyfinRemotePlay({ getConfiguredSession: () => ({ serverUrl: 'https://jellyfin.local', @@ -67,6 +76,7 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () calls.push({ itemId: params.itemId, start: params.startTimeTicksOverride, + fallback: params.fallbackToPlanStartTimeOnZeroOverride, }); }, logWarn: () => {}, @@ -77,7 +87,64 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () StartPositionTicks: '12345', }); - assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345 }]); + assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345, fallback: true }]); +}); + +test('createHandleJellyfinRemotePlay starts from beginning when StartPositionTicks is omitted', async () => { + const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = []; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({ enabled: true }), + playJellyfinItem: async (params) => { + calls.push({ + itemId: params.itemId, + start: params.startTimeTicksOverride, + fallback: params.fallbackToPlanStartTimeOnZeroOverride, + }); + }, + logWarn: () => {}, + }); + + await handlePlay({ + ItemIds: ['item-3'], + }); + + assert.deepEqual(calls, [{ itemId: 'item-3', start: 0, fallback: false }]); +}); + +test('createHandleJellyfinRemotePlay lets explicit zero fall back to Jellyfin item progress', async () => { + const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = []; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({ enabled: true }), + playJellyfinItem: async (params) => { + calls.push({ + itemId: params.itemId, + start: params.startTimeTicksOverride, + fallback: params.fallbackToPlanStartTimeOnZeroOverride, + }); + }, + logWarn: () => {}, + }); + + await handlePlay({ + ItemIds: ['item-4'], + StartPositionTicks: 0, + }); + + assert.deepEqual(calls, [{ itemId: 'item-4', start: 0, fallback: true }]); }); test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => { @@ -101,6 +168,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']); }); +test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => { + let playCalls = 0; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({}), + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + playJellyfinItem: async () => { + playCalls += 1; + }, + logWarn: () => {}, + }); + + await handlePlay({ ItemIds: ['item-1'] }); + + assert.equal(playCalls, 0); +}); + test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => { const mpvClient = {}; const commands: Array<(string | number)[]> = []; diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts index 17373911..5d50b26d 100644 --- a/src/main/runtime/jellyfin-remote-commands.ts +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -4,6 +4,9 @@ export type ActiveJellyfinRemotePlaybackState = { audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; playMethod: 'DirectPlay' | 'Transcode'; + loadedMediaPath?: string | null; + stopReportsAfterMs?: number; + lastKnownPositionSeconds?: number; }; type JellyfinSession = { @@ -51,6 +54,7 @@ export type JellyfinRemotePlayHandlerDeps = { getConfiguredSession: () => JellyfinSession | null; getClientInfo: () => JellyfinClientInfo; getJellyfinConfig: () => unknown; + getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null; playJellyfinItem: (params: { session: JellyfinSession; clientInfo: JellyfinClientInfo; @@ -59,6 +63,7 @@ export type JellyfinRemotePlayHandlerDeps = { audioStreamIndex?: number; subtitleStreamIndex?: number; startTimeTicksOverride?: number; + fallbackToPlanStartTimeOnZeroOverride?: boolean; setQuitOnDisconnectArm?: boolean; }) => Promise<void>; logWarn: (message: string) => void; @@ -79,6 +84,13 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.'); return; } + if (deps.getActivePlayback?.()?.itemId === itemId) { + return; + } + const hasStartPositionTicks = Object.prototype.hasOwnProperty.call(data, 'StartPositionTicks'); + const startTimeTicksOverride = hasStartPositionTicks + ? (asInteger(data.StartPositionTicks) ?? 0) + : 0; await deps.playJellyfinItem({ session, clientInfo, @@ -86,7 +98,8 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe itemId, audioStreamIndex: asInteger(data.AudioStreamIndex), subtitleStreamIndex: asInteger(data.SubtitleStreamIndex), - startTimeTicksOverride: asInteger(data.StartPositionTicks), + startTimeTicksOverride, + fallbackToPlanStartTimeOnZeroOverride: hasStartPositionTicks, setQuitOnDisconnectArm: false, }); }; diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts index 2f80e93d..cf2cfffc 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts @@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { getLaunchMode: () => 'fullscreen', platform: 'darwin', execPath: '/tmp/subminer', + getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua', + getInstalledPluginDetection: () => ({ + installed: false, + path: null, + version: null, + source: null, + message: null, + }), defaultMpvLogPath: '/tmp/mpv.log', defaultMpvArgs: ['--no-config'], removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`), @@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { assert.equal(deps.getLaunchMode(), 'fullscreen'); assert.equal(deps.platform, 'darwin'); assert.equal(deps.execPath, '/tmp/subminer'); + assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua'); + assert.equal(deps.getInstalledPluginDetection?.().installed, false); assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); assert.deepEqual(deps.defaultMpvArgs, ['--no-config']); deps.removeSocketPath('/tmp/mpv.sock'); diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts index 2c2e2eb5..4ec78d83 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( getLaunchMode: () => deps.getLaunchMode(), platform: deps.platform, execPath: deps.execPath, + getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint, + getInstalledPluginDetection: deps.getInstalledPluginDetection, getPluginRuntimeConfig: deps.getPluginRuntimeConfig, defaultMpvLogPath: deps.defaultMpvLogPath, defaultMpvArgs: deps.defaultMpvArgs, diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index d312478d..6e36eeb0 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( getLaunchMode: () => 'maximized', platform: 'darwin', execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + getRuntimePluginEntrypoint: () => + '/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua', defaultMpvLogPath: '/tmp/mp.log', defaultMpvArgs: ['--sid=auto'], removeSocketPath: () => {}, @@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( assert.equal(spawnedArgs.length, 1); assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes')); assert.ok(spawnedArgs[0]!.includes('--idle=yes')); + assert.ok( + spawnedArgs[0]!.includes( + '--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua', + ), + ); assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock'))); assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); }); @@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/); }); +test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => { + const spawnedArgs: string[][] = []; + const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ + getSocketPath: () => '/tmp/subminer.sock', + getLaunchMode: () => 'normal', + platform: 'linux', + execPath: '/opt/SubMiner/SubMiner.AppImage', + getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua', + getInstalledPluginDetection: () => ({ + installed: true, + path: '/home/tester/.config/mpv/scripts/subminer/main.lua', + version: '0.1.0', + source: 'default-config', + message: null, + }), + defaultMpvLogPath: '/tmp/mp.log', + defaultMpvArgs: ['--sid=auto'], + removeSocketPath: () => {}, + spawnMpv: (args) => { + spawnedArgs.push(args); + return { + on: () => {}, + unref: () => {}, + }; + }, + logWarn: () => {}, + logInfo: () => {}, + }); + + launch(); + assert.equal( + spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')), + false, + ); + assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts='))); +}); + test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => { let autoLaunchInFlight: Promise<boolean> | null = null; let launchCalls = 0; diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts index e72ca17e..90e193f7 100644 --- a/src/main/runtime/jellyfin-remote-connection.ts +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -4,6 +4,7 @@ import { type SubminerPluginRuntimeScriptOptConfig, } from '../../shared/subminer-plugin-script-opts'; import type { MpvLaunchMode } from '../../types/config'; +import type { InstalledMpvPluginDetection } from './first-run-setup-plugin'; type MpvClientLike = { connected: boolean; @@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = { getLaunchMode: () => MpvLaunchMode; platform: NodeJS.Platform; execPath: string; + getRuntimePluginEntrypoint?: () => string | null | undefined; + getInstalledPluginDetection?: () => InstalledMpvPluginDetection; getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig; defaultMpvLogPath: string; defaultMpvArgs: readonly string[]; @@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor ) : [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`]; const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`; + const installedPlugin = deps.getInstalledPluginDetection?.(); + const runtimePluginEntrypoint = installedPlugin?.installed + ? '' + : (deps.getRuntimePluginEntrypoint?.()?.trim() ?? ''); + if (installedPlugin?.installed && installedPlugin.path) { + deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`); + } const mpvArgs = [ ...deps.defaultMpvArgs, ...buildMpvLaunchModeArgs(deps.getLaunchMode()), + ...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []), '--idle=yes', scriptOpts, `--log-file=${deps.defaultMpvLogPath}`, diff --git a/src/main/runtime/jellyfin-remote-main-deps.test.ts b/src/main/runtime/jellyfin-remote-main-deps.test.ts index e8c6730f..b7178795 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.test.ts @@ -103,12 +103,16 @@ test('jellyfin remote stopped main deps builder maps callbacks', () => { getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }), clearActivePlayback: () => calls.push('clear'), getSession: () => session as never, + getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never, + ticksPerSecond: 10_000_000, logDebug: (message) => calls.push(`debug:${message}`), })(); assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' }); deps.clearActivePlayback(); assert.equal(deps.getSession(), session); + assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 }); + assert.equal(deps.ticksPerSecond, 10_000_000); deps.logDebug('stopped', null); assert.deepEqual(calls, ['clear', 'debug:stopped']); }); diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts index aebfa2a6..d5b4dd20 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler( getConfiguredSession: () => deps.getConfiguredSession(), getClientInfo: () => deps.getClientInfo(), getJellyfinConfig: () => deps.getJellyfinConfig(), + ...(deps.getActivePlayback + ? { getActivePlayback: () => deps.getActivePlayback?.() ?? null } + : {}), playJellyfinItem: (params) => deps.playJellyfinItem(params), logWarn: (message: string) => deps.logWarn(message), }); @@ -68,6 +71,9 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler( getActivePlayback: () => deps.getActivePlayback(), clearActivePlayback: () => deps.clearActivePlayback(), getSession: () => deps.getSession(), + getMpvClient: () => deps.getMpvClient(), + getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined, + ticksPerSecond: deps.ticksPerSecond, logDebug: (message: string, error: unknown) => deps.logDebug(message, error), }); } diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index 743018c8..b227f5aa 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + markJellyfinRemotePlaybackLoaded, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, secondsToJellyfinTicks, + shouldAutoLoadSecondarySubTrackForJellyfinPlayback, } from './jellyfin-remote-playback'; test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => { @@ -12,6 +14,39 @@ test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0); }); +test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic secondary autoload for active Jellyfin media', () => { + assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true); + assert.equal( + shouldAutoLoadSecondarySubTrackForJellyfinPlayback( + { itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null }, + 'http://pve-main:8096/Videos/item/stream', + ), + false, + ); + assert.equal( + shouldAutoLoadSecondarySubTrackForJellyfinPlayback( + { + itemId: 'item-1', + playMethod: 'DirectPlay', + loadedMediaPath: 'http://pve-main:8096/Videos/item/stream', + }, + 'http://pve-main:8096/Videos/item/stream', + ), + false, + ); + assert.equal( + shouldAutoLoadSecondarySubTrackForJellyfinPlayback( + { + itemId: 'item-1', + playMethod: 'DirectPlay', + loadedMediaPath: 'http://pve-main:8096/Videos/item/stream', + }, + '/tmp/local.mkv', + ), + true, + ); +}); + test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => { let lastProgressAtMs = 0; const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = []; @@ -61,6 +96,74 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn assert.equal(lastProgressAtMs, 5000); }); +test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => { + const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = []; + + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => false, + reportProgress: async (payload) => { + reportPayloads.push({ + positionTicks: payload.positionTicks, + isPaused: payload.isPaused, + }); + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + currentTimePos: 42, + requestProperty: async (name: string) => (name === 'pause' ? false : 42), + }), + getNow: () => 5000, + getLastProgressAtMs: () => 0, + setLastProgressAtMs: () => {}, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + + assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]); +}); + +test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => { + const reportPayloads: Array<{ isPaused: boolean }> = []; + + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => true, + reportProgress: async (payload) => { + reportPayloads.push({ isPaused: payload.isPaused }); + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3), + }), + getNow: () => 5000, + getLastProgressAtMs: () => 0, + setLastProgressAtMs: () => {}, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + + assert.deepEqual(reportPayloads, [{ isPaused: true }]); +}); + test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => { let called = false; const reportProgress = createReportJellyfinRemoteProgressHandler({ @@ -91,9 +194,61 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy assert.equal(called, false); }); +test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during debounce', async () => { + let now = 5000; + let lastProgressAtMs = 0; + let position = 10; + const reportPayloads: Array<{ positionTicks: number; eventName: string }> = []; + + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => true, + reportProgress: async (payload) => { + reportPayloads.push({ + positionTicks: payload.positionTicks, + eventName: payload.eventName, + }); + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + currentTimePos: position, + requestProperty: async (name: string) => (name === 'pause' ? false : position), + }), + getNow: () => now, + getLastProgressAtMs: () => lastProgressAtMs, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + now = 5500; + position = 90; + await reportProgress(false); + + assert.deepEqual(reportPayloads, [ + { positionTicks: 100_000_000, eventName: 'TimeUpdate' }, + { positionTicks: 900_000_000, eventName: 'TimeUpdate' }, + ]); + assert.equal(lastProgressAtMs, 5500); +}); + test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => { let cleared = false; - let stoppedItemId: string | null = null; + let stoppedPayload: { + itemId: string; + positionTicks?: number; + failed?: boolean; + } | null = null; const reportStopped = createReportJellyfinRemoteStoppedHandler({ getActivePlayback: () => ({ itemId: 'item-2', @@ -109,13 +264,267 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback' isConnected: () => true, reportProgress: async () => {}, reportStopped: async (payload) => { - stoppedItemId = payload.itemId; + stoppedPayload = { + itemId: payload.itemId, + positionTicks: payload.positionTicks, + failed: payload.failed, + }; }, }), + getMpvClient: () => ({ + currentTimePos: 12.5, + requestProperty: async () => { + throw new Error('unloaded'); + }, + }), + ticksPerSecond: 10_000_000, logDebug: () => {}, }); await reportStopped(); - assert.equal(stoppedItemId, 'item-2'); + assert.deepEqual(stoppedPayload, { + itemId: 'item-2', + positionTicks: 125_000_000, + failed: false, + }); 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: { + itemId: string; + positionTicks?: number; + failed?: boolean; + } | null = null; + const reportStopped = createReportJellyfinRemoteStoppedHandler({ + getActivePlayback: () => ({ + itemId: 'item-2', + mediaSourceId: undefined, + playMethod: 'Transcode', + audioStreamIndex: null, + subtitleStreamIndex: null, + loadedMediaPath: 'https://stream.example/video.m3u8', + }), + clearActivePlayback: () => { + cleared = true; + }, + getSession: () => ({ + isConnected: () => false, + reportProgress: async () => {}, + reportStopped: async (payload) => { + stoppedPayload = { + itemId: payload.itemId, + positionTicks: payload.positionTicks, + failed: payload.failed, + }; + }, + }), + getMpvClient: () => ({ + currentTimePos: 12.5, + }), + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportStopped(); + + assert.deepEqual(stoppedPayload, { + itemId: 'item-2', + positionTicks: 125_000_000, + failed: false, + }); + assert.equal(cleared, true); +}); + +test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => { + let cleared = false; + const calls: Array<{ event: string; positionTicks?: number }> = []; + const reportStopped = createReportJellyfinRemoteStoppedHandler({ + getActivePlayback: () => + ({ + itemId: 'item-2', + mediaSourceId: undefined, + playMethod: 'DirectPlay', + audioStreamIndex: null, + subtitleStreamIndex: null, + loadedMediaPath: 'https://stream.example/video.m3u8', + lastKnownPositionSeconds: 72.25, + }) as never, + clearActivePlayback: () => { + cleared = true; + }, + getSession: () => ({ + isConnected: () => true, + reportProgress: async (payload) => { + calls.push({ event: 'progress', positionTicks: payload.positionTicks }); + }, + reportStopped: async (payload) => { + calls.push({ event: 'stopped', positionTicks: payload.positionTicks }); + }, + }), + getMpvClient: () => ({ + currentTimePos: 0, + }), + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportStopped(); + + assert.deepEqual(calls, [ + { event: 'progress', positionTicks: 722_500_000 }, + { event: 'stopped', positionTicks: 722_500_000 }, + ]); + assert.equal(cleared, true); +}); + +test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => { + let cleared = false; + let stopped = false; + const reportStopped = createReportJellyfinRemoteStoppedHandler({ + getActivePlayback: () => + ({ + itemId: 'item-2', + playMethod: 'Transcode', + loadedMediaPath: null, + }) as never, + clearActivePlayback: () => { + cleared = true; + }, + getSession: () => ({ + isConnected: () => true, + reportProgress: async () => {}, + reportStopped: async () => { + stopped = true; + }, + }), + getMpvClient: () => ({ + currentTimePos: 0, + }), + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportStopped(); + + assert.equal(stopped, false); + assert.equal(cleared, true); +}); + +test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => { + let position = 42; + let lastProgressAtMs = 0; + const playback = { + itemId: 'item-1', + playMethod: 'DirectPlay' as const, + }; + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => playback, + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => true, + reportProgress: async () => {}, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + currentTimePos: position, + requestProperty: async (name: string) => (name === 'pause' ? false : position), + }), + getNow: () => 5000, + getLastProgressAtMs: () => lastProgressAtMs, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + position = 0; + await reportProgress(true); + + assert.equal((playback as { lastKnownPositionSeconds?: number }).lastKnownPositionSeconds, 42); +}); + +test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => { + const playback = { + itemId: 'item-2', + playMethod: 'Transcode' as const, + loadedMediaPath: 'https://stream.example/video.m3u8', + }; + + markJellyfinRemotePlaybackLoaded(playback, ''); + markJellyfinRemotePlaybackLoaded(playback, ' '); + assert.equal(playback.loadedMediaPath, 'https://stream.example/video.m3u8'); + + markJellyfinRemotePlaybackLoaded(playback, ' https://stream.example/next.m3u8 '); + assert.equal(playback.loadedMediaPath, 'https://stream.example/next.m3u8'); +}); + +test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => { + let cleared = false; + let stopped = false; + const reportStopped = createReportJellyfinRemoteStoppedHandler({ + getActivePlayback: () => + ({ + itemId: 'item-2', + playMethod: 'DirectPlay', + loadedMediaPath: 'https://stream.example/video.m3u8', + stopReportsAfterMs: 20_000, + }) as never, + clearActivePlayback: () => { + cleared = true; + }, + getSession: () => ({ + isConnected: () => true, + reportProgress: async () => {}, + reportStopped: async () => { + stopped = true; + }, + }), + getMpvClient: () => ({ + currentTimePos: 0, + }), + getNow: () => 12_000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportStopped(); + + assert.equal(stopped, false); + assert.equal(cleared, false); +}); diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index f085ef52..8ea0c6c9 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -10,11 +10,13 @@ type JellyfinRemoteSessionLike = { playMethod: 'DirectPlay' | 'Transcode'; audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; - eventName: 'timeupdate'; + eventName: 'TimeUpdate'; }) => Promise<unknown>; reportStopped: (payload: { itemId: string; mediaSourceId?: string; + positionTicks?: number; + failed?: boolean; playMethod: 'DirectPlay' | 'Transcode'; audioStreamIndex?: number | null; subtitleStreamIndex?: number | null; @@ -23,7 +25,8 @@ type JellyfinRemoteSessionLike = { }; type MpvClientLike = { - requestProperty: (name: string) => Promise<unknown>; + currentTimePos?: number; + requestProperty?: (name: string) => Promise<unknown>; }; export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number { @@ -31,6 +34,106 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): return Math.max(0, Math.floor(seconds * ticksPerSecond)); } +export function markJellyfinRemotePlaybackLoaded( + playback: ActiveJellyfinRemotePlaybackState | null, + path: string, +): void { + const normalizedPath = path.trim(); + if (playback && normalizedPath) { + playback.loadedMediaPath = normalizedPath; + } +} + +export function shouldAutoLoadSecondarySubTrackForJellyfinPlayback( + playback: ActiveJellyfinRemotePlaybackState | null, + path: string, +): boolean { + const normalizedPath = path.trim(); + if (!normalizedPath || !playback) { + return true; + } + const loadedMediaPath = playback.loadedMediaPath?.trim() ?? ''; + if (!loadedMediaPath) { + return false; + } + return loadedMediaPath !== normalizedPath; +} + +function isMpvPauseEnabled(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === 'no' || normalized === 'false' || normalized === '0') { + return false; + } + return true; + } + return false; +} + +function normalizeMpvPositionSeconds(value: unknown): number { + const seconds = Number(value); + if (!Number.isFinite(seconds)) return 0; + return Math.max(0, seconds); +} + +function getCachedMpvPositionSeconds(client: MpvClientLike | null): number | null { + if (!client) return null; + const seconds = Number(client.currentTimePos); + return Number.isFinite(seconds) ? Math.max(0, seconds) : null; +} + +async function readMpvPositionSeconds(client: MpvClientLike | null): Promise<number> { + const cached = getCachedMpvPositionSeconds(client); + if (cached !== null) return cached; + const position = await client?.requestProperty?.('time-pos'); + return normalizeMpvPositionSeconds(position); +} + +async function readMpvPositionSecondsOrFallback( + client: MpvClientLike | null, + fallback = 0, +): Promise<number> { + try { + return await readMpvPositionSeconds(client); + } catch { + return fallback; + } +} + +function cacheLastKnownPosition( + playback: ActiveJellyfinRemotePlaybackState, + positionSeconds: number, +): void { + if (!Number.isFinite(positionSeconds)) return; + if (positionSeconds > 0 || playback.lastKnownPositionSeconds === undefined) { + playback.lastKnownPositionSeconds = Math.max(0, positionSeconds); + } +} + +function resolveReportablePositionSeconds( + playback: ActiveJellyfinRemotePlaybackState, + positionSeconds: number, +): number { + const normalizedPosition = normalizeMpvPositionSeconds(positionSeconds); + if (normalizedPosition > 0) return normalizedPosition; + const cachedPosition = playback.lastKnownPositionSeconds; + if (typeof cachedPosition === 'number' && Number.isFinite(cachedPosition) && cachedPosition > 0) { + return cachedPosition; + } + return normalizedPosition; +} + +function isSeekLikePositionJump( + previousPositionSeconds: number | null, + nextPositionSeconds: number, + thresholdSeconds: number, +): boolean { + if (previousPositionSeconds === null) return false; + return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds; +} + export type JellyfinRemoteProgressReporterDeps = { getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; clearActivePlayback: () => void; @@ -47,29 +150,44 @@ export type JellyfinRemoteProgressReporterDeps = { export function createReportJellyfinRemoteProgressHandler( deps: JellyfinRemoteProgressReporterDeps, ) { + let lastReportedPositionSeconds: number | null = null; + return async (force = false): Promise<void> => { const playback = deps.getActivePlayback(); if (!playback) return; const session = deps.getSession(); - if (!session || !session.isConnected()) return; + // Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects. + if (!session) return; const now = deps.getNow(); - if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) { - return; - } try { const mpvClient = deps.getMpvClient(); - const position = await mpvClient?.requestProperty('time-pos'); - const paused = await mpvClient?.requestProperty('pause'); + const observedPositionSeconds = await readMpvPositionSeconds(mpvClient); + cacheLastKnownPosition(playback, observedPositionSeconds); + const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); + const forceForSeekJump = isSeekLikePositionJump( + lastReportedPositionSeconds, + positionSeconds, + Math.max(2, deps.progressIntervalMs / 1000), + ); + if ( + !force && + !forceForSeekJump && + now - deps.getLastProgressAtMs() < deps.progressIntervalMs + ) { + return; + } + const paused = await mpvClient?.requestProperty?.('pause'); await session.reportProgress({ itemId: playback.itemId, mediaSourceId: playback.mediaSourceId, - positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond), - isPaused: paused === true, + positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond), + isPaused: isMpvPauseEnabled(paused), playMethod: playback.playMethod, audioStreamIndex: playback.audioStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex, - eventName: 'timeupdate', + eventName: 'TimeUpdate', }); + lastReportedPositionSeconds = positionSeconds; deps.setLastProgressAtMs(now); } catch (error) { deps.logDebug('Failed to report Jellyfin remote progress', error); @@ -81,6 +199,9 @@ export type JellyfinRemoteStoppedReporterDeps = { getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; clearActivePlayback: () => void; getSession: () => JellyfinRemoteSessionLike | null; + getMpvClient: () => MpvClientLike | null; + getNow?: () => number; + ticksPerSecond: number; logDebug: (message: string, error: unknown) => void; }; @@ -88,15 +209,46 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto return async (): Promise<void> => { const playback = deps.getActivePlayback(); if (!playback) return; + if (playback.loadedMediaPath === null) { + deps.clearActivePlayback(); + return; + } + if ( + typeof playback.stopReportsAfterMs === 'number' && + Number.isFinite(playback.stopReportsAfterMs) && + (deps.getNow?.() ?? Date.now()) < playback.stopReportsAfterMs + ) { + return; + } const session = deps.getSession(); - if (!session || !session.isConnected()) { + // Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects. + if (!session) { deps.clearActivePlayback(); return; } try { + const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient()); + const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); + const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond); + try { + await session.reportProgress({ + itemId: playback.itemId, + mediaSourceId: playback.mediaSourceId, + positionTicks, + isPaused: false, + playMethod: playback.playMethod, + audioStreamIndex: playback.audioStreamIndex, + subtitleStreamIndex: playback.subtitleStreamIndex, + eventName: 'TimeUpdate', + }); + } catch (error) { + deps.logDebug('Failed to report Jellyfin remote final progress', error); + } await session.reportStopped({ itemId: playback.itemId, mediaSourceId: playback.mediaSourceId, + positionTicks, + failed: false, playMethod: playback.playMethod, audioStreamIndex: playback.audioStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex, diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts index 80722056..76519e58 100644 --- a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts @@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) { serverUrl: 'http://localhost', accessToken: 'token', userId: 'user-id', - deviceId: '', - clientName: '', - clientVersion: '', - remoteControlDeviceName: '', autoAnnounce: false, ...(overrides || {}), } as never; @@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => { } | null = null; let started = false; const infos: string[] = []; + let stateChanges = 0; const startRemote = createStartJellyfinRemoteSessionHandler({ getJellyfinConfig: () => createConfig({ clientName: 'Desk' }), getCurrentSession: () => null, @@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => { storedSession = session as never; }, createRemoteSessionService: (options) => { - assert.equal(options.deviceName, 'Desk'); + assert.equal(options.deviceName, 'workstation'); return { start: () => { started = true; @@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, logInfo: (message) => infos.push(message), logWarn: () => {}, + onSessionStateChanged: () => { + stateChanges += 1; + }, }); await startRemote(); assert.equal(started, true); assert.ok(storedSession); - assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).'))); + assert.equal(stateChanges, 1); + assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).'))); +}); + +test('start handler uses hostname-derived client info and visible device name', async () => { + let createdOptions: { + deviceId: string; + clientName: string; + clientVersion: string; + deviceName: string; + } | null = null; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => + createConfig({ + clientName: 'SubMiner', + }), + getClientInfo: () => ({ + deviceId: 'kyle-pc', + clientName: 'SubMiner', + clientVersion: '0.1.0', + }), + getHostName: () => 'kyle-pc', + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: (options) => { + createdOptions = { + deviceId: options.deviceId, + clientName: options.clientName, + clientVersion: options.clientVersion, + deviceName: options.deviceName, + }; + return { + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'subminer', + defaultClientName: 'SubMiner', + defaultClientVersion: '0.1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote({ explicit: true }); + + assert.deepEqual(createdOptions, { + deviceId: 'kyle-pc', + clientName: 'SubMiner', + clientVersion: '0.1.0', + deviceName: 'kyle-pc', + }); +}); + +test('start handler ignores configured visible device name', async () => { + let createdDeviceName = ''; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => + createConfig({ + remoteControlDeviceName: 'SubMiner Cachy sudacode', + }), + getClientInfo: () => ({ + deviceId: 'cachy', + clientName: 'SubMiner', + clientVersion: '0.1.0', + }), + getHostName: () => 'cachy', + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: (options) => { + createdDeviceName = options.deviceName; + return { + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'subminer', + defaultClientName: 'SubMiner', + defaultClientVersion: '0.1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote({ explicit: true }); + + assert.equal(createdDeviceName, 'cachy'); }); test('start handler stops previous session before replacing', async () => { @@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => { defaultDeviceId: 'default-device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', + getClientInfo: () => ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }), + getHostName: () => 'workstation', handlePlay: async () => {}, handlePlaystate: async () => {}, handleGeneralCommand: async () => {}, @@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => { test('stop handler stops active session and clears playback', () => { let stopCalls = 0; let clearCalls = 0; + let stateChanges = 0; let currentSession: { stop: () => void } | null = { stop: () => { stopCalls += 1; @@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => { clearActivePlayback: () => { clearCalls += 1; }, + onSessionStateChanged: () => { + stateChanges += 1; + }, }); stopRemote(); assert.equal(stopCalls, 1); assert.equal(clearCalls, 1); assert.equal(currentSession, null); + assert.equal(stateChanges, 1); }); diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts index df95c9a0..233f94af 100644 --- a/src/main/runtime/jellyfin-remote-session-lifecycle.ts +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts @@ -1,3 +1,5 @@ +import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity'; + type JellyfinRemoteConfig = { enabled: boolean; remoteControlEnabled: boolean; @@ -5,11 +7,13 @@ type JellyfinRemoteConfig = { serverUrl: string; accessToken?: string; userId?: string; + autoAnnounce: boolean; +}; + +type JellyfinClientInfo = { deviceId: string; clientName: string; clientVersion: string; - remoteControlDeviceName: string; - autoAnnounce: boolean; }; type JellyfinRemoteService = { @@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: { getCurrentSession: () => JellyfinRemoteService | null; setCurrentSession: (session: JellyfinRemoteService | null) => void; createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService; + getClientInfo: () => JellyfinClientInfo; + getHostName: () => string; defaultDeviceId: string; defaultClientName: string; defaultClientVersion: string; @@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: { handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>; logInfo: (message: string) => void; logWarn: (message: string, details?: unknown) => void; + onSessionStateChanged?: () => void; }) { return async (options?: { explicit?: boolean }): Promise<void> => { const jellyfinConfig = deps.getJellyfinConfig(); @@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: { if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return; if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; + const clientInfo = deps.getClientInfo(); + const clientName = clientInfo.clientName || deps.defaultClientName; + const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion; + const deviceName = resolveJellyfinRemoteDeviceName({ + hostName: deps.getHostName(), + }); + const existing = deps.getCurrentSession(); if (existing) { existing.stop(); @@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: { const service = deps.createRemoteSessionService({ serverUrl: jellyfinConfig.serverUrl, accessToken: jellyfinConfig.accessToken, - deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId, - clientName: jellyfinConfig.clientName || deps.defaultClientName, - clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion, - deviceName: - jellyfinConfig.remoteControlDeviceName || - jellyfinConfig.clientName || - deps.defaultClientName, + deviceId: clientInfo.deviceId || deps.defaultDeviceId, + clientName, + clientVersion, + deviceName, capabilities: { PlayableMediaTypes: 'Video,Audio', SupportedCommands: @@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: { service.start(); deps.setCurrentSession(service); - deps.logInfo( - `Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, - ); + deps.onSessionStateChanged?.(); + deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`); }; } @@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: { getCurrentSession: () => JellyfinRemoteService | null; setCurrentSession: (session: JellyfinRemoteService | null) => void; clearActivePlayback: () => void; + onSessionStateChanged?: () => void; }) { return (): void => { const session = deps.getCurrentSession(); @@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: { session.stop(); deps.setCurrentSession(null); deps.clearActivePlayback(); + deps.onSessionStateChanged?.(); }; } diff --git a/src/main/runtime/jellyfin-remote-session-main-deps.test.ts b/src/main/runtime/jellyfin-remote-session-main-deps.test.ts index 6d5f9b6b..ce8ffc69 100644 --- a/src/main/runtime/jellyfin-remote-session-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-session-main-deps.test.ts @@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async () getCurrentSession: () => null, setCurrentSession: () => calls.push('set-session'), createRemoteSessionService: () => session as never, + getClientInfo: () => + ({ + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }) as never, + getHostName: () => 'workstation', defaultDeviceId: 'device', defaultClientName: 'SubMiner', defaultClientVersion: '1.0', @@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async () }, logInfo: (message) => calls.push(`info:${message}`), logWarn: (message) => calls.push(`warn:${message}`), + onSessionStateChanged: () => calls.push('state-changed'), })(); assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' }); assert.equal(deps.defaultDeviceId, 'device'); assert.equal(deps.defaultClientName, 'SubMiner'); assert.equal(deps.defaultClientVersion, '1.0'); + assert.equal(deps.getHostName(), 'workstation'); + assert.deepEqual(deps.getClientInfo(), { + deviceId: 'workstation', + clientName: 'SubMiner', + clientVersion: '1.0', + }); assert.equal(deps.createRemoteSessionService({} as never), session); await deps.handlePlay({}); await deps.handlePlaystate({}); await deps.handleGeneralCommand({}); deps.logInfo('connected'); deps.logWarn('missing'); - assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']); + deps.onSessionStateChanged?.(); + assert.deepEqual(calls, [ + 'play', + 'playstate', + 'general', + 'info:connected', + 'warn:missing', + 'state-changed', + ]); }); test('stop jellyfin remote session main deps builder maps callbacks', () => { @@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => { getCurrentSession: () => session as never, setCurrentSession: () => calls.push('set-null'), clearActivePlayback: () => calls.push('clear'), + onSessionStateChanged: () => calls.push('state-changed'), })(); assert.equal(deps.getCurrentSession(), session); deps.setCurrentSession(null); deps.clearActivePlayback(); - assert.deepEqual(calls, ['set-null', 'clear']); + deps.onSessionStateChanged?.(); + assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']); }); diff --git a/src/main/runtime/jellyfin-remote-session-main-deps.ts b/src/main/runtime/jellyfin-remote-session-main-deps.ts index 49b2e152..47accb1b 100644 --- a/src/main/runtime/jellyfin-remote-session-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-session-main-deps.ts @@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler( getCurrentSession: () => deps.getCurrentSession(), setCurrentSession: (session) => deps.setCurrentSession(session), createRemoteSessionService: (options) => deps.createRemoteSessionService(options), + getClientInfo: () => deps.getClientInfo(), + getHostName: () => deps.getHostName(), defaultDeviceId: deps.defaultDeviceId, defaultClientName: deps.defaultClientName, defaultClientVersion: deps.defaultClientVersion, @@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler( handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload), logInfo: (message: string) => deps.logInfo(message), logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), + onSessionStateChanged: deps.onSessionStateChanged, }); } @@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler( getCurrentSession: () => deps.getCurrentSession(), setCurrentSession: (session) => deps.setCurrentSession(session), clearActivePlayback: () => deps.clearActivePlayback(), + onSessionStateChanged: deps.onSessionStateChanged, }); } diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts index eae22bf1..3a26ac0b 100644 --- a/src/main/runtime/jellyfin-setup-window.ts +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin <meta charset="utf-8" /> <title>Jellyfin Setup diff --git a/src/main/runtime/jellyfin-subtitle-cache-io.test.ts b/src/main/runtime/jellyfin-subtitle-cache-io.test.ts new file mode 100644 index 00000000..ac833ec6 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-cache-io.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createJellyfinSubtitleCacheIo } from './jellyfin-subtitle-cache-io'; + +test('jellyfin subtitle cache io downloads tracks to temp files and cleans cache dirs', async () => { + const writes: Array<{ filePath: string; bytes: string }> = []; + const removed: Array<{ dir: string; recursive: boolean; force: boolean }> = []; + const cacheIo = createJellyfinSubtitleCacheIo({ + tmpDir: () => '/tmp', + makeTempDir: async (prefix) => { + assert.equal(prefix, '/tmp/subminer-jellyfin-subtitles-'); + return '/tmp/subminer-jellyfin-subtitles-abc'; + }, + writeFile: async (filePath, bytes) => { + writes.push({ filePath, bytes: new TextDecoder().decode(bytes) }); + }, + removeDir: (dir, options) => { + removed.push({ dir, ...options }); + }, + fetch: async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => new TextEncoder().encode('subtitle body').buffer as ArrayBuffer, + }), + }); + + const cached = await cacheIo.cacheSubtitleTrack({ + index: 7, + deliveryUrl: 'https://example.test/Items/1/Subtitles/7/Stream.ass?api_key=secret', + }); + cacheIo.cleanupCachedSubtitles([cached.cleanupDir]); + + assert.deepEqual(cached, { + path: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass', + cleanupDir: '/tmp/subminer-jellyfin-subtitles-abc', + }); + assert.deepEqual(writes, [ + { + filePath: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass', + bytes: 'subtitle body', + }, + ]); + assert.deepEqual(removed, [ + { dir: '/tmp/subminer-jellyfin-subtitles-abc', recursive: true, force: true }, + ]); +}); + +test('jellyfin subtitle cache io removes temp dir when download fails', async () => { + const removed: string[] = []; + const cacheIo = createJellyfinSubtitleCacheIo({ + tmpDir: () => '/tmp', + makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed', + writeFile: async () => {}, + removeDir: (dir) => { + removed.push(dir); + }, + 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.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); +}); diff --git a/src/main/runtime/jellyfin-subtitle-cache-io.ts b/src/main/runtime/jellyfin-subtitle-cache-io.ts new file mode 100644 index 00000000..3361c16a --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-cache-io.ts @@ -0,0 +1,75 @@ +import * as path from 'path'; + +type JellyfinSubtitleCacheTrack = { + index: number; + deliveryUrl?: string | null; +}; + +type JellyfinSubtitleCacheEntry = { + path: string; + cleanupDir: string; +}; + +type FetchResponseLike = { + ok: boolean; + status: number; + arrayBuffer: () => Promise; +}; + +type JellyfinSubtitleCacheIoDeps = { + tmpDir: () => string; + makeTempDir: (prefix: string) => Promise; + writeFile: (filePath: string, bytes: Uint8Array) => Promise; + removeDir: (dir: string, options: { recursive: true; force: true }) => void | Promise; + fetch: (url: string) => Promise; +}; + +function getSubtitleExtension(deliveryUrl: string): string { + const urlPath = (() => { + try { + return new URL(deliveryUrl).pathname; + } catch { + return deliveryUrl; + } + })(); + return path.extname(urlPath).slice(0, 16) || '.srt'; +} + +export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps) { + return { + async cacheSubtitleTrack( + track: JellyfinSubtitleCacheTrack, + ): Promise { + if (!track.deliveryUrl) { + throw new Error('Jellyfin subtitle track has no delivery URL'); + } + + const cacheDir = await deps.makeTempDir( + path.join(deps.tmpDir(), 'subminer-jellyfin-subtitles-'), + ); + const subtitlePath = path.join( + cacheDir, + `track-${track.index}${getSubtitleExtension(track.deliveryUrl)}`, + ); + try { + const response = await deps.fetch(track.deliveryUrl); + if (!response.ok) { + throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + await deps.writeFile(subtitlePath, bytes); + } catch (error) { + 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) { + void Promise.resolve(deps.removeDir(dir, { recursive: true, force: true })).catch(() => {}); + } + }, + }; +} diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts index ce6f8d90..bb79a4de 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -14,6 +14,24 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy wait: async () => { calls.push('wait'); }, + cacheSubtitleTrack: async () => { + calls.push('cache'); + 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}`), })(); @@ -21,6 +39,23 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function'); deps.sendMpvCommand(['set_property', 'sid', 'auto']); 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', 'debug:oops']); + 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', + ]); }); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts index ed84df5e..63cb0e8b 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -13,6 +13,21 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( getMpvClient: () => deps.getMpvClient(), sendMpvCommand: (command) => deps.sendMpvCommand(command), 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, logDebug: (message: string, error: unknown) => deps.logDebug(message, error), }); } diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index 696a917e..bc8ae77c 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -15,65 +15,980 @@ const clientInfo = { deviceId: 'dev', }; -test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => { +function makeDeps(overrides: { + listJellyfinSubtitleTracks?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['listJellyfinSubtitleTracks']; + getMpvClient?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['getMpvClient']; + sendMpvCommand?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['sendMpvCommand']; + wait?: Parameters[0]['wait']; + cacheSubtitleTrack?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['cacheSubtitleTrack']; + cleanupCachedSubtitles?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['cleanupCachedSubtitles']; + getSavedSubtitleDelay?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['getSavedSubtitleDelay']; + setActiveSubtitleDelayKey?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['setActiveSubtitleDelayKey']; + loadSubtitleSourceText?: (source: string) => Promise; + saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void; + logDebug?: Parameters[0]['logDebug']; +}) { + return { + listJellyfinSubtitleTracks: overrides.listJellyfinSubtitleTracks ?? (async () => []), + getMpvClient: overrides.getMpvClient ?? (() => null), + sendMpvCommand: overrides.sendMpvCommand ?? (() => {}), + wait: overrides.wait ?? (async () => {}), + cacheSubtitleTrack: + overrides.cacheSubtitleTrack ?? + (async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`, + cleanupDir: '/tmp/subminer-jellyfin-subtitles', + })), + cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}), + getSavedSubtitleDelay: overrides.getSavedSubtitleDelay, + setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey, + loadSubtitleSourceText: overrides.loadSubtitleSourceText, + saveSubtitleDelay: overrides.saveSubtitleDelay, + logDebug: overrides.logDebug ?? (() => {}), + }; +} + +function withoutTrackAutoSelectionCommands( + commands: Array>, +): Array> { + return commands.filter( + (command) => + !( + command[0] === 'set_property' && + (command[1] === 'track-auto-selection' || + (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] === 'sub-delay' && command[2] === 0)) + ), + ); +} + +function setPropertyCommandsExceptTrackAutoSelection( + commands: Array>, +): Array> { + return withoutTrackAutoSelectionCommands(commands).filter( + (command) => command[0] === 'set_property', + ); +} + +test('preload jellyfin subtitles caches external tracks locally and chooses japanese+english tracks', async () => { const commands: Array> = []; - const preload = createPreloadJellyfinExternalSubtitlesHandler({ - listJellyfinSubtitleTracks: async () => [ - { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, - { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, - { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, - ], - getMpvClient: () => ({ - requestProperty: async () => [ - { type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true }, - { type: 'sub', id: 6, lang: 'eng', title: 'English', external: true }, + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, + { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, ], + getMpvClient: () => ({ + requestProperty: async () => [ + { + type: 'sub', + id: 5, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 6, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + cacheSubtitleTrack: async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`, + cleanupDir: '/tmp/subminer-jellyfin-subtitles', + }), }), - sendMpvCommand: (command) => commands.push(command), - wait: async () => {}, - logDebug: () => {}, - }); + ); await preload({ session, clientInfo, itemId: 'item-1' }); - assert.deepEqual(commands, [ - ['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'], - ['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'], + assert.deepEqual(withoutTrackAutoSelectionCommands(commands), [ + ['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'], ['set_property', 'sid', 5], ['set_property', 'secondary-sid', 6], ]); }); +test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => { + const commands: Array> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { + type: 'sub', + id: 5, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 6, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual( + commands.filter((command) => command[0] === 'sub-add').map((command) => command[2]), + ['auto', 'auto'], + ); + const firstFinalSelectionIndex = commands.findIndex( + (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 5, + ); + assert.ok(firstFinalSelectionIndex >= 0); + assert.equal( + commands + .slice(0, firstFinalSelectionIndex) + .some( + (command) => + command[0] === 'sub-add' && (command[2] === 'cached' || command[2] === 'select'), + ), + false, + ); +}); + +test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + if (requestCount < 3) { + return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }]; + } + return [ + { type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }, + { + type: 'sub', + id: 5, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 6, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 3); + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 5], + ['set_property', 'secondary-sid', 6], + ]); +}); + +test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + if (requestCount < 3) { + return [{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }]; + } + return [ + { type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }, + { + type: 'sub', + id: 42, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 43, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 3); + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 42], + ['set_property', 'secondary-sid', 43], + ]); +}); + +test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => { + const commands: Array> = []; + const activeDelayKeys: Array = []; + 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> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { + index: 0, + language: 'jpn', + title: 'External Japanese', + isExternal: true, + deliveryUrl: 'https://sub/external.srt', + }, + { + index: 1, + language: 'jpn', + title: 'Embedded Japanese', + isDefault: true, + isExternal: false, + deliveryUrl: 'https://sub/embedded.srt', + }, + { + index: 2, + language: 'eng', + title: 'English', + deliveryUrl: 'https://sub/english.srt', + }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { + type: 'sub', + id: 5, + lang: 'jpn', + title: 'External Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: 6, + lang: 'jpn', + title: 'Embedded Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + { + type: 'sub', + id: 7, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/2.srt', + }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 6], + ['set_property', 'secondary-sid', 7], + ]); +}); + +test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => { + const commands: Array> = []; + 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 () => { + const commands: Array> = []; + 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: () => 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, + ); + const selectedSidIndex = commands.findIndex( + (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11, + ); + assert.ok(delayIndex >= 0); + assert.ok(selectedSidIndex >= 0); + assert.ok(delayIndex < selectedSidIndex); +}); + +test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => { + const commands: Array> = []; + 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> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { + type: 'sub', + id: ' ', + lang: 'jpn', + title: 'Invalid empty id', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/invalid.srt', + }, + { + type: 'sub', + id: '10', + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: '11', + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 10], + ['set_property', 'secondary-sid', 11], + ]); +}); + +test('preload jellyfin subtitles retries transient mpv track-list read failures', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + ], + getMpvClient: () => ({ + connected: true, + requestProperty: async () => { + requestCount += 1; + if (requestCount === 1) { + throw new Error('MPV request timed out'); + } + return [ + { + type: 'sub', + id: 10, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 2); + assert.deepEqual(withoutTrackAutoSelectionCommands(commands).at(-1), ['set_property', 'sid', 10]); +}); + +test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, + { index: 10, language: 'deu', title: 'German', deliveryUrl: 'https://sub/deu.ass' }, + { index: 12, language: 'rus', title: 'Russian', deliveryUrl: 'https://sub/rus.ass' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + if (requestCount === 1) { + return [ + { + type: 'sub', + id: 11, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ]; + } + return [ + { + type: 'sub', + id: 11, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + { + type: 'sub', + id: 18, + lang: 'deu', + title: 'German', + external: true, + selected: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/10.srt', + }, + { + type: 'sub', + id: 20, + lang: 'rus', + title: 'Russian', + external: true, + selected: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/12.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 2); + assert.deepEqual( + commands.filter((command) => command[0] === 'sub-add'), + [ + ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'Japanese', 'jpn'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/10.srt', 'auto', 'German', 'deu'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'], + ], + ); + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 11], + ]); +}); + +test('preload jellyfin subtitles suppresses subtitle selection without disabling video auto selection', async () => { + const commands: Array> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, + { index: 2, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { + type: 'sub', + id: 11, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + { + type: 'sub', + id: 12, + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/2.srt', + }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + const firstSubAddIndex = commands.findIndex((command) => command[0] === 'sub-add'); + const subtitleSuppressionIndex = commands.findIndex( + (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no', + ); + const finalPrimarySidIndex = commands.findIndex( + (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11, + ); + + assert.equal( + commands.some( + (command) => command[0] === 'set_property' && command[1] === 'track-auto-selection', + ), + false, + ); + assert.ok(subtitleSuppressionIndex >= 0); + assert.ok(subtitleSuppressionIndex < firstSubAddIndex); + assert.ok(firstSubAddIndex < finalPrimarySidIndex); + assert.equal( + commands.filter( + (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11, + ).length, + 1, + ); +}); + +test('preload jellyfin subtitles does not select a missing japanese track', async () => { + const commands: Array> = []; + const logs: string[] = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => { + requestCount += 1; + return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + logDebug: (message) => logs.push(message), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 10); + assert.equal( + commands.some( + (command) => + command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number', + ), + false, + ); + assert.deepEqual(logs, ['Timed out waiting for Jellyfin Japanese subtitle track']); +}); + +test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => { + const cleanupCalls: string[][] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + ], + getMpvClient: () => ({ requestProperty: async () => [] }), + cacheSubtitleTrack: async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`, + cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`, + }), + cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + await preload({ session, clientInfo, itemId: 'item-2' }); + + assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]); +}); + +test('preload jellyfin subtitles continues after cleanup failures', async () => { + const commands: Array> = []; + const cleanupCalls: string[][] = []; + const logs: string[] = []; + let cleanupShouldFail = false; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => [ + { + index: itemId === 'item-1' ? 0 : 1, + language: 'eng', + title: 'English', + deliveryUrl: `https://sub/${itemId}.srt`, + }, + ], + getMpvClient: () => ({ requestProperty: async () => [] }), + cacheSubtitleTrack: async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`, + cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`, + }), + sendMpvCommand: (command) => commands.push(command), + cleanupCachedSubtitles: (dirs) => { + cleanupCalls.push(dirs); + if (cleanupShouldFail) { + throw new Error('cleanup failed'); + } + }, + logDebug: (message) => logs.push(message), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + cleanupShouldFail = true; + await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' })); + cleanupShouldFail = false; + preload.cleanupCachedSubtitles(); + + assert.deepEqual(logs, ['Failed to cleanup Jellyfin cached subtitles']); + assert.deepEqual(cleanupCalls, [ + ['/tmp/subminer-jellyfin-subtitles-0'], + ['/tmp/subminer-jellyfin-subtitles-0', '/tmp/subminer-jellyfin-subtitles-1'], + ]); + assert.deepEqual( + commands.filter((command) => command[0] === 'sub-add'), + [ + ['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles-1/track.srt', 'auto', 'English', 'eng'], + ], + ); +}); + +test('preload jellyfin subtitles serializes overlapping preload runs', async () => { + let releaseFirstList!: () => void; + const firstListBlocked = new Promise((resolve) => { + releaseFirstList = resolve; + }); + const listCalls: string[] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => { + listCalls.push(itemId); + if (itemId === 'item-1') { + await firstListBlocked; + } + return []; + }, + }), + ); + + const first = preload({ session, clientInfo, itemId: 'item-1' }); + const second = preload({ session, clientInfo, itemId: 'item-2' }); + await Promise.resolve(); + + assert.deepEqual(listCalls, ['item-1']); + releaseFirstList(); + await Promise.all([first, second]); + assert.deepEqual(listCalls, ['item-1', 'item-2']); +}); + +test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => { + const cleanupCalls: string[][] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + ], + getMpvClient: () => ({ requestProperty: async () => [] }), + cacheSubtitleTrack: async () => ({ + path: '/tmp/subminer-jellyfin-subtitles-active/track.srt', + cleanupDir: '/tmp/subminer-jellyfin-subtitles-active', + }), + cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + preload.cleanupCachedSubtitles(); + preload.cleanupCachedSubtitles(); + + assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-active']]); +}); + test('preload jellyfin subtitles exits quietly when no external tracks', async () => { const commands: Array> = []; let waited = false; - const preload = createPreloadJellyfinExternalSubtitlesHandler({ - listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], - getMpvClient: () => ({ requestProperty: async () => [] }), - sendMpvCommand: (command) => commands.push(command), - wait: async () => { - waited = true; - }, - logDebug: () => {}, - }); + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], + getMpvClient: () => ({ requestProperty: async () => [] }), + sendMpvCommand: (command) => commands.push(command), + wait: async () => { + waited = true; + }, + }), + ); 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 () => { const logs: string[] = []; - const preload = createPreloadJellyfinExternalSubtitlesHandler({ - listJellyfinSubtitleTracks: async () => { - throw new Error('network down'); - }, - getMpvClient: () => null, - sendMpvCommand: () => {}, - wait: async () => {}, - logDebug: (message) => logs.push(message), - }); + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => { + throw new Error('network down'); + }, + getMpvClient: () => null, + sendMpvCommand: () => {}, + wait: async () => {}, + logDebug: (message) => logs.push(message), + }), + ); await preload({ session, clientInfo, itemId: 'item-1' }); diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 409dff51..abcebff8 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -1,3 +1,6 @@ +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; +import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset'; + type JellyfinSession = { serverUrl: string; accessToken: string; @@ -15,13 +18,53 @@ type JellyfinSubtitleTrack = { index: number; language?: string; title?: string; + codec?: string; + isDefault?: boolean; + isForced?: boolean; + isExternal?: boolean; + deliveryMethod?: string; deliveryUrl?: string | null; }; +type CachedSubtitleTrack = { + path: string; + cleanupDir: string; +}; + +type CachedExternalSubtitleTrack = CachedSubtitleTrack & { + source: JellyfinSubtitleTrack; +}; + +type JellyfinSubtitleDelayKey = { + itemId: string; + streamIndex: number; +}; + +type MpvSubtitleTrack = { + id: number; + lang: string; + title: string; + external: boolean; + externalFilename: string; +}; + type MpvClientLike = { + connected?: boolean; requestProperty: (name: string) => Promise; }; +const TRACK_SELECTION_INITIAL_WAIT_MS = 250; +const TRACK_SELECTION_RETRY_MS = 150; +const TRACK_SELECTION_MAX_ATTEMPTS = 10; + +export type PreloadJellyfinExternalSubtitlesHandler = ((params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; +}) => Promise) & { + cleanupCachedSubtitles: () => void; +}; + function normalizeLang(value: unknown): string { return String(value || '') .trim() @@ -58,17 +101,12 @@ function isLikelyHearingImpaired(title: string): boolean { } function pickBestTrackId( - tracks: Array<{ - id: number; - lang: string; - title: string; - external: boolean; - }>, + tracks: MpvSubtitleTrack[], languageMatcher: (value: string) => boolean, excludeId: number | null = null, ): number | null { const ranked = tracks - .filter((track) => languageMatcher(track.lang)) + .filter((track) => languageMatcher(track.lang) || languageMatcher(track.title)) .filter((track) => track.id !== excludeId) .map((track) => ({ track, @@ -81,6 +119,192 @@ function pickBestTrackId( return ranked[0]?.track.id ?? null; } +function pickBestCachedTrackId( + tracks: MpvSubtitleTrack[], + cachedTracks: CachedExternalSubtitleTrack[], + sourceMatcher: (value: string) => boolean, + excludeId: number | null = null, +): number | null { + const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track])); + const ranked = tracks + .map((track) => ({ + track, + cached: cachedByPath.get(track.externalFilename), + })) + .filter(({ cached }) => + cached + ? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '') + : false, + ) + .filter(({ track }) => track.id !== excludeId) + .map(({ track, cached }) => { + const title = cached?.source.title || track.title; + return { + track, + score: + (track.external ? 100 : 0) + + (cached?.source.isDefault ? 35 : 0) + + (cached?.source.isExternal === false ? 25 : 0) + + (cached?.source.isExternal === true ? -10 : 0) + + (cached?.source.isForced ? -25 : 0) + + (isLikelyHearingImpaired(title) ? -10 : 10) + + (/\bdefault\b/i.test(title) ? 3 : 0), + }; + }) + .sort((a, b) => b.score - a.score); + return ranked[0]?.track.id ?? null; +} + +function findCachedTrackForMpvTrackId( + tracks: MpvSubtitleTrack[], + cachedTracks: CachedExternalSubtitleTrack[], + trackId: number | null, +): CachedExternalSubtitleTrack | null { + if (trackId === null) return null; + const mpvTrack = tracks.find((track) => track.id === trackId); + if (!mpvTrack?.externalFilename) return null; + return cachedTracks.find((track) => track.path === mpvTrack.externalFilename) ?? null; +} + +function isJapaneseTrack(track: MpvSubtitleTrack): boolean { + return isJapanese(track.lang) || isJapanese(track.title); +} + +function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean { + return tracks.some((track) => track.external && isJapaneseTrack(track)); +} + +function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] { + return Array.isArray(trackListRaw) + ? trackListRaw + .filter( + (track): track is Record => + Boolean(track) && typeof track === 'object' && track.type === 'sub', + ) + .map((track) => ({ + id: parseTrackId(track.id), + lang: String(track.lang || ''), + title: String(track.title || ''), + external: track.external === true, + externalFilename: String(track['external-filename'] || ''), + })) + .filter((track): track is MpvSubtitleTrack => track.id !== null) + : []; +} + +function hasExpectedExternalSubtitleTracks( + tracks: MpvSubtitleTrack[], + expectedExternalFilenames: string[], +): boolean { + if (expectedExternalFilenames.length === 0) { + return true; + } + const loadedExternalFilenames = new Set( + tracks.filter((track) => track.externalFilename).map((track) => track.externalFilename), + ); + return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath)); +} + +function parseTrackId(value: unknown): number | null { + if (typeof value === 'string' && value.trim() === '') { + return null; + } + const numeric = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; + return Number.isFinite(numeric) ? numeric : null; +} + +async function readMpvSubtitleTracks(deps: { + getMpvClient: () => MpvClientLike | null; +}): Promise { + const client = deps.getMpvClient(); + if (!client || client.connected === false) { + return null; + } + let trackListRaw: unknown; + try { + trackListRaw = await client.requestProperty('track-list'); + } catch { + return null; + } + return parseMpvSubtitleTracks(trackListRaw); +} + +async function waitForPreferredSubtitleTracks( + deps: { + getMpvClient: () => MpvClientLike | null; + wait: (ms: number) => Promise; + }, + shouldWaitForExternalJapanese: boolean, + expectedExternalFilenames: string[], +): Promise { + let subtitleTracks: MpvSubtitleTrack[] = []; + for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) { + const nextTracks = await readMpvSubtitleTracks(deps); + if (nextTracks !== null) { + subtitleTracks = nextTracks; + if ( + (!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) && + hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames) + ) { + return subtitleTracks; + } + } + if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) { + await deps.wait(TRACK_SELECTION_RETRY_MS); + } + } + return subtitleTracks; +} + +async function estimateSubtitleDelayFromReference( + deps: { + loadSubtitleSourceText?: (source: string) => Promise; + logDebug: (message: string, error: unknown) => void; + }, + primaryTrack: CachedExternalSubtitleTrack | null, + referenceTrack: CachedExternalSubtitleTrack | null, +): Promise { + 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, @@ -90,14 +314,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { getMpvClient: () => MpvClientLike | null; sendMpvCommand: (command: Array) => void; wait: (ms: number) => Promise; + cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise; + cleanupCachedSubtitles: (dirs: string[]) => void; + getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null; + setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; + loadSubtitleSourceText?: (source: string) => Promise; + saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; logDebug: (message: string, error: unknown) => void; -}) { - return async (params: { +}): PreloadJellyfinExternalSubtitlesHandler { + const activeCacheDirs = new Set(); + let preloadQueue: Promise = Promise.resolve(); + + function resetManagedSubtitleDelay(): void { + deps.sendMpvCommand(['set_property', 'sub-delay', 0]); + } + + function cleanupActiveCache(): void { + const dirs = [...activeCacheDirs]; + if (dirs.length === 0) return; + deps.cleanupCachedSubtitles(dirs); + for (const dir of dirs) { + activeCacheDirs.delete(dir); + } + } + + const runPreload = async (params: { session: JellyfinSession; clientInfo: JellyfinClientInfo; itemId: string; }): Promise => { try { + try { + cleanupActiveCache(); + } catch (error) { + deps.logDebug('Failed to cleanup Jellyfin cached subtitles', error); + } const tracks = await deps.listJellyfinSubtitleTracks( params.session, params.clientInfo, @@ -105,11 +356,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { ); const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl)); if (externalTracks.length === 0) { + deps.setActiveSubtitleDelayKey?.(null); + resetManagedSubtitleDelay(); return; } + deps.sendMpvCommand(['set_property', 'sid', 'no']); + deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']); + deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']); + deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']); await deps.wait(300); const seenUrls = new Set(); + const cachedTracks: CachedExternalSubtitleTrack[] = []; for (const track of externalTracks) { if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) { continue; @@ -117,36 +375,80 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { seenUrls.add(track.deliveryUrl); const labelBase = (track.title || track.language || '').trim(); const label = labelBase || `Jellyfin Subtitle ${track.index}`; - deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']); + const cached = await deps.cacheSubtitleTrack(track); + activeCacheDirs.add(cached.cleanupDir); + cachedTracks.push({ ...cached, source: track }); + deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']); } - await deps.wait(250); - const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list'); - const subtitleTracks = Array.isArray(trackListRaw) - ? trackListRaw - .filter( - (track): track is Record => - Boolean(track) && - typeof track === 'object' && - track.type === 'sub' && - typeof track.id === 'number', - ) - .map((track) => ({ - id: track.id as number, - lang: String(track.lang || ''), - title: String(track.title || ''), - external: track.external === true, - })) - : []; + await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS); + const shouldWaitForExternalJapanese = externalTracks.some( + (track) => isJapanese(track.language || '') || isJapanese(track.title || ''), + ); + const subtitleTracks = await waitForPreferredSubtitleTracks( + deps, + shouldWaitForExternalJapanese, + cachedTracks.map((track) => track.path), + ); + if ( + shouldWaitForExternalJapanese && + (!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks)) + ) { + deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', { + itemId: params.itemId, + }); + return; + } - const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); + const resolvedSubtitleTracks = subtitleTracks ?? []; + const japanesePrimaryId = + pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isJapanese) ?? + pickBestTrackId(resolvedSubtitleTracks, isJapanese); + const englishSecondaryId = + pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ?? + pickBestTrackId(resolvedSubtitleTracks, isEnglish, japanesePrimaryId); if (japanesePrimaryId !== null) { - deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); + const selectedCachedTrack = findCachedTrackForMpvTrackId( + resolvedSubtitleTracks, + cachedTracks, + 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]); + } else { + deps.setActiveSubtitleDelayKey?.(null); + resetManagedSubtitleDelay(); + deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); + } } else { deps.sendMpvCommand(['set_property', 'sid', 'no']); + deps.setActiveSubtitleDelayKey?.(null); + resetManagedSubtitleDelay(); } - const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); if (englishSecondaryId !== null) { deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); } @@ -154,4 +456,20 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { deps.logDebug('Failed to preload Jellyfin external subtitles', error); } }; + + const preload = (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; + }): Promise => { + preloadQueue = preloadQueue.then( + () => runPreload(params), + () => runPreload(params), + ); + return preloadQueue; + }; + + return Object.assign(preload, { + cleanupCachedSubtitles: cleanupActiveCache, + }); } diff --git a/src/main/runtime/jellyfin-tray-discovery.test.ts b/src/main/runtime/jellyfin-tray-discovery.test.ts index cd23a841..7c627330 100644 --- a/src/main/runtime/jellyfin-tray-discovery.test.ts +++ b/src/main/runtime/jellyfin-tray-discovery.test.ts @@ -194,6 +194,124 @@ test('stops active discovery from tray', async () => { ]); }); +test('uses checked tray state to start discovery instead of blind toggling', async () => { + const calls: string[] = []; + let session: { advertiseNow: () => Promise } | null = null; + + await toggleJellyfinDiscoveryFromTray( + { + getRemoteSession: () => session, + stopRemoteSession: () => calls.push('stop'), + startRemoteSession: async (options) => { + assert.deepEqual(options, { explicit: true }); + calls.push('start'); + session = { + advertiseNow: async () => { + calls.push('advertise'); + return true; + }, + }; + }, + refreshTrayMenu: () => calls.push('refresh'), + logger: { + info: (message) => calls.push(`info:${message}`), + warn: (message) => calls.push(`warn:${message}`), + error: (message) => calls.push(`error:${message}`), + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + }, + { desiredActive: true }, + ); + + assert.deepEqual(calls, [ + 'start', + 'advertise', + 'info:Jellyfin discovery started; cast target is visible in server sessions.', + 'osd:Jellyfin discovery started', + 'refresh', + ]); +}); + +test('uses unchecked tray state to stop discovery without visibility probing', async () => { + const calls: string[] = []; + + await toggleJellyfinDiscoveryFromTray( + { + getRemoteSession: () => ({ + advertiseNow: async () => { + calls.push('advertise'); + return true; + }, + }), + stopRemoteSession: () => calls.push('stop'), + startRemoteSession: async () => { + calls.push('start'); + }, + refreshTrayMenu: () => calls.push('refresh'), + logger: { + info: (message) => calls.push(`info:${message}`), + warn: (message) => calls.push(`warn:${message}`), + error: (message) => calls.push(`error:${message}`), + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + }, + { desiredActive: false }, + ); + + assert.deepEqual(calls, [ + 'stop', + 'info:Jellyfin discovery stopped.', + 'osd:Jellyfin discovery stopped', + 'refresh', + ]); +}); + +test('restarts active discovery when current session is not visible', async () => { + const calls: string[] = []; + let session: { advertiseNow: () => Promise } | null = { + advertiseNow: async () => { + calls.push('advertise-stale'); + return false; + }, + }; + + await toggleJellyfinDiscoveryFromTray({ + getRemoteSession: () => session, + stopRemoteSession: () => { + calls.push('stop'); + session = null; + }, + startRemoteSession: async (options) => { + assert.deepEqual(options, { explicit: true }); + calls.push('start'); + session = { + advertiseNow: async () => { + calls.push('advertise-fresh'); + return true; + }, + }; + }, + refreshTrayMenu: () => calls.push('refresh'), + logger: { + info: (message) => calls.push(`info:${message}`), + warn: (message) => calls.push(`warn:${message}`), + error: (message) => calls.push(`error:${message}`), + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + }); + + assert.deepEqual(calls, [ + 'advertise-stale', + 'warn:Jellyfin discovery was active but not visible; restarting.', + 'stop', + 'start', + 'advertise-fresh', + 'info:Jellyfin discovery started; cast target is visible in server sessions.', + 'osd:Jellyfin discovery started', + 'refresh', + ]); +}); + test('warns and refreshes tray when explicit discovery cannot create a session', async () => { const calls: string[] = []; diff --git a/src/main/runtime/jellyfin-tray-discovery.ts b/src/main/runtime/jellyfin-tray-discovery.ts index 1611db30..79ffa033 100644 --- a/src/main/runtime/jellyfin-tray-discovery.ts +++ b/src/main/runtime/jellyfin-tray-discovery.ts @@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray, + options: { desiredActive?: boolean } = {}, ): Promise { try { const activeSession = deps.getRemoteSession(); - if (activeSession) { - deps.stopRemoteSession(); - deps.logger.info('Jellyfin discovery stopped.'); - deps.showMpvOsd('Jellyfin discovery stopped'); + if (options.desiredActive === false) { + if (activeSession) { + deps.stopRemoteSession(); + deps.logger.info('Jellyfin discovery stopped.'); + deps.showMpvOsd('Jellyfin discovery stopped'); + } return; } + if (activeSession) { + let visible = false; + try { + visible = await activeSession.advertiseNow(); + } catch { + deps.logger.warn('Jellyfin discovery visibility check failed; restarting.'); + } + + if (visible) { + if (options.desiredActive === true) { + deps.logger.info('Jellyfin discovery already active.'); + } else { + deps.stopRemoteSession(); + deps.logger.info('Jellyfin discovery stopped.'); + deps.showMpvOsd('Jellyfin discovery stopped'); + } + return; + } + + deps.logger.warn('Jellyfin discovery was active but not visible; restarting.'); + deps.stopRemoteSession(); + } + await deps.startRemoteSession({ explicit: true }); const remoteSession = deps.getRemoteSession(); if (!remoteSession) { diff --git a/src/main/runtime/local-subtitle-selection.test.ts b/src/main/runtime/local-subtitle-selection.test.ts index cf55a0a0..15398099 100644 --- a/src/main/runtime/local-subtitle-selection.test.ts +++ b/src/main/runtime/local-subtitle-selection.test.ts @@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec ['set_property', 'sid', 3], ]); }); + +test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => { + const commands: Array> = []; + const scheduled = new Map void>(); + let nextTimerId = 1; + + const runtime = createManagedLocalSubtitleSelectionRuntime({ + getCurrentMediaPath: () => '/videos/example.mkv', + getMpvClient: () => + ({ + connected: true, + requestProperty: async (name: string) => { + if (name === 'track-list') { + return [ + { type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true }, + { type: 'sub', id: 4, lang: 'en', title: 'en.srt', external: true }, + ]; + } + throw new Error(`Unexpected property: ${name}`); + }, + }) as never, + getPrimarySubtitleLanguages: () => [], + getSecondarySubtitleLanguages: () => [], + sendMpvCommand: (command) => { + commands.push(command); + }, + schedule: (callback) => { + const timerId = nextTimerId++; + scheduled.set(timerId, callback); + return timerId as never; + }, + clearScheduled: (timer) => { + scheduled.delete(timer as never); + }, + }); + + runtime.handleMediaPathChange('/videos/example.mkv'); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true }, + ]); + + assert.deepEqual(commands, [['set_property', 'sid', 3]]); + assert.equal(scheduled.size, 1); + + const refresh = [...scheduled.values()][0]; + assert.ok(refresh); + refresh(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands, [ + ['set_property', 'sid', 3], + ['set_property', 'secondary-sid', 4], + ]); +}); diff --git a/src/main/runtime/local-subtitle-selection.ts b/src/main/runtime/local-subtitle-selection.ts index fa35bbfc..53e389ef 100644 --- a/src/main/runtime/local-subtitle-selection.ts +++ b/src/main/runtime/local-subtitle-selection.ts @@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: { pendingTimer = null; }; + const hasAppliedSelectionForCurrentMediaPath = (): boolean => + appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath; + const maybeApplySelection = (trackList: unknown[] | null): void => { - if ( - !currentMediaPath || - (appliedPrimaryMediaPath === currentMediaPath && - appliedSecondaryMediaPath === currentMediaPath) - ) { + if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) { return; } const selection = resolveManagedLocalSubtitleSelection({ @@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: { deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]); appliedSecondaryMediaPath = currentMediaPath; } - if (appliedPrimaryMediaPath === currentMediaPath) { + if (hasAppliedSelectionForCurrentMediaPath()) { clearPendingTimer(); } }; @@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: { const scheduleRefresh = (): void => { clearPendingTimer(); - if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) { + if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) { return; } pendingTimer = deps.schedule(() => { diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.ts index b6169ae4..5ef9b1b3 100644 --- a/src/main/runtime/mpv-client-runtime-service-main-deps.ts +++ b/src/main/runtime/mpv-client-runtime-service-main-deps.ts @@ -11,6 +11,7 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType | null; setReconnectTimer: (timer: ReturnType | null) => void; + shouldAutoLoadSecondarySubTrack?: (path: string) => boolean; shouldQuitOnMpvShutdown?: () => boolean; requestAppQuit?: () => void; bindEventHandlers: (client: TClient) => void; @@ -26,6 +27,9 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< getReconnectTimer: () => deps.getReconnectTimer(), setReconnectTimer: (timer: ReturnType | null) => deps.setReconnectTimer(timer), + shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack + ? (path: string) => deps.shouldAutoLoadSecondarySubTrack?.(path) ?? true + : undefined, shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false, requestAppQuit: () => deps.requestAppQuit?.(), }, diff --git a/src/main/runtime/mpv-client-runtime-service.ts b/src/main/runtime/mpv-client-runtime-service.ts index 2fd0290b..10b6cf63 100644 --- a/src/main/runtime/mpv-client-runtime-service.ts +++ b/src/main/runtime/mpv-client-runtime-service.ts @@ -7,6 +7,7 @@ export type MpvClientRuntimeServiceOptions = { isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType | null; setReconnectTimer: (timer: ReturnType | null) => void; + shouldAutoLoadSecondarySubTrack?: (path: string) => boolean; shouldQuitOnMpvShutdown?: () => boolean; requestAppQuit?: () => void; }; diff --git a/src/main/runtime/mpv-jellyfin-defaults.test.ts b/src/main/runtime/mpv-jellyfin-defaults.test.ts index 6f2ea8e1..9b12f96d 100644 --- a/src/main/runtime/mpv-jellyfin-defaults.test.ts +++ b/src/main/runtime/mpv-jellyfin-defaults.test.ts @@ -14,10 +14,11 @@ test('apply jellyfin mpv defaults sends expected property commands', () => { applyDefaults({ connected: true, send: () => {} }); assert.deepEqual(calls, [ - 'set_property:sub-auto:fuzzy', + 'set_property:sub-auto:no', 'set_property:aid:auto', - 'set_property:sid:auto', - 'set_property:secondary-sid:auto', + 'set_property:sid:no', + 'set_property:secondary-sid:no', + 'set_property:sub-visibility:no', 'set_property:secondary-sub-visibility:no', 'set_property:alang:ja,jp', 'set_property:slang:ja,jp', diff --git a/src/main/runtime/mpv-jellyfin-defaults.ts b/src/main/runtime/mpv-jellyfin-defaults.ts index f5bf038c..daa912cf 100644 --- a/src/main/runtime/mpv-jellyfin-defaults.ts +++ b/src/main/runtime/mpv-jellyfin-defaults.ts @@ -6,10 +6,11 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: { jellyfinLangPref: string; }) { return (client: MpvRuntimeClientLike): void => { - deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']); + deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'no']); deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']); - deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']); - deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'no']); + deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'no']); + deps.sendMpvCommandRuntime(client, ['set_property', 'sub-visibility', 'no']); deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']); deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]); deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]); diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 9066bedf..727a00c2 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -168,6 +168,28 @@ test('media path change handler signals autoplay readiness from warm media path' ]); }); +test('media path change handler marks Jellyfin remote playback loaded from media path', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + reportJellyfinRemoteStopped: () => calls.push('stopped'), + restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync'), + markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ path: 'https://stream.example/video.m3u8' }); + + assert.ok(calls.includes('jellyfin-loaded:https://stream.example/video.m3u8')); + assert.equal(calls.includes('stopped'), false); +}); + test('media title change handler clears guess state without re-scheduling character dictionary sync', () => { const calls: string[] = []; const deps: Parameters[0] & { @@ -222,6 +244,36 @@ test('time-pos and pause handlers report progress with correct urgency', () => { ]); }); +test('time-pos handler forces Jellyfin progress when mpv position jumps', () => { + const calls: string[] = []; + const timeHandler = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: (time) => calls.push(`time:${time}`), + reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + refreshDiscordPresence: () => calls.push('presence'), + maybeRunAnilistPostWatchUpdate: async () => {}, + }); + + timeHandler({ time: 10 }); + timeHandler({ time: 11 }); + timeHandler({ time: 90 }); + timeHandler({ time: 30 }); + + assert.deepEqual(calls, [ + 'time:10', + 'progress:normal', + 'presence', + 'time:11', + 'progress:normal', + 'presence', + 'time:90', + 'progress:force', + 'presence', + 'time:30', + 'progress:force', + 'presence', + ]); +}); + test('time-pos handler passes fresh playback time to AniList post-watch', async () => { const watchedSeconds: unknown[] = []; const timeHandler = createHandleMpvTimePosChangeHandler({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index d255cf12..dca7a765 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -4,6 +4,15 @@ type AnilistPostWatchRunOptions = { watchedSeconds?: number; }; +const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5; + +function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean { + if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) { + return false; + } + return Math.abs(nextTime - previousTime) >= SEEK_LIKE_TIME_DELTA_SECONDS; +} + export function createHandleMpvSubtitleChangeHandler(deps: { setCurrentSubText: (text: string) => void; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; @@ -59,6 +68,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { syncImmersionMediaState: () => void; scheduleCharacterDictionarySync?: () => void; signalAutoplayReadyIfWarm?: (path: string) => void; + markJellyfinRemotePlaybackLoaded?: (path: string) => void; flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; refreshDiscordPresence: () => void; }) { @@ -81,6 +91,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { } deps.syncImmersionMediaState(); if (normalizedPath.trim().length > 0) { + deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath); deps.scheduleCharacterDictionarySync?.(); deps.signalAutoplayReadyIfWarm?.(normalizedPath); } @@ -113,9 +124,15 @@ export function createHandleMpvTimePosChangeHandler(deps: { logError?: (message: string, error: unknown) => void; onTimePosUpdate?: (time: number) => void; }) { + let lastObservedTime: number | null = null; + return ({ time }: { time: number }): void => { + const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time); + if (Number.isFinite(time)) { + lastObservedTime = time; + } deps.recordPlaybackPosition(time); - deps.reportJellyfinRemoteProgress(false); + deps.reportJellyfinRemoteProgress(forceImmediate); deps.refreshDiscordPresence(); void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => { deps.logError?.('AniList post-watch update failed unexpectedly', error); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 65c59fb5..b3ddef03 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -63,6 +63,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { ensureAnilistMediaGuess: (mediaKey: string) => void; syncImmersionMediaState: () => void; signalAutoplayReadyIfWarm?: (path: string) => void; + markJellyfinRemotePlaybackLoaded?: (path: string) => void; flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; updateCurrentMediaTitle: (title: string) => void; @@ -142,6 +143,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { flushPlaybackPositionOnMediaPathClear: (mediaPath) => deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath), signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path), + markJellyfinRemotePlaybackLoaded: (path) => deps.markJellyfinRemotePlaybackLoaded?.(path), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 6515025f..5219e300 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -65,6 +65,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { ensureAnilistMediaGuess: (mediaKey: string) => void; syncImmersionMediaState: () => void; signalAutoplayReadyIfWarm?: (path: string) => void; + markJellyfinRemotePlaybackLoaded?: (path: string) => void; scheduleCharacterDictionarySync?: () => void; updateCurrentMediaTitle: (title: string) => void; resetAnilistMediaGuessState: () => void; @@ -178,6 +179,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), syncImmersionMediaState: () => deps.syncImmersionMediaState(), signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path), + markJellyfinRemotePlaybackLoaded: (path: string) => + deps.markJellyfinRemotePlaybackLoaded?.(path), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title), resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), diff --git a/src/main/runtime/overlay-modal-input-state.test.ts b/src/main/runtime/overlay-modal-input-state.test.ts index 694f7e1c..55bc1f6d 100644 --- a/src/main/runtime/overlay-modal-input-state.test.ts +++ b/src/main/runtime/overlay-modal-input-state.test.ts @@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d assert.deepEqual(modalWindow.calls, [ 'focusable:true', 'ignore:false', - 'top:true:screen-saver:1', + 'top:true:screen-saver:3', 'focus', 'web-focus', ]); diff --git a/src/main/runtime/overlay-modal-input-state.ts b/src/main/runtime/overlay-modal-input-state.ts index fd49a952..68c58797 100644 --- a/src/main/runtime/overlay-modal-input-state.ts +++ b/src/main/runtime/overlay-modal-input-state.ts @@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { setWindowFocusable(modalWindow); requestOverlayApplicationFocus(); modalWindow.setIgnoreMouseEvents(false); - modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); + modalWindow.setAlwaysOnTop(true, 'screen-saver', 3); modalWindow.focus(); if (!modalWindow.webContents.isFocused()) { modalWindow.webContents.focus(); diff --git a/src/main/runtime/overlay-visibility-actions-main-deps.ts b/src/main/runtime/overlay-visibility-actions-main-deps.ts index df554710..518d0b3b 100644 --- a/src/main/runtime/overlay-visibility-actions-main-deps.ts +++ b/src/main/runtime/overlay-visibility-actions-main-deps.ts @@ -10,6 +10,9 @@ export function createBuildSetVisibleOverlayVisibleMainDepsHandler( deps: SetVisibleOverlayVisibleMainDeps, ) { return (): SetVisibleOverlayVisibleMainDeps => ({ + getVisibleOverlayVisible: deps.getVisibleOverlayVisible + ? () => deps.getVisibleOverlayVisible?.() ?? false + : undefined, setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options), setVisibleOverlayVisibleState: (visible: boolean) => deps.setVisibleOverlayVisibleState(visible), diff --git a/src/main/runtime/overlay-visibility-actions.test.ts b/src/main/runtime/overlay-visibility-actions.test.ts index b109ee37..6bc21247 100644 --- a/src/main/runtime/overlay-visibility-actions.test.ts +++ b/src/main/runtime/overlay-visibility-actions.test.ts @@ -8,9 +8,12 @@ import { test('set visible overlay handler forwards dependencies to core', () => { const calls: string[] = []; let warmupStarts = 0; + let currentVisible = false; const setVisible = createSetVisibleOverlayVisibleHandler({ + getVisibleOverlayVisible: () => currentVisible, setVisibleOverlayVisibleCore: (options) => { calls.push(`core:${options.visible}`); + currentVisible = options.visible; options.setVisibleOverlayVisibleState(options.visible); options.updateVisibleOverlayVisibility(); }, @@ -25,6 +28,10 @@ test('set visible overlay handler forwards dependencies to core', () => { assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']); assert.equal(warmupStarts, 1); + setVisible(true); + assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']); + assert.equal(warmupStarts, 1); + setVisible(false); assert.equal(warmupStarts, 1); }); diff --git a/src/main/runtime/overlay-visibility-actions.ts b/src/main/runtime/overlay-visibility-actions.ts index 092dee8f..343579c1 100644 --- a/src/main/runtime/overlay-visibility-actions.ts +++ b/src/main/runtime/overlay-visibility-actions.ts @@ -1,4 +1,5 @@ export function createSetVisibleOverlayVisibleHandler(deps: { + getVisibleOverlayVisible?: () => boolean; setVisibleOverlayVisibleCore: (options: { visible: boolean; setVisibleOverlayVisibleState: (visible: boolean) => void; @@ -9,6 +10,9 @@ export function createSetVisibleOverlayVisibleHandler(deps: { onVisibleOverlayEnabled?: () => void; }) { return (visible: boolean): void => { + if (deps.getVisibleOverlayVisible?.() === visible) { + return; + } if (visible) { deps.onVisibleOverlayEnabled?.(); } diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index f15bb400..fcef4244 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -21,6 +21,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb getLastKnownWindowsForegroundProcessName: () => 'mpv', getWindowsOverlayProcessName: () => 'subminer', getWindowsFocusHandoffGraceActive: () => true, + getMacOSForegroundProbeActive: () => true, getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown) => { trackerNotReadyWarningShown = shown; @@ -47,6 +48,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true); + assert.equal(deps.getMacOSForegroundProbeActive?.(), true); assert.equal(deps.getTrackerNotReadyWarningShown(), false); deps.setTrackerNotReadyWarningShown(true); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index 2445256d..90cfca06 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -17,6 +17,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( deps.getLastKnownWindowsForegroundProcessName?.() ?? null, getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null, getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false, + getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false, getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), updateVisibleOverlayBounds: (geometry: WindowGeometry) => diff --git a/src/main/runtime/overlay-visibility-runtime.test.ts b/src/main/runtime/overlay-visibility-runtime.test.ts index 56b499ce..b3da9115 100644 --- a/src/main/runtime/overlay-visibility-runtime.test.ts +++ b/src/main/runtime/overlay-visibility-runtime.test.ts @@ -27,6 +27,8 @@ test('overlay visibility runtime wires set/toggle handlers through composed deps runtime.setVisibleOverlayVisible(true); assert.equal(visible, true); + runtime.setVisibleOverlayVisible(true); + assert.equal(setVisibleCoreCalls, 1); runtime.toggleVisibleOverlay(); assert.equal(visible, false); diff --git a/src/main/runtime/overlay-visibility-runtime.ts b/src/main/runtime/overlay-visibility-runtime.ts index 87e78b52..e17ef7c5 100644 --- a/src/main/runtime/overlay-visibility-runtime.ts +++ b/src/main/runtime/overlay-visibility-runtime.ts @@ -22,9 +22,10 @@ export type OverlayVisibilityRuntimeDeps = { }; export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) { - const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler( - deps.setVisibleOverlayVisibleDeps, - )(); + const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler({ + ...deps.setVisibleOverlayVisibleDeps, + getVisibleOverlayVisible: deps.getVisibleOverlayVisible, + })(); const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler( setVisibleOverlayVisibleMainDeps, ); diff --git a/src/main/runtime/subsync-open.test.ts b/src/main/runtime/subsync-open.test.ts index 24ad84ab..bbaa457b 100644 --- a/src/main/runtime/subsync-open.test.ts +++ b/src/main/runtime/subsync-open.test.ts @@ -4,6 +4,7 @@ import { openSubsyncManualModal } from './subsync-open'; import type { SubsyncManualPayload } from '../../types'; const payload: SubsyncManualPayload = { + ffsubsyncAvailable: true, sourceTracks: [{ id: 2, label: 'External #2 - eng' }], }; diff --git a/src/main/runtime/subtitle-prefetch-runtime.test.ts b/src/main/runtime/subtitle-prefetch-runtime.test.ts index 0a022b1e..1d2481f7 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.test.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.test.ts @@ -100,3 +100,33 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track assert.deepEqual(calls, []); }); + +test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => { + let extracted = false; + const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ + getFfmpegPath: () => 'ffmpeg-custom', + extractInternalSubtitleTrack: async () => { + extracted = true; + return { + path: '/tmp/subminer-sidebar-123/track_7.ass', + cleanup: async () => {}, + }; + }, + }); + + const resolved = await resolveSource({ + currentExternalFilenameRaw: null, + currentTrackRaw: { + type: 'sub', + id: 3, + 'ff-index': 7, + codec: 'ass', + }, + trackListRaw: [], + sidRaw: 3, + videoPath: 'http://jellyfin.local/Videos/movie/stream?static=true', + }); + + assert.equal(resolved, null); + assert.equal(extracted, false); +}); diff --git a/src/main/runtime/subtitle-prefetch-runtime.ts b/src/main/runtime/subtitle-prefetch-runtime.ts index 99a113dc..443bb3e8 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.ts @@ -28,6 +28,15 @@ function parseTrackId(value: unknown): number | null { return null; } +function isRemoteMediaPath(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + function getActiveSubtitleTrack( currentTrackRaw: unknown, trackListRaw: unknown, @@ -104,6 +113,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: { return { path: externalFilename, sourceKey: externalFilename }; } + if (isRemoteMediaPath(input.videoPath)) { + return null; + } + const extracted = await deps.extractInternalSubtitleTrack( deps.getFfmpegPath(), input.videoPath, diff --git a/src/main/runtime/tray-lifecycle.test.ts b/src/main/runtime/tray-lifecycle.test.ts index 594e7efc..279adc7a 100644 --- a/src/main/runtime/tray-lifecycle.test.ts +++ b/src/main/runtime/tray-lifecycle.test.ts @@ -43,6 +43,63 @@ test('ensure tray updates menu when tray already exists', () => { assert.deepEqual(calls, ['set-menu']); }); +test('ensure tray refreshes existing tray menu on linux with setContextMenu', () => { + const calls: string[] = []; + let trayRef: unknown = { + setContextMenu: () => calls.push('old-set-menu'), + setToolTip: () => calls.push('old-set-tooltip'), + on: () => calls.push('old-bind-click'), + destroy: () => calls.push('old-destroy'), + }; + + const ensureTray = createEnsureTrayHandler({ + getTray: () => trayRef as never, + setTray: (tray) => { + trayRef = tray; + calls.push(tray ? 'set-new-tray' : 'clear-tray'); + }, + buildTrayMenu: () => ({ id: 'menu' }), + resolveTrayIconPath: () => '/tmp/icon.png', + createImageFromPath: () => + ({ + isEmpty: () => false, + resize: (options: { width: number; height: number }) => { + calls.push(`resize:${options.width}x${options.height}`); + return { + isEmpty: () => false, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }; + }, + setTemplateImage: () => {}, + }) as never, + createEmptyImage: () => + ({ + isEmpty: () => true, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }) as never, + createTray: () => + ({ + setContextMenu: () => calls.push('new-set-menu'), + setToolTip: () => calls.push('new-set-tooltip'), + on: () => calls.push('new-bind-click'), + destroy: () => calls.push('new-destroy'), + }) as never, + trayTooltip: 'SubMiner', + platform: 'linux', + logWarn: () => calls.push('warn'), + ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'), + }); + + ensureTray(); + assert.deepEqual(calls, ['old-set-menu']); +}); + test('ensure tray creates new tray and binds click handler', () => { const calls: string[] = []; let trayRef: unknown = null; diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index a3f37872..e36d0113 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => { let initialized = false; const buildTemplate = createBuildTrayMenuTemplateHandler({ buildTrayMenuTemplateRuntime: (handlers) => { + calls.push(`platform:${handlers.platform}`); handlers.openSessionHelp(); handlers.openTexthookerInBrowser(); calls.push(`show-texthooker:${handlers.showTexthookerPage}`); @@ -50,7 +51,7 @@ test('build tray template handler wires actions and init guards', () => { handlers.openYomitanSettings(); handlers.openConfigSettings(); handlers.openJellyfinSetup(); - handlers.toggleJellyfinDiscovery(); + handlers.toggleJellyfinDiscovery(true); handlers.openAnilistSetup(); handlers.checkForUpdates(); handlers.quitApp(); @@ -72,9 +73,10 @@ test('build tray template handler wires actions and init guards', () => { openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, - toggleJellyfinDiscovery: async () => { - calls.push('jellyfin-discovery'); + toggleJellyfinDiscovery: async (checked) => { + calls.push(`jellyfin-discovery:${checked}`); }, + platform: 'linux', openAnilistSetupWindow: () => calls.push('anilist'), checkForUpdates: () => calls.push('updates'), quitApp: () => calls.push('quit'), @@ -83,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => { const template = buildTemplate(); assert.deepEqual(template, [{ label: 'ok' }]); assert.deepEqual(calls, [ + 'platform:linux', 'init', 'help', 'texthooker', @@ -92,7 +95,7 @@ test('build tray template handler wires actions and init guards', () => { 'yomitan', 'configuration', 'jellyfin', - 'jellyfin-discovery', + 'jellyfin-discovery:true', 'anilist', 'updates', 'quit', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 94552903..e60d4db3 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -37,6 +37,7 @@ export function shouldShowTexthookerTrayEntry(config: { export function createBuildTrayMenuTemplateHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { + platform?: string; openSessionHelp: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; @@ -49,7 +50,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; - toggleJellyfinDiscovery: () => void; + toggleJellyfinDiscovery: (checked: boolean) => void; openAnilistSetup: () => void; checkForUpdates: () => void; quitApp: () => void; @@ -67,13 +68,15 @@ export function createBuildTrayMenuTemplateHandler(deps: { openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; - toggleJellyfinDiscovery: () => void | Promise; + toggleJellyfinDiscovery: (checked: boolean) => void | Promise; + platform?: string; openAnilistSetupWindow: () => void; checkForUpdates: () => void; quitApp: () => void; }) { return (): TMenuItem[] => { return deps.buildTrayMenuTemplateRuntime({ + platform: deps.platform, openSessionHelp: () => { if (!deps.isOverlayRuntimeInitialized()) { deps.initializeOverlayRuntime(); @@ -103,8 +106,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { }, showJellyfinDiscovery: deps.isJellyfinConfigured(), jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(), - toggleJellyfinDiscovery: () => { - void deps.toggleJellyfinDiscovery(); + toggleJellyfinDiscovery: (checked) => { + void deps.toggleJellyfinDiscovery(checked); }, openAnilistSetup: () => { deps.openAnilistSetupWindow(); diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 8206c999..8188018f 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -35,15 +35,18 @@ test('tray main deps builders return mapped handlers', () => { openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, - toggleJellyfinDiscovery: () => { - calls.push('jellyfin-discovery'); + toggleJellyfinDiscovery: (checked) => { + calls.push(`jellyfin-discovery:${checked}`); }, + platform: 'linux', openAnilistSetupWindow: () => calls.push('anilist'), checkForUpdates: () => calls.push('updates'), quitApp: () => calls.push('quit'), })(); + assert.equal(menuDeps.platform, 'linux'); const template = menuDeps.buildTrayMenuTemplateRuntime({ + platform: menuDeps.platform, openSessionHelp: () => calls.push('open-help'), openTexthookerInBrowser: () => calls.push('open-texthooker'), showTexthookerPage: true, @@ -56,7 +59,7 @@ test('tray main deps builders return mapped handlers', () => { openJellyfinSetup: () => calls.push('open-jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, - toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'), + toggleJellyfinDiscovery: (checked) => calls.push(`open-jellyfin-discovery:${checked}`), openAnilistSetup: () => calls.push('open-anilist'), checkForUpdates: () => calls.push('open-updates'), quitApp: () => calls.push('quit-app'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index bea49cd4..f318843b 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -27,6 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: { export function createBuildTrayMenuTemplateMainDepsHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { + platform?: string; openSessionHelp: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; @@ -39,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; - toggleJellyfinDiscovery: () => void; + toggleJellyfinDiscovery: (checked: boolean) => void; openAnilistSetup: () => void; checkForUpdates: () => void; quitApp: () => void; @@ -57,13 +58,15 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; - toggleJellyfinDiscovery: () => void | Promise; + toggleJellyfinDiscovery: (checked: boolean) => void | Promise; + platform?: string; openAnilistSetupWindow: () => void; checkForUpdates: () => void; quitApp: () => void; }) { return () => ({ buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime, + platform: deps.platform, initializeOverlayRuntime: deps.initializeOverlayRuntime, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, openSessionHelpModal: deps.openSessionHelpModal, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index f79ae128..74182f6c 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -41,7 +41,7 @@ test('tray menu template contains expected entries and handlers', () => { openJellyfinSetup: () => calls.push('jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, - toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'), + toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`), openAnilistSetup: () => calls.push('anilist'), checkForUpdates: () => calls.push('updates'), quitApp: () => calls.push('quit'), @@ -60,7 +60,7 @@ test('tray menu template contains expected entries and handlers', () => { const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery'); assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.checked, false); - discovery?.click?.(); + discovery?.click?.({ checked: true }); template[0]!.click?.(); assert.equal(template[1]!.label, 'Open Texthooker'); template[1]!.click?.(); @@ -70,7 +70,7 @@ test('tray menu template contains expected entries and handlers', () => { template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); template[11]!.click?.(); assert.deepEqual(calls, [ - 'jellyfin-discovery', + 'jellyfin-discovery:true', 'help', 'texthooker', 'updates', @@ -155,3 +155,29 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.checked, true); }); + +test('tray menu template renders a visible linux discovery check mark when active', () => { + const template = buildTrayMenuTemplateRuntime({ + platform: 'linux', + openSessionHelp: () => undefined, + openTexthookerInBrowser: () => undefined, + showTexthookerPage: true, + openFirstRunSetup: () => undefined, + showFirstRunSetup: false, + openWindowsMpvLauncherSetup: () => undefined, + showWindowsMpvLauncherSetup: false, + openYomitanSettings: () => undefined, + openConfigSettings: () => undefined, + openJellyfinSetup: () => undefined, + showJellyfinDiscovery: true, + jellyfinDiscoveryActive: true, + toggleJellyfinDiscovery: () => undefined, + openAnilistSetup: () => undefined, + checkForUpdates: () => undefined, + quitApp: () => undefined, + }); + + const discovery = template.find((entry) => entry.label === '✓ Jellyfin Discovery'); + assert.equal(discovery?.type, 'checkbox'); + assert.equal(discovery?.checked, true); +}); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index cbb40844..ba71c5a0 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -30,6 +30,7 @@ export function resolveTrayIconPathRuntime(deps: { } export type TrayMenuActionHandlers = { + platform?: string; openSessionHelp: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; @@ -42,19 +43,28 @@ export type TrayMenuActionHandlers = { openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; - toggleJellyfinDiscovery: () => void; + toggleJellyfinDiscovery: (checked: boolean) => void; openAnilistSetup: () => void; checkForUpdates: () => void; quitApp: () => void; }; +type TrayMenuClickItem = { + checked?: boolean; +}; + export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{ label?: string; type?: 'separator' | 'checkbox'; checked?: boolean; enabled?: boolean; - click?: () => void; + click?: (menuItem?: TrayMenuClickItem) => void; }> { + const jellyfinDiscoveryLabel = + handlers.platform === 'linux' && handlers.jellyfinDiscoveryActive + ? '✓ Jellyfin Discovery' + : 'Jellyfin Discovery'; + return [ { label: 'Open Help', @@ -99,11 +109,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): ...(handlers.showJellyfinDiscovery ? [ { - label: 'Jellyfin Discovery', + label: jellyfinDiscoveryLabel, type: 'checkbox' as const, checked: handlers.jellyfinDiscoveryActive, enabled: true, - click: handlers.toggleJellyfinDiscovery, + click: (menuItem?: TrayMenuClickItem) => { + const checked = + typeof menuItem?.checked === 'boolean' + ? menuItem.checked + : !handlers.jellyfinDiscoveryActive; + handlers.toggleJellyfinDiscovery(checked); + }, }, ] : []), diff --git a/src/main/runtime/update/update-dialogs.test.ts b/src/main/runtime/update/update-dialogs.test.ts index 5672a137..49b4a9c8 100644 --- a/src/main/runtime/update/update-dialogs.test.ts +++ b/src/main/runtime/update/update-dialogs.test.ts @@ -28,6 +28,57 @@ test('update dialog presenter focuses app and yields the run loop before showing assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']); }); +test('update dialog presenter suspends stats window layer while showing dialogs', async () => { + const calls: string[] = []; + const showMessageBox: ShowMessageBox = async (options) => { + calls.push(`dialog:${options.message}`); + return { response: 0 }; + }; + const presenter = createUpdateDialogPresenter({ + platform: 'linux', + withStatsWindowLayerSuspended: async (showDialog) => { + calls.push('suspend-stats-window'); + try { + return await showDialog(); + } finally { + calls.push('restore-stats-window'); + } + }, + showMessageBox, + }); + + await presenter.showNoUpdateDialog('0.14.0'); + + assert.deepEqual(calls, [ + 'suspend-stats-window', + 'dialog:SubMiner is up to date (v0.14.0)', + 'restore-stats-window', + ]); +}); + +test('update dialog presenter restores stats window layer when dialog fails', async () => { + const calls: string[] = []; + const presenter = createUpdateDialogPresenter({ + platform: 'linux', + withStatsWindowLayerSuspended: async (showDialog) => { + calls.push('suspend-stats-window'); + try { + return await showDialog(); + } finally { + calls.push('restore-stats-window'); + } + }, + showMessageBox: async () => { + calls.push('dialog'); + throw new Error('dialog failed'); + }, + }); + + await assert.rejects(() => presenter.showNoUpdateDialog('0.14.0'), /dialog failed/); + + assert.deepEqual(calls, ['suspend-stats-window', 'dialog', 'restore-stats-window']); +}); + test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => { const calls: string[] = []; const showMessageBox: ShowMessageBox = async (options) => { diff --git a/src/main/runtime/update/update-dialogs.ts b/src/main/runtime/update/update-dialogs.ts index f5ce4f88..261d3ac4 100644 --- a/src/main/runtime/update/update-dialogs.ts +++ b/src/main/runtime/update/update-dialogs.ts @@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps { showMessageBox: ShowMessageBox; focusApp?: () => void | Promise; yieldToRunLoop?: () => Promise; + withStatsWindowLayerSuspended?: (showDialog: () => Promise) => Promise; platform?: NodeJS.Platform; } @@ -46,12 +47,18 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise< export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) { const showFocusedMessageBox: ShowMessageBox = async (options) => { - try { - await maybeFocusAppForDialog(deps); - } catch { - // Best-effort focus only; never block the dialog itself. - } - return deps.showMessageBox(options); + const showDialog = async (): Promise => { + try { + await maybeFocusAppForDialog(deps); + } catch { + // Best-effort focus only; never block the dialog itself. + } + return deps.showMessageBox(options); + }; + + return deps.withStatsWindowLayerSuspended + ? deps.withStatsWindowLayerSuspended(showDialog) + : showDialog(); }; return { diff --git a/src/main/runtime/update/update-service.test.ts b/src/main/runtime/update/update-service.test.ts index bd9a1225..29709ee0 100644 --- a/src/main/runtime/update/update-service.test.ts +++ b/src/main/runtime/update/update-service.test.ts @@ -361,3 +361,34 @@ test('manual prerelease update check uses prerelease release and launcher channe 'restart-dialog', ]); }); + +test('manual update check keeps current prerelease builds on configured stable channel', async () => { + const { deps, calls } = createDeps({ + getCurrentVersion: () => '0.15.0-beta.3', + checkAppUpdate: async (channel) => { + calls.push(`app:${channel}`); + return { available: false, version: '0.15.0-beta.3' }; + }, + fetchLatestStableRelease: async (channel) => { + calls.push(`fetch:${channel}`); + return { + tag_name: 'v0.14.0', + prerelease: false, + draft: false, + assets: [], + }; + }, + showNoUpdateDialog: async (version) => { + calls.push(`no-update:${version}`); + }, + showUpdateAvailableDialog: async () => { + throw new Error('unexpected update dialog'); + }, + }); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.equal(result.status, 'up-to-date'); + assert.deepEqual(calls, ['app:stable', 'fetch:stable', 'no-update:0.15.0-beta.3']); +}); diff --git a/src/preload-stats.ts b/src/preload-stats.ts index 136890ad..8e2b5a31 100644 --- a/src/preload-stats.ts +++ b/src/preload-stats.ts @@ -43,6 +43,18 @@ const statsAPI = { hideOverlay: (): void => { ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay); }, + + confirmNativeDialog: (message: string): boolean => { + return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true; + }, + + beginNativeDialog: (): void => { + ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeDialogOpened); + }, + + endNativeDialog: (): void => { + ipcRenderer.send(IPC_CHANNELS.command.statsNativeDialogClosed); + }, }; contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI }); diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index e3b88908..443e2171 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -1008,6 +1008,38 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', } }); +test('visible-layer configured overlay toggle dispatches mpv plugin toggle', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.toggleVisibleOverlayGlobal', + originalKey: 'Alt+Shift+O', + key: { code: 'KeyO', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'toggleVisibleOverlay', + }, + ] as never); + + testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', altKey: true, shiftKey: true }); + + assert.equal( + testGlobals.mpvCommands.some( + (command) => command[0] === 'script-message' && command[1] === 'subminer-toggle', + ), + true, + ); + assert.equal( + testGlobals.sessionActions.some((action) => action.actionId === 'toggleVisibleOverlay'), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index cefdd995..0da7cbaf 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -207,6 +207,11 @@ export function createKeyboardHandlers( return; } + if (binding.actionType === 'session-action' && binding.actionId === 'toggleVisibleOverlay') { + window.electronAPI.sendMpvCommand(['script-message', 'subminer-toggle']); + return; + } + if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') { options.openControllerSelectModal?.(); return; diff --git a/src/renderer/modals/subsync.test.ts b/src/renderer/modals/subsync.test.ts index bb642724..b2211422 100644 --- a/src/renderer/modals/subsync.test.ts +++ b/src/renderer/modals/subsync.test.ts @@ -83,6 +83,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag const subsyncEngineFfsubsync = { checked: false, + disabled: false, addEventListener: engineFfsubsyncEvents.addEventListener, dispatch: engineFfsubsyncEvents.dispatch, }; @@ -194,6 +195,7 @@ test('manual subsync failure closes during run, then reopens modal with error', harness.modal.wireDomEvents(); harness.modal.openSubsyncModal({ sourceTracks: [{ id: 2, label: 'External #2 - eng' }], + ffsubsyncAvailable: true, }); harness.runButton.dispatch('click'); @@ -224,3 +226,47 @@ test('manual subsync failure closes during run, then reopens modal with error', harness.restoreGlobals(); } }); + +test('subsync modal disables ffsubsync when payload marks it unavailable', () => { + const harness = createTestHarness(async () => ({ ok: true, message: 'ok' })); + + try { + harness.modal.openSubsyncModal({ + sourceTracks: [{ id: 2, label: 'External #2 - eng' }], + ffsubsyncAvailable: false, + }); + + assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true); + assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false); + assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true); + assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.'); + } finally { + harness.restoreGlobals(); + } +}); + +test('subsync modal ignores enter submission when no sync engine is available', async () => { + let runCalls = 0; + const harness = createTestHarness(async () => { + runCalls += 1; + return { ok: true, message: 'ok' }; + }); + + try { + harness.modal.openSubsyncModal({ + sourceTracks: [], + ffsubsyncAvailable: false, + }); + + harness.modal.handleSubsyncKeydown({ + key: 'Enter', + preventDefault: () => {}, + } as KeyboardEvent); + await flushMicrotasks(); + + assert.equal(runCalls, 0); + assert.equal(harness.ctx.state.subsyncModalOpen, true); + } finally { + harness.restoreGlobals(); + } +}); diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts index 21fdc7ae..9812df96 100644 --- a/src/renderer/modals/subsync.ts +++ b/src/renderer/modals/subsync.ts @@ -8,6 +8,8 @@ export function createSubsyncModal( syncSettingsModalSubtitleSuppression: () => void; }, ) { + let ffsubsyncAvailable = true; + function setSubsyncStatus(message: string, isError = false): void { ctx.dom.subsyncStatus.textContent = message; ctx.dom.subsyncStatus.classList.toggle('error', isError); @@ -46,20 +48,26 @@ export function createSubsyncModal( function openSubsyncModal(payload: SubsyncManualPayload): void { ctx.state.subsyncSubmitting = false; - ctx.dom.subsyncRunButton.disabled = false; ctx.state.subsyncSourceTracks = payload.sourceTracks; + ffsubsyncAvailable = payload.ffsubsyncAvailable; const hasSources = ctx.state.subsyncSourceTracks.length > 0; ctx.dom.subsyncEngineAlass.checked = hasSources; - ctx.dom.subsyncEngineFfsubsync.checked = !hasSources; + ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable; + ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable; + ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable; renderSubsyncSourceTracks(); updateSubsyncSourceVisibility(); setSubsyncStatus( - hasSources - ? 'Choose engine and source, then run.' - : 'No source subtitles available for alass. Use ffsubsync.', + !ffsubsyncAvailable && hasSources + ? 'Choose alass source, then run.' + : !ffsubsyncAvailable + ? 'No source subtitles available for alass.' + : hasSources + ? 'Choose engine and source, then run.' + : 'No source subtitles available for alass. Use ffsubsync.', false, ); @@ -77,7 +85,7 @@ export function createSubsyncModal( sourceTrackId: number | null, message: string, ): void { - openSubsyncModal({ sourceTracks }); + openSubsyncModal({ sourceTracks, ffsubsyncAvailable }); if (engine === 'alass' && sourceTracks.length > 0) { ctx.dom.subsyncEngineAlass.checked = true; @@ -85,7 +93,7 @@ export function createSubsyncModal( if (Number.isFinite(sourceTrackId)) { ctx.dom.subsyncSourceSelect.value = String(sourceTrackId); } - } else { + } else if (ffsubsyncAvailable) { ctx.dom.subsyncEngineAlass.checked = false; ctx.dom.subsyncEngineFfsubsync.checked = true; } @@ -97,8 +105,16 @@ export function createSubsyncModal( async function runSubsyncManualFromModal(): Promise { if (ctx.state.subsyncSubmitting) return; + if (ctx.dom.subsyncRunButton.disabled) return; - const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync'; + const useAlass = ctx.dom.subsyncEngineAlass.checked; + const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked; + if (!useAlass && !useFfsubsync) { + setSubsyncStatus('No sync engine available for current media.', true); + return; + } + + const engine = useAlass ? 'alass' : 'ffsubsync'; const sourceTrackId = engine === 'alass' && ctx.dom.subsyncSourceSelect.value ? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10) diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index af3d4bf4..721c3463 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -8,6 +8,7 @@ import { createSubtitleSidebarModal, findActiveSubtitleCueIndex, } from './subtitle-sidebar.js'; +import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; function createClassList(initialTokens: string[] = []) { const tokens = new Set(initialTokens); @@ -1542,6 +1543,137 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async ( } }); +test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains open', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + const mpvCommands: Array> = []; + const contentListeners = new Map Promise | void>>(); + const windowListeners = new Map Promise | void>>(); + + const snapshot: SubtitleSidebarSnapshot = { + cues: [{ startTime: 1, endTime: 2, text: 'first' }], + currentSubtitle: { + text: 'first', + startTime: 1, + endTime: 2, + }, + currentTimeSec: 1.1, + config: { + enabled: true, + autoOpen: false, + layout: 'overlay', + toggleKey: 'Backslash', + pauseVideoOnHover: true, + autoScroll: true, + maxWidth: 420, + opacity: 0.92, + backgroundColor: 'rgba(54, 58, 79, 0.88)', + textColor: '#cad3f5', + fontFamily: '"Iosevka Aile", sans-serif', + fontSize: 17, + timestampColor: '#a5adcb', + activeLineColor: '#f5bde6', + activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)', + hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)', + }, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => Promise | void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + removeEventListener: () => {}, + electronAPI: { + getSubtitleSidebarSnapshot: async () => snapshot, + getPlaybackPaused: async () => false, + sendMpvCommand: (command: Array) => { + mpvCommands.push(command); + }, + } as unknown as ElectronAPI, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createCueRow(), + body: { + classList: createClassList(), + }, + documentElement: { + style: { + setProperty: () => {}, + }, + }, + }, + }); + + try { + const state = createRendererState(); + state.autoPauseVideoOnYomitanPopup = true; + const ctx = { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: createClassList(['hidden']), + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + addEventListener: (type: string, listener: () => Promise | void) => { + const bucket = contentListeners.get(type) ?? []; + bucket.push(listener); + contentListeners.set(type, bucket); + }, + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + state, + }; + + const modal = createSubtitleSidebarModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + }); + modal.wireDomEvents(); + + await modal.openSubtitleSidebarModal(); + mpvCommands.length = 0; + await contentListeners.get('mouseenter')?.[0]?.(); + + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + + for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + await listener(); + } + await contentListeners.get('mouseleave')?.[0]?.(); + + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + assert.equal(state.subtitleSidebarPausedByHover, true); + + for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + await listener(); + } + + assert.deepEqual(mpvCommands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); + assert.equal(state.subtitleSidebarPausedByHover, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 9f766f52..57e4d6b1 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -1,6 +1,11 @@ import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; +import { + YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_SHOWN_EVENT, + isYomitanPopupVisible, +} from '../yomitan-popup.js'; const MANUAL_SCROLL_HOLD_MS = 1500; const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18; @@ -194,6 +199,8 @@ export function createSubtitleSidebarModal( let disposeDomEvents: (() => void) | null = null; let subtitleSidebarHovered = false; let subtitleSidebarFocusedWithin = false; + let subtitleSidebarYomitanPopupVisible = false; + let subtitleSidebarPauseHeldByYomitanPopup = false; function restoreEmbeddedSidebarPassthrough(): void { syncOverlayMouseIgnoreState(ctx); @@ -323,18 +330,65 @@ export function createSubtitleSidebarModal( return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`; } + function isYomitanPopupVisibleForSidebar(): boolean { + if (subtitleSidebarYomitanPopupVisible || ctx.state.yomitanPopupVisible) { + return true; + } + if (typeof document === 'undefined') { + return false; + } + return isYomitanPopupVisible(document); + } + + function shouldHoldSidebarPauseForYomitanPopup(): boolean { + return ( + ctx.state.autoPauseVideoOnYomitanPopup && + ctx.state.subtitleSidebarPausedByHover && + isYomitanPopupVisibleForSidebar() + ); + } + function resumeSubtitleSidebarHoverPause(): void { subtitleSidebarHoverRequestId += 1; if (!ctx.state.subtitleSidebarPausedByHover) { + subtitleSidebarPauseHeldByYomitanPopup = false; restoreEmbeddedSidebarPassthrough(); return; } + if (shouldHoldSidebarPauseForYomitanPopup()) { + subtitleSidebarPauseHeldByYomitanPopup = true; + restoreEmbeddedSidebarPassthrough(); + return; + } + + subtitleSidebarPauseHeldByYomitanPopup = false; ctx.state.subtitleSidebarPausedByHover = false; window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']); restoreEmbeddedSidebarPassthrough(); } + function handleYomitanPopupShown(): void { + subtitleSidebarYomitanPopupVisible = true; + if (ctx.state.autoPauseVideoOnYomitanPopup && ctx.state.subtitleSidebarPausedByHover) { + subtitleSidebarPauseHeldByYomitanPopup = true; + } + } + + function handleYomitanPopupHidden(): void { + subtitleSidebarYomitanPopupVisible = false; + if (!subtitleSidebarPauseHeldByYomitanPopup) { + return; + } + + subtitleSidebarPauseHeldByYomitanPopup = false; + if (ctx.state.isOverSubtitleSidebar) { + restoreEmbeddedSidebarPassthrough(); + return; + } + resumeSubtitleSidebarHoverPause(); + } + function maybeAutoScrollActiveCue( previousActiveCueIndex: number, behavior: ScrollBehavior = 'smooth', @@ -660,8 +714,12 @@ export function createSubtitleSidebarModal( syncEmbeddedSidebarLayout(); }; window.addEventListener('resize', resizeHandler); + window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown); + window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden); disposeDomEvents = () => { window.removeEventListener('resize', resizeHandler); + window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown); + window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden); disposeDomEvents = null; }; } diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 3f880cc4..6167eb3e 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -27,6 +27,9 @@ export const IPC_CHANNELS = { toggleDevTools: 'toggle-dev-tools', toggleOverlay: 'toggle-overlay', saveSubtitlePosition: 'save-subtitle-position', + statsNativeConfirmDialog: 'stats:native-confirm-dialog', + statsNativeDialogOpened: 'stats:native-dialog-opened', + statsNativeDialogClosed: 'stats:native-dialog-closed', saveControllerConfig: 'save-controller-config', saveControllerPreference: 'save-controller-preference', setMecabEnabled: 'set-mecab-enabled', diff --git a/src/types/config.ts b/src/types/config.ts index a73abc89..42288bd5 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -302,14 +302,10 @@ export interface ResolvedConfig { serverUrl: string; recentServers: string[]; username: string; - deviceId: string; - clientName: string; - clientVersion: string; defaultLibraryId: string; remoteControlEnabled: boolean; remoteControlAutoConnect: boolean; autoAnnounce: boolean; - remoteControlDeviceName: string; pullPictures: boolean; iconCacheDir: string; directPlayPreferred: boolean; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index d0e18a55..340395a3 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -87,14 +87,10 @@ export interface JellyfinConfig { serverUrl?: string; recentServers?: string[]; username?: string; - deviceId?: string; - clientName?: string; - clientVersion?: string; defaultLibraryId?: string; remoteControlEnabled?: boolean; remoteControlAutoConnect?: boolean; autoAnnounce?: boolean; - remoteControlDeviceName?: string; pullPictures?: boolean; iconCacheDir?: string; directPlayPreferred?: boolean; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 4a1bb0ae..80ea121a 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -72,6 +72,7 @@ export interface SubsyncSourceTrack { export interface SubsyncManualPayload { sourceTracks: SubsyncSourceTrack[]; + ffsubsyncAvailable: boolean; } export interface SubsyncManualRunRequest { diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index 15b5d9f5..8947dd4b 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -50,6 +50,10 @@ export abstract class BaseWindowTracker { abstract start(): void; abstract stop(): void; + refreshNow(): Promise { + return Promise.resolve(); + } + getGeometry(): WindowGeometry | null { return this.currentGeometry; } diff --git a/src/window-trackers/macos-tracker.test.ts b/src/window-trackers/macos-tracker.test.ts index c7dafade..3de7ca31 100644 --- a/src/window-trackers/macos-tracker.test.ts +++ b/src/window-trackers/macos-tracker.test.ts @@ -359,6 +359,35 @@ test('MacOSWindowTracker marks target unfocused on explicit inactive helper sign assert.deepEqual(focusChanges, [true, false]); }); +test('MacOSWindowTracker refreshNow immediately samples frontmost mpv state', async () => { + let callIndex = 0; + const outputs = [ + { stdout: '10,20,1280,720,0', stderr: '' }, + { stdout: 'active', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + }); + + await (tracker as unknown as { refreshNow: () => Promise }).refreshNow(); + assert.equal(tracker.isTargetWindowFocused(), false); + + await (tracker as unknown as { refreshNow: () => Promise }).refreshNow(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); +}); + test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => { let callIndex = 0; const outputs = [ diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index 761f1500..45556854 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -196,7 +196,7 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | export class MacOSWindowTracker extends BaseWindowTracker { private pollTimeout: ReturnType | null = null; - private pollInFlight = false; + private pollInFlightPromise: Promise | null = null; private started = false; private helperPath: string | null = null; private helperType: 'binary' | 'swift' | null = null; @@ -357,7 +357,7 @@ export class MacOSWindowTracker extends BaseWindowTracker { return; } this.started = true; - this.pollGeometry(); + void this.pollGeometry(); } stop(): void { @@ -365,6 +365,11 @@ export class MacOSWindowTracker extends BaseWindowTracker { this.clearScheduledPoll(); } + override refreshNow(): Promise { + this.clearScheduledPoll(); + return this.pollGeometry(); + } + override isTargetWindowMinimized(): boolean { return this.targetWindowMinimized; } @@ -443,13 +448,19 @@ export class MacOSWindowTracker extends BaseWindowTracker { }, this.resolveNextPollIntervalMs()); } - private pollGeometry(): void { - if (this.pollInFlight || !this.helperPath || !this.helperType) { - return; + private pollGeometry(): Promise { + if (this.pollInFlightPromise) { + return this.pollInFlightPromise; + } + if (!this.helperPath || !this.helperType) { + return Promise.resolve(); } - this.pollInFlight = true; - void this.runHelper(this.helperPath, this.helperType, this.targetMpvSocketPath) + this.pollInFlightPromise = this.runHelper( + this.helperPath, + this.helperType, + this.targetMpvSocketPath, + ) .then(({ stdout }) => { const parsed = parseMacOSHelperOutput(stdout || ''); if (parsed) { @@ -495,8 +506,9 @@ export class MacOSWindowTracker extends BaseWindowTracker { this.registerTrackingMiss(); }) .finally(() => { - this.pollInFlight = false; + this.pollInFlightPromise = null; this.scheduleNextPoll(); }); + return this.pollInFlightPromise; } } diff --git a/stats/src/App.tsx b/stats/src/App.tsx index e70e0968..e8eab628 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -1,4 +1,5 @@ import { Suspense, lazy, useCallback, useState } from 'react'; +import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog'; import { TabBar } from './components/layout/TabBar'; import { OverviewTab } from './components/overview/OverviewTab'; import { useExcludedWords } from './hooks/useExcludedWords'; @@ -272,6 +273,7 @@ export function App() { /> ) : null} + ); } diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index 408b79d5..01ff837e 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -85,7 +85,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) }, [videoId]); const handleDeleteSession = async (sessionId: number) => { - if (!confirmSessionDelete()) return; + if (!(await confirmSessionDelete())) return; await apiClient.deleteSession(sessionId); setData((prev) => { if (!prev) return prev; diff --git a/stats/src/components/anime/EpisodeList.tsx b/stats/src/components/anime/EpisodeList.tsx index 8a2da53c..f9e11ff2 100644 --- a/stats/src/components/anime/EpisodeList.tsx +++ b/stats/src/components/anime/EpisodeList.tsx @@ -44,7 +44,7 @@ export function EpisodeList({ }; const handleDeleteEpisode = async (videoId: number, title: string) => { - if (!confirmEpisodeDelete(title)) return; + if (!(await confirmEpisodeDelete(title))) return; await apiClient.deleteVideo(videoId); setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId)); if (expandedVideoId === videoId) setExpandedVideoId(null); diff --git a/stats/src/components/layout/DeleteConfirmDialog.test.tsx b/stats/src/components/layout/DeleteConfirmDialog.test.tsx new file mode 100644 index 00000000..7a96fecd --- /dev/null +++ b/stats/src/components/layout/DeleteConfirmDialog.test.tsx @@ -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\) => \{(?[\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);'), + ); +}); diff --git a/stats/src/components/layout/DeleteConfirmDialog.tsx b/stats/src/components/layout/DeleteConfirmDialog.tsx new file mode 100644 index 00000000..e708c8f8 --- /dev/null +++ b/stats/src/components/layout/DeleteConfirmDialog.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { setDeleteConfirmPresenter } from '../../lib/delete-confirm'; + +interface PendingDeleteConfirm { + message: string; + resolve: (confirmed: boolean) => void; +} + +export function DeleteConfirmDialog() { + const [pendingConfirm, setPendingConfirm] = useState(null); + const pendingRef = useRef(null); + const cancelButtonRef = useRef(null); + + const finish = useCallback((confirmed: boolean) => { + const pending = pendingRef.current; + pendingRef.current = null; + setPendingConfirm(null); + pending?.resolve(confirmed); + }, []); + + useEffect(() => { + return setDeleteConfirmPresenter( + (message) => + new Promise((resolve) => { + pendingRef.current?.resolve(false); + const next = { message, resolve }; + pendingRef.current = next; + setPendingConfirm(next); + }), + ); + }, []); + + useEffect(() => { + if (!pendingConfirm) return; + cancelButtonRef.current?.focus(); + }, [pendingConfirm]); + + useEffect(() => { + if (!pendingConfirm) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + finish(false); + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, [finish, pendingConfirm]); + + useEffect(() => { + return () => { + pendingRef.current?.resolve(false); + pendingRef.current = null; + }; + }, []); + + if (!pendingConfirm) return null; + + return ( +
+
+
+

+ Delete? +

+
+
+ {pendingConfirm.message} +
+
+ + +
+
+
+ ); +} diff --git a/stats/src/components/library/MediaDetailView.test.tsx b/stats/src/components/library/MediaDetailView.test.tsx index 6a8c47a9..9230f398 100644 --- a/stats/src/components/library/MediaDetailView.test.tsx +++ b/stats/src/components/library/MediaDetailView.test.tsx @@ -125,3 +125,40 @@ test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () => await handler(); 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((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); +}); diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx index 8c07a286..3e5d6d8c 100644 --- a/stats/src/components/library/MediaDetailView.tsx +++ b/stats/src/components/library/MediaDetailView.tsx @@ -11,7 +11,7 @@ interface DeleteEpisodeHandlerOptions { videoId: number; title: string; apiClient: { deleteVideo: (id: number) => Promise }; - confirmFn: (title: string) => boolean; + confirmFn: (title: string) => boolean | Promise; onBack: () => void; setDeleteError: (msg: string | null) => void; /** @@ -27,8 +27,19 @@ interface DeleteEpisodeHandlerOptions { export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise { return async () => { if (opts.isDeletingRef?.current) return; - if (!opts.confirmFn(opts.title)) return; 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.setDeleteError(null); try { @@ -73,6 +84,7 @@ export function MediaDetailView({ const [deletingSessionId, setDeletingSessionId] = useState(null); const [isDeletingEpisode, setIsDeletingEpisode] = useState(false); const isDeletingEpisodeRef = useRef(false); + const isDeletingSessionRef = useRef(false); useEffect(() => { setLocalSessions(data?.sessions ?? null); @@ -101,7 +113,20 @@ export function MediaDetailView({ const relatedCollectionLabel = getRelatedCollectionLabel(detail); const handleDeleteSession = async (session: SessionSummary) => { - if (!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); setDeletingSessionId(session.sessionId); @@ -114,6 +139,7 @@ export function MediaDetailView({ setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); } finally { setDeletingSessionId(null); + isDeletingSessionRef.current = false; } }; diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx index 83cf834a..f2b95811 100644 --- a/stats/src/components/overview/OverviewTab.tsx +++ b/stats/src/components/overview/OverviewTab.tsx @@ -47,7 +47,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov }, []); const handleDeleteSession = async (session: SessionSummary) => { - if (!confirmSessionDelete()) return; + if (!(await confirmSessionDelete())) return; setDeleteError(null); setDeletingIds((prev) => new Set(prev).add(session.sessionId)); try { @@ -65,7 +65,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov }; const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => { - if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return; + if (!(await confirmDayGroupDelete(dayLabel, daySessions.length))) return; setDeleteError(null); const ids = daySessions.map((s) => s.sessionId); setDeletingIds((prev) => { @@ -91,7 +91,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => { const title = groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media'; - if (!confirmAnimeGroupDelete(title, groupSessions.length)) return; + if (!(await confirmAnimeGroupDelete(title, groupSessions.length))) return; setDeleteError(null); const ids = groupSessions.map((s) => s.sessionId); setDeletingIds((prev) => { diff --git a/stats/src/components/sessions/SessionsTab.test.tsx b/stats/src/components/sessions/SessionsTab.test.tsx index ebf17334..956a49bc 100644 --- a/stats/src/components/sessions/SessionsTab.test.tsx +++ b/stats/src/components/sessions/SessionsTab.test.tsx @@ -125,6 +125,34 @@ test('buildBucketDeleteHandler reports errors via onError without calling onSucc 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 () => { let seenTitle: string | null = null; diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index c35e8c98..d4cd8593 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -27,7 +27,7 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map Promise }; - confirm: (title: string, count: number) => boolean; + confirm: (title: string, count: number) => boolean | Promise; onSuccess: (deletedIds: number[]) => void; onError: (message: string) => void; } @@ -43,8 +43,8 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise< return async () => { const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; const ids = bucket.sessions.map((s) => s.sessionId); - if (!confirm(title, ids.length)) return; try { + if (!(await confirm(title, ids.length))) return; await client.deleteSessions(ids); onSuccess(ids); } catch (err) { @@ -120,7 +120,14 @@ export function SessionsTab({ }; const handleDeleteSession = async (session: SessionSummary) => { - if (!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); setDeletingSessionId(session.sessionId); diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 585d19db..cbd57454 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -5,9 +5,10 @@ import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete, + setDeleteConfirmPresenter, } from './delete-confirm'; -test('confirmSessionDelete uses the shared session delete warning copy', () => { +test('confirmSessionDelete uses the shared session delete warning copy', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -16,14 +17,183 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => { }) as typeof globalThis.confirm; try { - assert.equal(confirmSessionDelete(), true); + assert.equal(await confirmSessionDelete(), true); assert.deepEqual(calls, ['Delete this session and all associated data?']); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmDayGroupDelete includes the day label and count in the warning copy', () => { +test('confirmSessionDelete suspends stats overlay layering around native confirm', async () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + const originalElectronAPI = ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = { + stats: { + beginNativeDialog: () => calls.push('begin-native-dialog'), + endNativeDialog: () => calls.push('end-native-dialog'), + }, + }; + globalThis.confirm = ((message?: string) => { + calls.push(`confirm:${message ?? ''}`); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(await confirmSessionDelete(), true); + assert.deepEqual(calls, [ + 'begin-native-dialog', + 'confirm:Delete this session and all associated data?', + 'end-native-dialog', + ]); + } finally { + globalThis.confirm = originalConfirm; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = originalElectronAPI; + } +}); + +test('confirmSessionDelete uses parented Electron confirm when available', async () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + const originalElectronAPI = ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = { + stats: { + confirmNativeDialog: (message) => { + calls.push(`native-confirm:${message}`); + return false; + }, + beginNativeDialog: () => calls.push('begin-native-dialog'), + endNativeDialog: () => calls.push('end-native-dialog'), + }, + }; + globalThis.confirm = ((message?: string) => { + calls.push(`browser-confirm:${message ?? ''}`); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(await confirmSessionDelete(), false); + assert.deepEqual(calls, ['native-confirm:Delete this session and all associated data?']); + } finally { + globalThis.confirm = originalConfirm; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = originalElectronAPI; + } +}); + +test('confirmSessionDelete uses the registered stats presenter before native or browser confirm', async () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + const originalElectronAPI = ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + }; + }; + } + ).electronAPI; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + }; + }; + } + ).electronAPI = { + stats: { + confirmNativeDialog: (message) => { + calls.push(`native-confirm:${message}`); + return true; + }, + }, + }; + globalThis.confirm = ((message?: string) => { + calls.push(`browser-confirm:${message ?? ''}`); + return true; + }) as typeof globalThis.confirm; + + const unregister = setDeleteConfirmPresenter(async (message) => { + calls.push(`presenter:${message}`); + return false; + }); + + try { + assert.equal(await confirmSessionDelete(), false); + assert.deepEqual(calls, ['presenter:Delete this session and all associated data?']); + } finally { + unregister(); + globalThis.confirm = originalConfirm; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + }; + }; + } + ).electronAPI = originalElectronAPI; + } +}); + +test('confirmDayGroupDelete includes the day label and count in the warning copy', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -32,14 +202,14 @@ test('confirmDayGroupDelete includes the day label and count in the warning copy }) as typeof globalThis.confirm; try { - assert.equal(confirmDayGroupDelete('Today', 3), true); + assert.equal(await confirmDayGroupDelete('Today', 3), true); assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmDayGroupDelete uses singular for one session', () => { +test('confirmDayGroupDelete uses singular for one session', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -48,14 +218,14 @@ test('confirmDayGroupDelete uses singular for one session', () => { }) as typeof globalThis.confirm; try { - assert.equal(confirmDayGroupDelete('Yesterday', 1), true); - assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']); + assert.equal(await confirmDayGroupDelete('Yesterday', 1), true); + assert.deepEqual(calls, ['Delete this session from Yesterday and all associated data?']); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { +test('confirmBucketDelete asks about merging multiple sessions of the same episode', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -64,7 +234,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo }) as typeof globalThis.confirm; try { - assert.equal(confirmBucketDelete('My Episode', 3), true); + assert.equal(await confirmBucketDelete('My Episode', 3), true); assert.deepEqual(calls, [ 'Delete all 3 sessions of "My Episode" from this day and all associated data?', ]); @@ -73,7 +243,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo } }); -test('confirmBucketDelete uses a clean singular form for one session', () => { +test('confirmBucketDelete uses a clean singular form for one session', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -82,7 +252,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => { }) as typeof globalThis.confirm; try { - assert.equal(confirmBucketDelete('Solo Episode', 1), false); + assert.equal(await confirmBucketDelete('Solo Episode', 1), false); assert.deepEqual(calls, [ 'Delete this session of "Solo Episode" from this day and all associated data?', ]); @@ -91,7 +261,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => { } }); -test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => { +test('confirmEpisodeDelete includes the episode title in the shared warning copy', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -100,7 +270,7 @@ test('confirmEpisodeDelete includes the episode title in the shared warning copy }) as typeof globalThis.confirm; try { - assert.equal(confirmEpisodeDelete('Episode 4'), false); + assert.equal(await confirmEpisodeDelete('Episode 4'), false); assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']); } finally { globalThis.confirm = originalConfirm; diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index 137e3996..5480437a 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -1,30 +1,76 @@ -export function confirmSessionDelete(): boolean { - return globalThis.confirm('Delete this session and all associated data?'); +type NativeDialogBridge = { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; +}; + +type DeleteConfirmPresenter = (message: string) => boolean | Promise; + +let deleteConfirmPresenter: DeleteConfirmPresenter | null = null; + +export function setDeleteConfirmPresenter(presenter: DeleteConfirmPresenter): () => void { + deleteConfirmPresenter = presenter; + return () => { + if (deleteConfirmPresenter === presenter) { + deleteConfirmPresenter = null; + } + }; } -export function confirmDayGroupDelete(dayLabel: string, count: number): boolean { - return globalThis.confirm( - `Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`, +async function confirmWithStatsNativeDialogLayer(message: string): Promise { + if (deleteConfirmPresenter) { + return deleteConfirmPresenter(message); + } + + const statsApi = (globalThis as typeof globalThis & NativeDialogBridge).electronAPI?.stats; + if (statsApi?.confirmNativeDialog) { + return statsApi.confirmNativeDialog(message); + } + + statsApi?.beginNativeDialog?.(); + try { + return globalThis.confirm(message); + } finally { + statsApi?.endNativeDialog?.(); + } +} + +export function confirmSessionDelete(): Promise { + return confirmWithStatsNativeDialogLayer('Delete this session and all associated data?'); +} + +export function confirmDayGroupDelete(dayLabel: string, count: number): Promise { + if (count === 1) { + return confirmWithStatsNativeDialogLayer( + `Delete this session from ${dayLabel} and all associated data?`, + ); + } + return confirmWithStatsNativeDialogLayer( + `Delete all ${count} sessions from ${dayLabel} and all associated data?`, ); } -export function confirmAnimeGroupDelete(title: string, count: number): boolean { - return globalThis.confirm( +export function confirmAnimeGroupDelete(title: string, count: number): Promise { + return confirmWithStatsNativeDialogLayer( `Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`, ); } -export function confirmEpisodeDelete(title: string): boolean { - return globalThis.confirm(`Delete "${title}" and all its sessions?`); +export function confirmEpisodeDelete(title: string): Promise { + return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`); } -export function confirmBucketDelete(title: string, count: number): boolean { +export function confirmBucketDelete(title: string, count: number): Promise { if (count === 1) { - return globalThis.confirm( + return confirmWithStatsNativeDialogLayer( `Delete this session of "${title}" from this day and all associated data?`, ); } - return globalThis.confirm( + return confirmWithStatsNativeDialogLayer( `Delete all ${count} sessions of "${title}" from this day and all associated data?`, ); } diff --git a/stats/src/lib/ipc-client.ts b/stats/src/lib/ipc-client.ts index 096b02d1..ae141037 100644 --- a/stats/src/lib/ipc-client.ts +++ b/stats/src/lib/ipc-client.ts @@ -62,6 +62,9 @@ interface StatsElectronAPI { ankiBrowse: (noteId: number) => Promise; ankiNotesInfo: (noteIds: number[]) => Promise; hideOverlay: () => void; + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; }; }