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-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-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/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 6d1782e6..2a60b24f 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -1,192 +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, - "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, 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. By default, Jellyfin sees the cast target as the OS hostname (`uname -n` on Linux). 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/] `. -- When SubMiner auto-launches mpv for Jellyfin playback, it injects the bundled mpv plugin unless an installed SubMiner mpv plugin is already present. This keeps mpv-side keybindings available without clicking the overlay first. -- Jellyfin playback shows the SubMiner visible overlay before selecting subtitle tracks, so `subtitleStyle` controls the rendered subtitle appearance. Use the overlay toggle shortcut if you want to hide it for a session. - -## 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/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 8f281a05..6d1ee1b2 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -85,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") @@ -179,12 +183,21 @@ function M.create(ctx) 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.app_managed_playback_active then + return + end if state.overlay_running and not state.suppress_ready_overlay_restore @@ -208,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 @@ -227,6 +245,8 @@ function M.create(ctx) 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() @@ -252,6 +272,7 @@ function M.create(ctx) state.pending_reload_media_identity = nil state.pending_reload_media_title = nil state.pending_reload_reason = nil + 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 28a01ab6..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(...) @@ -23,6 +24,10 @@ function M.create(ctx) 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 250d3838..214d92d9 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -102,6 +102,46 @@ function M.create(ctx) 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 @@ -247,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 @@ -481,12 +521,10 @@ function M.create(ctx) disarm_auto_play_ready_gate() end local visibility_action = resolve_auto_start_visibility_action() - if visibility_action ~= nil then - run_control_command_async(visibility_action, { - socket_path = socket_path, - log_level = overrides.log_level, - }) - end + run_visibility_action_if_needed(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) return end subminer_log("info", "process", "Overlay already running") @@ -526,6 +564,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, @@ -552,12 +591,10 @@ function M.create(ctx) if overrides.auto_start_trigger == true then local visibility_action = resolve_auto_start_visibility_action() - if visibility_action ~= nil then - run_control_command_async(visibility_action, { - socket_path = socket_path, - log_level = overrides.log_level, - }) - end + run_visibility_action_if_needed(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) end end) diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 58fdbdfd..bdfc9d74 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -42,6 +42,8 @@ function M.new() 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/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index ca6bfc98..9ccccf5d 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -714,7 +714,7 @@ do fire_event(recorded, "start-file") fire_event(recorded, "file-loaded") assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + 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( @@ -752,13 +752,13 @@ do "duplicate same-tick visible overlay toggles should hide once" ) assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + 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") == 2, + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, "later visible overlay toggle should still show after duplicate suppression window" ) end @@ -844,7 +844,7 @@ do "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") == 1, + 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( @@ -853,6 +853,68 @@ 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 = "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 = "", @@ -1102,8 +1164,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 @@ -1498,8 +1560,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), @@ -1530,8 +1592,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, @@ -1566,8 +1628,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") == 3, - "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, @@ -1628,8 +1690,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, @@ -1663,8 +1725,8 @@ do recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]() assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, - "duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands" + 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 @@ -1729,7 +1791,7 @@ do ) 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( @@ -1764,7 +1826,7 @@ 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 @@ -1794,7 +1856,7 @@ do fire_event(recorded, "file-loaded") 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 duplicate auto-start visible overlay reassertion" ) assert_true( @@ -1829,7 +1891,7 @@ do fire_event(recorded, "end-file", { reason = "redirect" }) fire_event(recorded, "file-loaded") assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0, "manual toggle-off should suppress same-media reload visible overlay reassertion" ) assert_true( @@ -1868,7 +1930,7 @@ do fire_event(recorded, "start-file") fire_event(recorded, "file-loaded") assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + 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( @@ -2042,8 +2104,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/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-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 1bcc56d8..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 { @@ -570,7 +571,7 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu 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 da9b0b12..a09577e6 100644 --- a/src/core/services/jellyfin.ts +++ b/src/core/services/jellyfin.ts @@ -233,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()}`; } 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/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/main.ts b/src/main.ts index 2263744b..3f3c5862 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,7 +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 } from './main/runtime/jellyfin-remote-playback'; +import { + markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState, + shouldAutoLoadSecondarySubTrackForJellyfinPlayback, +} from './main/runtime/jellyfin-remote-playback'; import { getAppControlSocketPath } from './shared/app-control'; import { type CancelLinuxMpvFullscreenOverlayRefreshBurst, @@ -322,6 +325,7 @@ import { listJellyfinItemsRuntime, listJellyfinLibrariesRuntime, listJellyfinSubtitleTracksRuntime, + loadJellyfinSubtitleDelay, loadSubtitlePosition as loadSubtitlePositionCore, loadYomitanExtension as loadYomitanExtensionCore, markLastCardAsAudioCard as markLastCardAsAudioCardCore, @@ -332,6 +336,7 @@ import { replayCurrentSubtitleRuntime, resolveJellyfinPlaybackPlanRuntime, runStartupBootstrapRuntime, + saveJellyfinSubtitleDelay, saveSubtitlePosition as saveSubtitlePositionCore, addYomitanNoteViaSearch, clearYomitanParserCachesForWindow, @@ -667,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; @@ -2135,6 +2142,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({ @@ -2942,6 +2950,23 @@ const { 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); }, @@ -3005,6 +3030,7 @@ const { getActivePlayback: () => activeJellyfinRemotePlayback, clearActivePlayback: () => { activeJellyfinRemotePlayback = null; + activeJellyfinSubtitleDelayKey = null; }, getSession: () => appState.jellyfinRemoteSession, getNow: () => Date.now(), @@ -4426,6 +4452,7 @@ const { appState.activeParsedSubtitleSource = null; appState.activeParsedSubtitleMediaPath = null; } + activeJellyfinSubtitleDelayKey = null; broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); @@ -4533,6 +4560,8 @@ const { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { appState.reconnectTimer = timer; }, + shouldAutoLoadSecondarySubTrack: (path: string) => + shouldAutoLoadSecondarySubTrackForJellyfinPlayback(activeJellyfinRemotePlayback, path), shouldQuitOnMpvShutdown: () => shouldQuitOnMpvShutdownForTrayState({ managedPlayback: appState.initialArgs?.managedPlayback === true, @@ -5518,6 +5547,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), }); 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 451910e4..ceeed7a7 100644 --- a/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts @@ -25,7 +25,9 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { 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'), diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index d327a8bc..f6d2916c 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -77,7 +77,9 @@ test('playback handler drives mpv commands and playback state', async () => { 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>), @@ -94,12 +96,21 @@ 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', '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, 0); assert.equal( @@ -108,11 +119,11 @@ test('playback handler drives mpv commands and playback state', async () => { ); assert.ok(calls.includes('defaults')); - assert.ok(calls.includes('visible-overlay')); assert.ok( - calls.indexOf('visible-overlay') < calls.indexOf('preload'), - 'visible overlay should be shown before Jellyfin subtitles are selected', + 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')); @@ -120,6 +131,7 @@ 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); @@ -137,6 +149,249 @@ test('playback handler drives mpv commands and playback state', async () => { ]); }); +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({ @@ -264,11 +519,67 @@ 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 () => { @@ -311,7 +622,16 @@ test('playback handler does not let stats metadata failures block playback start itemId: 'item-3', }); - assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); + 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 () => { @@ -354,7 +674,16 @@ test('playback handler does not let media title failures block playback startup' itemId: 'item-4', }); - assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); + 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 () => { @@ -402,5 +731,14 @@ test('playback handler handles rejected best-effort hook promises', async () => await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); + 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 5783cca9..9b1ad344 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -16,6 +16,7 @@ type ActivePlaybackState = { playMethod: 'DirectPlay' | 'Transcode'; loadedMediaPath?: string | null; stopReportsAfterMs?: number; + lastKnownPositionSeconds?: number; }; export type JellyfinPlaybackStatsMetadata = { @@ -28,6 +29,14 @@ export type JellyfinPlaybackStatsMetadata = { 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(() => {}); @@ -36,6 +45,14 @@ function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void { } } +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 { @@ -51,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; @@ -72,7 +131,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { session: JellyfinAuthSession; clientInfo: JellyfinClientInfo; itemId: string; - }) => void; + }) => void | Promise<void>; setActivePlayback: (state: ActivePlaybackState) => void; setLastProgressAtMs: (value: number) => void; reportPlaying: (payload: { @@ -99,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(); @@ -120,7 +180,23 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.applyJellyfinMpvDefaults(mpvClient); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); - const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); + 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']); + 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(() => @@ -141,29 +217,24 @@ export function createPlayJellyfinItemInMpvHandler(deps: { subtitleStreamIndex: plan.subtitleStreamIndex, playMethod, loadedMediaPath: null, + lastKnownPositionSeconds: startSeconds > 0 ? startSeconds : undefined, }); deps.setLastProgressAtMs(0); - deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); + 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]); - deps.sendMpvCommand(['set_property', 'sid', 'no']); - - const startTimeTicks = - typeof params.startTimeTicksOverride === 'number' - ? Math.max(0, params.startTimeTicksOverride) - : plan.startTimeTicks; - if (startTimeTicks > 0) { - deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']); - } + await awaitBestEffortPlaybackHook(() => + deps.preloadExternalSubtitles({ + session: params.session, + clientInfo: params.clientInfo, + itemId: params.itemId, + }), + ); deps.showVisibleOverlay(); - deps.preloadExternalSubtitles({ - session: params.session, - clientInfo: params.clientInfo, - itemId: params.itemId, - }); deps.reportPlaying({ itemId: params.itemId, diff --git a/src/main/runtime/jellyfin-remote-commands.test.ts b/src/main/runtime/jellyfin-remote-commands.test.ts index 6bf69a43..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 () => { diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts index 09889876..5d50b26d 100644 --- a/src/main/runtime/jellyfin-remote-commands.ts +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -6,6 +6,7 @@ export type ActiveJellyfinRemotePlaybackState = { playMethod: 'DirectPlay' | 'Transcode'; loadedMediaPath?: string | null; stopReportsAfterMs?: number; + lastKnownPositionSeconds?: number; }; type JellyfinSession = { @@ -62,6 +63,7 @@ export type JellyfinRemotePlayHandlerDeps = { audioStreamIndex?: number; subtitleStreamIndex?: number; startTimeTicksOverride?: number; + fallbackToPlanStartTimeOnZeroOverride?: boolean; setQuitOnDisconnectArm?: boolean; }) => Promise<void>; logWarn: (message: string) => void; @@ -85,6 +87,10 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe 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, @@ -92,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-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index 338b9e1f..a99ce75b 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -5,6 +5,7 @@ import { createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, secondsToJellyfinTicks, + shouldAutoLoadSecondarySubTrackForJellyfinPlayback, } from './jellyfin-remote-playback'; test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => { @@ -13,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 }> = []; @@ -303,6 +337,48 @@ test('createReportJellyfinRemoteStoppedHandler reports stop while remote websock 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; @@ -336,6 +412,42 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback' assert.equal(cleared, false); }); +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', diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 0b68f579..b1367105 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -44,6 +44,21 @@ export function markJellyfinRemotePlaybackLoaded( } } +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; @@ -87,6 +102,29 @@ async function readMpvPositionSecondsOrFallback( } } +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, @@ -123,7 +161,9 @@ export function createReportJellyfinRemoteProgressHandler( const now = deps.getNow(); try { const mpvClient = deps.getMpvClient(); - const positionSeconds = await readMpvPositionSeconds(mpvClient); + const observedPositionSeconds = await readMpvPositionSeconds(mpvClient); + cacheLastKnownPosition(playback, observedPositionSeconds); + const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); const forceForSeekJump = isSeekLikePositionJump( lastReportedPositionSeconds, positionSeconds, @@ -184,11 +224,27 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto return; } try { - const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient()); + 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: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond), + positionTicks, failed: false, playMethod: playback.playMethod, audioStreamIndex: playback.audioStreamIndex, 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 f6f4d1b5..bb79a4de 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -19,6 +19,19 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy 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}`), })(); @@ -28,6 +41,21 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy 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', 'cache', 'cleanup', '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 ab558dd8..63cb0e8b 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -15,6 +15,19 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( 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 07e5f076..039d6d97 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -32,6 +32,14 @@ function makeDeps(overrides: { cleanupCachedSubtitles?: Parameters< typeof createPreloadJellyfinExternalSubtitlesHandler >[0]['cleanupCachedSubtitles']; + getSavedSubtitleDelay?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['getSavedSubtitleDelay']; + setActiveSubtitleDelayKey?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['setActiveSubtitleDelayKey']; + loadSubtitleSourceText?: (source: string) => Promise<string>; + saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void; logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug']; }) { return { @@ -46,10 +54,38 @@ function makeDeps(overrides: { 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<string | number>>, +): Array<Array<string | number>> { + 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')) + ), + ); +} + +function setPropertyCommandsExceptTrackAutoSelection( + commands: Array<Array<string | number>>, +): Array<Array<string | number>> { + 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<Array<string | number>> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( @@ -89,7 +125,7 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa await preload({ session, clientInfo, itemId: 'item-1' }); - assert.deepEqual(commands, [ + 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], @@ -97,6 +133,59 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa ]); }); +test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => { + const commands: Array<Array<string | number>> = []; + 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<Array<string | number>> = []; let requestCount = 0; @@ -140,13 +229,10 @@ test('preload jellyfin subtitles waits for delayed cached japanese track before await preload({ session, clientInfo, itemId: 'item-1' }); assert.equal(requestCount, 3); - assert.deepEqual( - commands.filter((command) => command[0] === 'set_property'), - [ - ['set_property', 'sid', 5], - ['set_property', 'secondary-sid', 6], - ], - ); + 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 () => { @@ -192,13 +278,286 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste await preload({ session, clientInfo, itemId: 'item-1' }); assert.equal(requestCount, 3); - assert.deepEqual( - commands.filter((command) => command[0] === 'set_property'), - [ - ['set_property', 'sid', 42], - ['set_property', 'secondary-sid', 43], - ], + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 42], + ['set_property', 'secondary-sid', 43], + ]); +}); + +test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese sources', async () => { + const commands: Array<Array<string | number>> = []; + 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<Array<string | number>> = []; + 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<Array<string | number>> = []; + 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<Array<string | number>> = []; + 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 () => { @@ -243,13 +602,10 @@ test('preload jellyfin subtitles accepts numeric string mpv track ids', async () await preload({ session, clientInfo, itemId: 'item-1' }); - assert.deepEqual( - commands.filter((command) => command[0] === 'set_property'), - [ - ['set_property', 'sid', 10], - ['set_property', 'secondary-sid', 11], - ], - ); + 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 () => { @@ -286,7 +642,7 @@ test('preload jellyfin subtitles retries transient mpv track-list read failures' await preload({ session, clientInfo, itemId: 'item-1' }); assert.equal(requestCount, 2); - assert.deepEqual(commands.at(-1), ['set_property', 'sid', 10]); + 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 () => { @@ -359,13 +715,71 @@ test('preload jellyfin subtitles does not let later subtitle adds steal japanese ['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'], ], ); - assert.deepEqual( - commands.filter((command) => command[0] === 'set_property'), - [['set_property', 'sid', 11]], + assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ + ['set_property', 'sid', 11], + ]); +}); + +test('preload jellyfin subtitles suppresses subtitle selection without disabling video auto selection', async () => { + const commands: Array<Array<string | number>> = []; + 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 leaves current track alone when reported japanese track never appears', async () => { +test('preload jellyfin subtitles does not select a missing japanese track', async () => { const commands: Array<Array<string | number>> = []; const logs: string[] = []; let requestCount = 0; @@ -390,7 +804,8 @@ test('preload jellyfin subtitles leaves current track alone when reported japane assert.equal(requestCount, 10); assert.equal( commands.some( - (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no', + (command) => + command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number', ), false, ); diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index ee3f4cbf..2acf35dd 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,6 +18,11 @@ type JellyfinSubtitleTrack = { index: number; language?: string; title?: string; + codec?: string; + isDefault?: boolean; + isForced?: boolean; + isExternal?: boolean; + deliveryMethod?: string; deliveryUrl?: string | null; }; @@ -27,6 +35,11 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & { source: JellyfinSubtitleTrack; }; +type JellyfinSubtitleDelayKey = { + itemId: string; + streamIndex: number; +}; + type MpvSubtitleTrack = { id: number; lang: string; @@ -130,6 +143,10 @@ function pickBestCachedTrackId( 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), }; @@ -138,6 +155,17 @@ function pickBestCachedTrackId( 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); } @@ -229,6 +257,54 @@ async function waitForPreferredSubtitleTracks( return subtitleTracks; } +async function estimateSubtitleDelayFromReference( + deps: { + loadSubtitleSourceText?: (source: string) => Promise<string>; + logDebug: (message: string, error: unknown) => void; + }, + primaryTrack: CachedExternalSubtitleTrack | null, + referenceTrack: CachedExternalSubtitleTrack | null, +): Promise<number | null> { + 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, @@ -240,11 +316,21 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { wait: (ms: number) => Promise<void>; cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>; cleanupCachedSubtitles: (dirs: string[]) => void; + getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null; + setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; + loadSubtitleSourceText?: (source: string) => Promise<string>; + saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; logDebug: (message: string, error: unknown) => void; }): PreloadJellyfinExternalSubtitlesHandler { const activeCacheDirs = new Set<string>(); let preloadQueue: Promise<void> = Promise.resolve(); + function resetManagedSubtitleDelay(): void { + if (deps.getSavedSubtitleDelay) { + deps.sendMpvCommand(['set_property', 'sub-delay', 0]); + } + } + function cleanupActiveCache(): void { const dirs = [...activeCacheDirs]; if (dirs.length === 0) return; @@ -275,6 +361,10 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { 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<string>(); const cachedTracks: CachedExternalSubtitleTrack[] = []; @@ -310,18 +400,55 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { return; } + const resolvedSubtitleTracks = subtitleTracks ?? []; const japanesePrimaryId = - pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ?? - pickBestTrackId(subtitleTracks ?? [], isJapanese); + 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 = - pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ?? - pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId); if (englishSecondaryId !== null) { deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); } 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<typeof setTimeout> | null; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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<typeof setTimeout> | null; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | 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/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.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, );