fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77)

* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb

- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies
- Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus
- Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles
- Mark ffsubsync unavailable in subsync modal for remote media paths
- Drain queued second-instance commands even when onReady throws

* fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause

- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open

* fix(jellyfin): fix discovery loop, device identity, tray state, and Disc

- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting

* docs(release): trim and consolidate prerelease notes for 0.15.0

- Remove breaking changes section and several redundant bullet points
- Consolidate per-platform updater notes into a single entry
- Normalize em-dash separators to hyphens in section headers

* fix(config): remove trailing commas from config.example.jsonc

- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl

* fix(tokenizer): preserve known-word highlight when POS filters suppress

- Known-word cache matches now set isKnown=true even for tokens excluded by POS filters
- POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate
- Jellyfin subtitle preload continues after cleanup failures instead of aborting
- Update config docs and option description to document the known-word bypass behavior

* fix(jellyfin): send explicit hide/show overlay instead of toggle

- Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known
- Prevent paused Jellyfin playback from resuming on overlay hide
- Fix subtitle cache cleanup to only remove dirs after successful cleanup

* fix(jellyfin): fix remote progress sync, seek reporting, and startup sto

- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events
- force immediate progress report on seek-like position jumps at the mpv time-pos level
- send positionTicks and failed=false in reportStopped payload
- remove EventName from HTTP timeline payloads (websocket-only field)
- add startup grace window to drop stop events before media finishes loading

* fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi

- Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift
- Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress
- Preserve manual hide across Jellyfin path-changing redirects even when media-title drops
- Rearm managed subtitle defaults on path-changing redirects
- Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC
- Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus
- Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary
- Add stats window layer management so delete/update dialogs appear above stats window
- Fix Jellyfin remote progress sync during Linux websocket reconnect windows

* Fix CodeRabbit review feedback

* fix(jellyfin): subtitle timing, resume progress, and overlay sync

- Add per-stream subtitle delay persistence and auto timeline-offset correction
- Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload
- Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports
- Keep Play vs Resume distinct to avoid early seek race on normal play
- Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress
- Deduplicate show/hide overlay commands using recorded visibility state
- Rewrite docs-site Jellyfin page around cast-to-device UX

* test: update lifecycle cleanup assertion

* fix: clear aborted playback state, fix overlay passthrough, and guard du

- Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item
- Record visible overlay action only after command succeeds, not before
- Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering)
- Defer activeParsedSubtitleMediaPath assignment until after prefetch completes
- Move autoplay gate release into the hide branch of toggleVisibleOverlay
- Clear active Jellyfin playback when stopping media that never loaded
- Reset managed subtitle delay and delay key when no external tracks are available
- Await async removeDir in subtitle cache cleanup
- Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs
- Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
This commit is contained in:
2026-05-24 18:40:56 -07:00
committed by GitHub
parent da3c971ee6
commit b1bdeabca8
193 changed files with 7975 additions and 771 deletions
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: settings
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
@@ -0,0 +1,4 @@
type: fixed
area: integrations
- Prevented Discord Rich Presence from falling back to Jellyfin stream URLs, and primed Jellyfin playback titles before loading tokenized streams so presence shows the show/episode title
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Prevented Jellyfin discovery playback from reloading the active item, misreporting paused mpv playback as still playing, retrying startup unpause after playback is paused again, unpausing after a manual `y-t` overlay toggle during startup, repeatedly restoring the overlay from duplicate ready signals, missing delayed Japanese subtitle selection on startup, letting later German/Russian subtitle loads steal the selected Japanese track, and spawning long-lived sidebar ffmpeg extractors against Jellyfin stream URLs.
+4
View File
@@ -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.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Derived Jellyfin cast device identity from the OS hostname, always reports the client as SubMiner, and ignores legacy configurable Jellyfin client/device identity fields so multiple SubMiner installs no longer share the same remote-session identity.
+11
View File
@@ -0,0 +1,11 @@
type: fixed
area: jellyfin
- Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes.
- Kept that manual hide sticky across Jellyfin stream redirects that change mpv's path, even when the redirected URL drops mpv's media title.
- Re-armed managed subtitle defaults during those path-changing redirects so Japanese primary subtitles can load on the redirected stream.
- Routed visible-overlay shortcuts and app-side visibility changes back through the mpv plugin so SubMiner overlay toggling stays independent of Jellyfin playback controls.
- Collapsed duplicate visible-overlay toggle events so Hyprland does not process one physical shortcut as hide-then-show.
- Kept passive Linux/Hyprland visible-overlay shows from taking keyboard focus away from mpv/Jellyfin.
- Made Jellyfin external subtitle selection tolerate transient mpv `track-list` read failures and numeric string track IDs so Japanese subtitles are selected after preload on Linux.
- Fixed AppImage-launched Jellyfin playback controls so mpv sends overlay commands to the running SubMiner app-control socket instead of the mounted Electron binary.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Grouped Jellyfin playback stats under Jellyfin item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names.
+4
View File
@@ -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.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Keep the Jellyfin discovery tray checkbox in sync on Linux after tray, CLI, or startup remote-session changes, with a visible check mark when Linux tray hosts ignore native checkbox rendering.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Restarted stale Jellyfin tray discovery sessions when the server no longer lists the SubMiner cast target, avoiding a needless Jellyfin re-login.
+5
View File
@@ -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.
@@ -1,4 +0,0 @@
type: changed
area: config
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Clarified that beta/RC update checks are controlled by `updates.channel`; set it to `"prerelease"` to receive beta/RC updates.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats: Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Kept Jellyfin picker library discovery working when the running app log level is above info.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Showed the visible subtitle overlay automatically during Jellyfin playback so configured `subtitleStyle` appearance applies to Jellyfin subtitles.
- Injected the bundled mpv plugin when SubMiner auto-launches mpv for Jellyfin playback, restoring mpv-side keybindings without needing overlay focus.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the macOS visible overlay stable when clicking from the overlay back into mpv.
@@ -1,7 +0,0 @@
type: changed
area: launcher
breaking: true
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: config
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
+1 -5
View File
@@ -523,7 +523,7 @@
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
@@ -644,14 +644,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+4 -6
View File
@@ -1045,6 +1045,7 @@ Known-word cache policy:
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes). - The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching. - Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
- Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility. - Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility.
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid. - Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. - If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
@@ -1253,7 +1254,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"directPlayPreferred": true, "directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
@@ -1268,21 +1268,17 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) |
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists |
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
@@ -1299,6 +1295,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session. When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
### Discord Rich Presence ### Discord Rich Presence
+90 -163
View File
@@ -1,191 +1,118 @@
# Jellyfin Integration # 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? ::: 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 This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end.
- 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
## Requirements ## Requirements
- Jellyfin server URL and user credentials - A Jellyfin server plus your username and password
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow) - SubMiner installed and running (see [Installation](/installation))
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override. - 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 ```jsonc
{ {
"jellyfin": { "jellyfin": {
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "your-user",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "",
"pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
"transcodeVideoCodec": "h264",
}, },
} }
``` ```
2. Authenticate: See [Configuration](/configuration) for the full list (transcode codec, direct-play containers, default library, and more).
## Troubleshooting
**SubMiner doesn't appear in the cast menu**
- Make sure SubMiner is running.
- Make sure you're signed in — reopen **Configure Jellyfin** and log in again if your token expired.
- Make sure discovery is on (tray **Jellyfin Discovery**, or **Remote Control Auto Connect** in settings).
- Make sure SubMiner and the Jellyfin client point at the same server.
**Casting starts but nothing plays**
- Confirm the item plays normally in another Jellyfin client.
- If mpv was closed, give it a moment — SubMiner launches it on demand and retries.
**SubMiner keeps disconnecting**
- Check server/network stability and whether the session token has expired.
## Security notes
- The Jellyfin session (access token + user ID) is kept in SubMiner's local encrypted token storage. Your password is used only to log in and is never saved.
- Treat the token storage and your `config.jsonc` as secrets — don't commit them.
- Advanced/headless: the `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` environment variables can supply a session without the sign-in window.
## Launcher playback
If you'd rather stay in the terminal, the `subminer` launcher can browse and play Jellyfin media directly, without casting from a Jellyfin app:
```bash ```bash
subminer jellyfin subminer jellyfin -p # alias: subminer jf -p
subminer jellyfin -l \
--server http://127.0.0.1:8096 \
--username your-user \
--password 'your-password'
``` ```
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. 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.
3. List libraries:
```bash
SubMiner.AppImage --jellyfin-libraries
```
Launcher wrapper equivalent for interactive playback flow:
```bash
subminer jellyfin -p
```
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
```bash
subminer jellyfin -d
```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app:
```bash
subminer app --stop
```
`subminer jf ...` is an alias for `subminer jellyfin ...`.
To clear saved session credentials:
```bash
subminer jellyfin --logout
```
4. List items in a library:
```bash
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
```
Optional listing controls:
- `--jellyfin-recursive=true|false` (default: true)
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
These are used by the launcher picker flow to:
- keep root search focused on shows/folders/movies (exclude episode rows)
- browse selected anime/show directories as folder-or-file lists
- recurse for playable files only after selecting a folder
5. Start playback:
```bash
SubMiner.AppImage --start
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID
```
Optional stream overrides:
- `--jellyfin-audio-stream-index N`
- `--jellyfin-subtitle-stream-index N`
## Playback Behavior
- Direct play is attempted first when:
- `jellyfin.directPlayPreferred=true`
- media source supports direct stream
- source container matches `jellyfin.directPlayContainers`
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
## 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.
+2
View File
@@ -164,6 +164,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
3. For alass, select a reference subtitle track from the video. 3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle. 4. SubMiner runs the sync and reloads the corrected subtitle.
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
## Texthooker ## Texthooker
+1 -5
View File
@@ -523,7 +523,7 @@
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
@@ -644,14 +644,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+1 -1
View File
@@ -114,7 +114,7 @@ Automatic checks log failures quietly so playback is not interrupted.
**"SubMiner is up to date" but a prerelease exists** **"SubMiner is up to date" but a prerelease exists**
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
**Launcher update shows a sudo command** **Launcher update shows a sudo command**
+16 -8
View File
@@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection(
fail('Selected Jellyfin item is not playable.'); fail('Selected Jellyfin item is not playable.');
} }
export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] {
const forwarded = [...appArgs];
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwarded.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwarded.push('--password-store', args.passwordStore);
}
if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) {
forwarded.push('--log-level', args.logLevel);
}
return forwarded;
}
async function runAppJellyfinListCommand( async function runAppJellyfinListCommand(
appPath: string, appPath: string,
args: Args, args: Args,
@@ -384,14 +399,7 @@ async function runAppJellyfinCommand(
appArgs: string[], appArgs: string[],
label: string, label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> { ): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = [...appArgs]; const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwardedBase.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwardedBase.push('--password-store', args.passwordStore);
}
const readLogAppendedSince = (offset: number): string => { const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath(); const logPath = getMpvLogPath();
+22
View File
@@ -17,6 +17,7 @@ import {
parseEpisodePathFromDisplay, parseEpisodePathFromDisplay,
buildRootSearchGroups, buildRootSearchGroups,
classifyJellyfinChildSelection, classifyJellyfinChildSelection,
buildForwardedJellyfinAppArgs,
} from './jellyfin.js'; } from './jellyfin.js';
type RunResult = { type RunResult = {
@@ -878,6 +879,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
]); ]);
}); });
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
const forwarded = buildForwardedJellyfinAppArgs(
{
jellyfinServer: 'https://jf.example.test/',
passwordStore: 'gnome-libsecret',
logLevel: 'info',
} as never,
['--jellyfin-libraries'],
);
assert.deepEqual(forwarded, [
'--jellyfin-libraries',
'--jellyfin-server',
'https://jf.example.test',
'--password-store',
'gnome-libsecret',
'--log-level',
'info',
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(` const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning [subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
+1
View File
@@ -819,6 +819,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
const configDir = path.join(dir, 'launcher-config'); const configDir = path.join(dir, 'launcher-config');
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
fs.mkdirSync(configDir, { recursive: true });
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log'); const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = []; const receivedControlArgv: string[][] = [];
+2 -2
View File
@@ -50,8 +50,8 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
+7
View File
@@ -114,6 +114,13 @@ function M.create(ctx)
end end
end end
if not environment.is_windows() then
local appimage_path = resolve_binary_candidate(os.getenv("APPIMAGE"))
if appimage_path and appimage_path ~= "" then
return appimage_path
end
end
return nil return nil
end end
+72 -1
View File
@@ -33,6 +33,20 @@ function M.create(ctx)
return nil return nil
end end
local function resolve_media_title()
local media_title = mp.get_property("media-title")
if type(media_title) == "string" and media_title ~= "" then
return media_title
end
local filename = mp.get_property("filename")
if type(filename) == "string" and filename ~= "" then
return filename
end
return nil
end
local function is_reload_end_file(reason) local function is_reload_end_file(reason)
return reason == "reload" or reason == "redirect" return reason == "reload" or reason == "redirect"
end end
@@ -71,6 +85,10 @@ function M.create(ctx)
if not has_matching_subminer_socket() then if not has_matching_subminer_socket() then
return false return false
end 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("sub-auto", "fuzzy")
mp.set_property_native("sid", "auto") mp.set_property_native("sid", "auto")
mp.set_property_native("secondary-sid", "auto") mp.set_property_native("secondary-sid", "auto")
@@ -125,6 +143,10 @@ function M.create(ctx)
local function on_start_file() local function on_start_file()
if state.pending_reload_media_identity ~= nil then if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
rearm_managed_subtitle_load_defaults()
end
return return
end end
rearm_managed_subtitle_load_defaults() rearm_managed_subtitle_load_defaults()
@@ -132,24 +154,56 @@ function M.create(ctx)
local function on_file_loaded() local function on_file_loaded()
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation() local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity local previous_media_identity = state.current_media_identity
local pending_reload_title = state.pending_reload_media_title
local pending_reload_reason = state.pending_reload_reason
local same_media_reload = ( local same_media_reload = (
media_identity ~= nil media_identity ~= nil
and state.pending_reload_media_identity ~= nil and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity and media_identity == state.pending_reload_media_identity
) or (
state.pending_reload_media_identity ~= nil
and media_title ~= nil
and pending_reload_title ~= nil
and media_title == pending_reload_title
) or (
pending_reload_reason == "redirect"
and state.pending_reload_media_identity ~= nil
) )
local same_media_loaded = ( local same_media_loaded = (
media_identity ~= nil media_identity ~= nil
and previous_media_identity ~= nil and previous_media_identity ~= nil
and media_identity == previous_media_identity and media_identity == previous_media_identity
) )
local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.current_media_identity = media_identity 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 if same_media_reload then
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then if state.app_managed_playback_active then
return
end
if
state.overlay_running
and not state.suppress_ready_overlay_restore
and resolve_auto_start_enabled()
and process.has_matching_mpv_ipc_socket(opts.socket_path)
then
process.run_control_command_async("show-visible-overlay", { process.run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path, socket_path = opts.socket_path,
}) })
@@ -167,6 +221,11 @@ function M.create(ctx)
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
end 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 if should_auto_start then
start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1) start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
return return
@@ -182,7 +241,12 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.app_managed_playback_pending = false
state.app_managed_playback_active = false
end end
local function register_lifecycle_hooks() local function register_lifecycle_hooks()
@@ -198,11 +262,18 @@ function M.create(ctx)
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
if is_reload_end_file(reason) then if is_reload_end_file(reason) then
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
state.pending_reload_media_title = state.current_media_title or resolve_media_title()
state.pending_reload_reason = reason
return return
end end
next_auto_start_retry_generation() next_auto_start_retry_generation()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.app_managed_playback_pending = false
state.app_managed_playback_active = false
if state.overlay_running and reason ~= "quit" then if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() process.hide_visible_overlay()
end end
+11
View File
@@ -6,6 +6,7 @@ function M.create(ctx)
local aniskip = ctx.aniskip local aniskip = ctx.aniskip
local hover = ctx.hover local hover = ctx.hover
local ui = ctx.ui local ui = ctx.ui
local state = ctx.state
local function register_script_messages() local function register_script_messages()
mp.register_script_message("subminer-start", function(...) mp.register_script_message("subminer-start", function(...)
@@ -17,6 +18,16 @@ function M.create(ctx)
mp.register_script_message("subminer-toggle", function() mp.register_script_message("subminer-toggle", function()
process.toggle_overlay() process.toggle_overlay()
end) end)
mp.register_script_message("subminer-visible-overlay-hidden", function()
process.record_visible_overlay_visibility(false)
end)
mp.register_script_message("subminer-visible-overlay-shown", function()
process.record_visible_overlay_visibility(true)
end)
mp.register_script_message("subminer-managed-subtitles-loading", function()
state.skip_managed_subtitle_rearm_once = true
state.app_managed_playback_pending = true
end)
mp.register_script_message("subminer-menu", function() mp.register_script_message("subminer-menu", function()
ui.show_menu() ui.show_menu()
end) end)
+140 -20
View File
@@ -7,6 +7,7 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -31,6 +32,16 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end end
local function resolve_auto_start_visibility_action()
if resolve_visible_overlay_startup() then
if state.suppress_ready_overlay_restore then
return nil
end
return "show-visible-overlay"
end
return "hide-visible-overlay"
end
local function resolve_pause_until_ready() local function resolve_pause_until_ready()
local raw_pause_until_ready = opts.auto_start_pause_until_ready local raw_pause_until_ready = opts.auto_start_pause_until_ready
if raw_pause_until_ready == nil then if raw_pause_until_ready == nil then
@@ -67,6 +78,89 @@ function M.create(ctx)
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
end end
local function record_visible_overlay_action(action)
if action == "show-visible-overlay" then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
elseif action == "hide-visible-overlay" then
state.visible_overlay_requested = false
elseif action == "toggle-visible-overlay" and state.visible_overlay_requested ~= nil then
state.visible_overlay_requested = not state.visible_overlay_requested
if state.visible_overlay_requested then
state.suppress_ready_overlay_restore = false
end
end
end
local function record_visible_overlay_visibility(visible)
if visible then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
return
end
state.visible_overlay_requested = false
state.suppress_ready_overlay_restore = true
end
local function record_start_visibility_args(args)
for _, arg in ipairs(args) do
if arg == "--show-visible-overlay" then
record_visible_overlay_action("show-visible-overlay")
return
end
if arg == "--hide-visible-overlay" then
record_visible_overlay_action("hide-visible-overlay")
return
end
end
end
local function should_run_visibility_action(action)
if action == "show-visible-overlay" and state.visible_overlay_requested == true then
return false
end
if action == "hide-visible-overlay" and state.visible_overlay_requested == false then
return false
end
return true
end
local function run_visibility_action_if_needed(action, overrides, callback)
if action == nil then
if callback then
callback(true)
end
return
end
if not should_run_visibility_action(action) then
subminer_log("debug", "process", "Skipping duplicate visible overlay action: " .. tostring(action))
if callback then
callback(true)
end
return
end
run_control_command_async(action, overrides, callback)
end
local function should_ignore_duplicate_visible_overlay_toggle()
if type(mp.get_time) ~= "function" then
return false
end
local now = mp.get_time()
if type(now) ~= "number" then
return false
end
local previous = state.last_visible_overlay_toggle_time
state.last_visible_overlay_toggle_time = now
if type(previous) ~= "number" then
return false
end
local elapsed = now - previous
return elapsed >= 0 and elapsed < DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS
end
local function normalize_socket_path(path) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -129,7 +223,7 @@ function M.create(ctx)
local function release_auto_play_ready_gate(reason) local function release_auto_play_ready_gate(reason)
if not state.auto_play_ready_gate_armed then if not state.auto_play_ready_gate_armed then
return return false
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
@@ -140,6 +234,7 @@ function M.create(ctx)
else else
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready")) subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
end end
return true
end end
local function arm_auto_play_ready_gate() local function arm_auto_play_ready_gate()
@@ -179,9 +274,12 @@ function M.create(ctx)
end end
local function notify_auto_play_ready() local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready") local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
local force_ready_overlay_restore = state.force_ready_overlay_restore == true local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false state.force_ready_overlay_restore = false
if not released_ready_gate and not force_ready_overlay_restore then
return
end
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
return return
end end
@@ -189,7 +287,7 @@ function M.create(ctx)
state.suppress_ready_overlay_restore = false state.suppress_ready_overlay_restore = false
end end
if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then 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, socket_path = opts.socket_path,
}) })
end end
@@ -224,7 +322,7 @@ function M.create(ctx)
local should_show_visible = overrides.show_visible_overlay local should_show_visible = overrides.show_visible_overlay
if should_show_visible == nil then if should_show_visible == nil then
should_show_visible = resolve_visible_overlay_startup() should_show_visible = resolve_visible_overlay_startup() and not state.suppress_ready_overlay_restore
end end
if should_show_visible then if should_show_visible then
table.insert(args, "--show-visible-overlay") table.insert(args, "--show-visible-overlay")
@@ -315,6 +413,9 @@ function M.create(ctx)
capture_stderr = true, capture_stderr = true,
}, function(success, result, error) }, function(success, result, error)
local ok = success and (result == nil or result.status == 0) local ok = success and (result == nil or result.status == 0)
if ok then
record_visible_overlay_action(action)
end
if callback then if callback then
callback(ok, result, error) callback(ok, result, error)
end end
@@ -399,9 +500,6 @@ function M.create(ctx)
local function start_overlay(overrides) local function start_overlay(overrides)
overrides = overrides or {} overrides = overrides or {}
if overrides.auto_start_trigger == true then
state.suppress_ready_overlay_restore = false
end
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
@@ -424,10 +522,8 @@ function M.create(ctx)
elseif not state.auto_play_ready_gate_armed then elseif not state.auto_play_ready_gate_armed then
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
end end
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_auto_start_visibility_action()
and "show-visible-overlay" run_visibility_action_if_needed(visibility_action, {
or "hide-visible-overlay"
run_control_command_async(visibility_action, {
socket_path = socket_path, socket_path = socket_path,
log_level = overrides.log_level, log_level = overrides.log_level,
}) })
@@ -470,6 +566,7 @@ function M.create(ctx)
state.overlay_running = true state.overlay_running = true
local command = build_subprocess_command(args) local command = build_subprocess_command(args)
record_start_visibility_args(args)
mp.command_native_async({ mp.command_native_async({
name = "subprocess", name = "subprocess",
args = command.args, args = command.args,
@@ -495,13 +592,11 @@ function M.create(ctx)
end end
if overrides.auto_start_trigger == true then if overrides.auto_start_trigger == true then
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_auto_start_visibility_action()
and "show-visible-overlay" run_visibility_action_if_needed(visibility_action, {
or "hide-visible-overlay" socket_path = socket_path,
run_control_command_async(visibility_action, { log_level = overrides.log_level,
socket_path = socket_path, })
log_level = overrides.log_level,
})
end end
end) end)
@@ -546,7 +641,8 @@ function M.create(ctx)
show_osd("Stopped") show_osd("Stopped")
end end
local function hide_visible_overlay() local function hide_visible_overlay(options)
options = options or {}
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
return return
@@ -566,7 +662,9 @@ function M.create(ctx)
end end
end) end)
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate({
resume_playback = options.resume_playback ~= false,
})
end end
local function toggle_overlay() local function toggle_overlay()
@@ -575,7 +673,28 @@ function M.create(ctx)
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
if should_ignore_duplicate_visible_overlay_toggle() then
subminer_log("debug", "process", "Ignoring duplicate visible overlay toggle")
return
end
if state.visible_overlay_requested == true then
state.suppress_ready_overlay_restore = true
hide_visible_overlay({ resume_playback = false })
return
end
if state.visible_overlay_requested == false then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("show-visible-overlay", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Show-visible-overlay command failed")
show_osd("Toggle failed")
end
end)
return
end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("toggle-visible-overlay", nil, function(ok) run_control_command_async("toggle-visible-overlay", nil, function(ok)
if not ok then if not ok then
@@ -705,6 +824,7 @@ function M.create(ctx)
build_command_args = build_command_args, build_command_args = build_command_args,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
record_visible_overlay_visibility = record_visible_overlay_visibility,
run_binary_command_async = run_binary_command_async, run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides, parse_start_script_message_overrides = parse_start_script_message_overrides,
ensure_texthooker_running = ensure_texthooker_running, ensure_texthooker_running = ensure_texthooker_running,
+5
View File
@@ -251,6 +251,11 @@ function M.create(ctx)
return return
end end
if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then
process.toggle_overlay()
return
end
invoke_cli_action(binding.actionId, binding.payload) invoke_cli_action(binding.actionId, binding.payload)
end end
+7
View File
@@ -35,8 +35,15 @@ function M.new()
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false, force_ready_overlay_restore = false,
visible_overlay_requested = nil,
last_visible_overlay_toggle_time = nil,
current_media_identity = nil, current_media_identity = nil,
current_media_title = nil,
pending_reload_media_identity = nil, pending_reload_media_identity = nil,
pending_reload_media_title = nil,
pending_reload_reason = nil,
app_managed_playback_pending = false,
app_managed_playback_active = false,
auto_start_retry_generation = 0, auto_start_retry_generation = 0,
session_binding_generation = 0, session_binding_generation = 0,
session_binding_names = {}, session_binding_names = {},
+4 -18
View File
@@ -1,10 +1,6 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights ## Highlights
### Breaking Changes
- **Settings Window:** The Configuration window is now called the Settings window everywhere — UI, tray menu, docs, and CLI. `--config` and `subminer config` (no action) are replaced by `--settings` and `subminer settings`; `subminer config` now only accepts `path` or `show`. The `--settings` alias that previously opened the Yomitan settings popup is removed — use `--yomitan` instead.
### Added ### Added
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only. - **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only.
@@ -17,16 +13,12 @@
### Changed ### Changed
- **Settings Window:** Option rows no longer display raw config paths; live/restart status is shown inline beside each option title. Known-words deck rows are now cards with the deck name on a separate header line so long names remain readable. Playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls have been reorganized.
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. - **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set. - **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set.
- **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow. - **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
- **Subsync:** Always opens the manual subtitle picker. The `subsync.defaultMode` config option has been removed.
- **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field. - **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field.
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry. - **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
@@ -47,17 +39,13 @@
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. - **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
- **Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. Update check traffic is routed through `/usr/bin/curl` to avoid Electron network-service crashes during video startup. - **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater, `subminer -u` can update independently of the tray app, macOS update dialogs come to the front reliably, unsupported builds show a manual-install message, and Windows keeps the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups now avoid Electron networking on Linux and macOS.
- **Updater — macOS:** Update dialogs now reliably come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native `electron-updater`/Squirrel path; supplemental GitHub release lookups are routed through `/usr/bin/curl`, eliminating the last Electron-networking path from background update checks. - **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed - both return control to the terminal without requiring Ctrl+C.
- **Updater — Windows:** Automatic updates keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch.
- **Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed — both return control to the terminal without requiring Ctrl+C.
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running. - **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
- **Launcher Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently. - **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
- **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. - **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends.
@@ -79,9 +67,7 @@
- **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. - **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`.
- **Config:** User config files are preserved during legacy compatibility handling. The note-fields note-type picker now defaults to the configured Anki deck's note type, falling back to `Kiku`, then `Lapis`, then blank for manual selection. - **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
- **Build — Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
### Docs ### Docs
+25
View File
@@ -68,6 +68,31 @@ local function create_binary_module(config)
return binary return binary
end end
do
local appimage_path = "/home/tester/.local/bin/SubMiner.AppImage"
local mounted_binary_path = "/tmp/.mount_SubMiner/SubMiner"
local resolved = with_env({
APPIMAGE = appimage_path,
}, function()
local binary = create_binary_module({
is_windows = false,
binary_path = mounted_binary_path,
entries = {
[appimage_path] = "file",
[mounted_binary_path] = "file",
},
})
return binary.find_binary()
end)
assert_equal(
resolved,
appimage_path,
"linux resolver should prefer APPIMAGE over the mounted AppImage inner binary"
)
end
do do
local binary = create_binary_module({ local binary = create_binary_module({
is_windows = true, is_windows = true,
+85 -4
View File
@@ -83,10 +83,16 @@ local process = process_module.create({
return true return true
end, end,
}, },
environment = { environment = {
detect_backend = function() detect_backend = function()
return "x11" return "x11"
end, end,
is_linux = function()
return false
end,
is_subminer_app_running_async = function(callback)
callback(false)
end,
}, },
options_helper = { options_helper = {
coerce_bool = function(value, default_value) coerce_bool = function(value, default_value)
@@ -125,4 +131,79 @@ for _, timeout_seconds in ipairs(recorded.timeouts) do
end end
assert_true(retry_timeout_seen, "expected shorter bounded retry timeout") assert_true(retry_timeout_seen, "expected shorter bounded retry timeout")
do
local visibility_state = {
binary_path = "/tmp/subminer",
overlay_running = true,
texthooker_running = false,
visible_overlay_requested = false,
}
local visibility_calls = {}
local visibility_mp = {}
function visibility_mp.command_native_async(command, callback)
visibility_calls[#visibility_calls + 1] = command
if callback then
callback(false, { status = 1, stdout = "", stderr = "failed" }, "failed")
end
end
local visibility_process = process_module.create({
mp = visibility_mp,
opts = {
backend = "x11",
socket_path = "/tmp/subminer.sock",
log_level = "debug",
texthooker_enabled = true,
texthooker_port = 5174,
auto_start_visible_overlay = false,
},
state = visibility_state,
binary = {
ensure_binary_available = function()
return true
end,
},
environment = {
detect_backend = function()
return "x11"
end,
is_linux = function()
return false
end,
is_subminer_app_running_async = function(callback)
callback(true)
end,
},
options_helper = {
coerce_bool = function(value, default_value)
if value == true or value == "yes" or value == "true" then
return true
end
if value == false or value == "no" or value == "false" then
return false
end
return default_value
end,
},
log = {
subminer_log = function(_level, _scope, line)
recorded.logs[#recorded.logs + 1] = line
end,
show_osd = function(_) end,
normalize_log_level = function(value)
return value or "info"
end,
},
})
visibility_process.run_control_command_async("show-visible-overlay")
assert_true(#visibility_calls == 1, "expected visible overlay command to run")
assert_true(
visibility_state.visible_overlay_requested == false,
"failed visible-overlay command should not update requested visibility state"
)
end
print("plugin process retry regression tests: OK") print("plugin process retry regression tests: OK")
+17
View File
@@ -23,6 +23,7 @@ local recorded = {
async_calls = {}, async_calls = {},
mpv_commands = {}, mpv_commands = {},
osd = {}, osd = {},
overlay_toggles = 0,
} }
local mp = {} local mp = {}
@@ -68,6 +69,14 @@ local ctx = {
return { return {
numericSelectionTimeoutMs = 3000, numericSelectionTimeoutMs = 3000,
bindings = { bindings = {
{
key = {
code = "KeyO",
modifiers = { "alt", "shift" },
},
actionType = "session-action",
actionId = "toggleVisibleOverlay",
},
{ {
key = { key = {
code = "KeyS", code = "KeyS",
@@ -253,6 +262,9 @@ local ctx = {
run_binary_command_async = function(args) run_binary_command_async = function(args)
recorded.async_calls[#recorded.async_calls + 1] = args recorded.async_calls[#recorded.async_calls + 1] = args
end, end,
toggle_overlay = function()
recorded.overlay_toggles = recorded.overlay_toggles + 1
end,
}, },
environment = { environment = {
resolve_session_bindings_artifact_path = function() resolve_session_bindings_artifact_path = function()
@@ -318,6 +330,11 @@ local expected_cli_bindings = {
{ keys = "w", flag = "--mark-watched" }, { keys = "w", flag = "--mark-watched" },
} }
local visible_overlay_toggle = find_binding("Alt+O")
assert_true(visible_overlay_toggle ~= nil, "visible overlay session binding should register")
visible_overlay_toggle.fn()
assert_true(recorded.overlay_toggles == 1, "visible overlay session binding should use plugin toggle")
for _, expected in ipairs(expected_cli_bindings) do for _, expected in ipairs(expected_cli_bindings) do
local binding = find_binding(expected.keys) local binding = find_binding(expected.keys)
assert_true(binding ~= nil, "default session action should register " .. expected.keys) assert_true(binding ~= nil, "default session action should register " .. expected.keys)
+432 -19
View File
@@ -201,7 +201,7 @@ local function run_plugin_scenario(config)
end end
function mp.set_osd_ass(...) end function mp.set_osd_ass(...) end
function mp.get_time() function mp.get_time()
return 0 return config.now or 0
end end
function mp.commandv(...) end function mp.commandv(...) end
function mp.set_property_native(name, value) function mp.set_property_native(name, value)
@@ -623,16 +623,18 @@ local binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage" local appimage_path = "/tmp/SubMiner.AppImage"
do do
local recorded, err = run_plugin_scenario({ local scenario = {
process_list = "", process_list = "",
option_overrides = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
}, },
now = 20,
files = { files = {
[binary_path] = true, [binary_path] = true,
}, },
}) }
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
recorded.script_messages["subminer-start"]("texthooker=no") recorded.script_messages["subminer-start"]("texthooker=no")
@@ -643,6 +645,36 @@ do
) )
end end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/aborted-app-managed.m3u8",
media_title = "Aborted App Managed",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for aborted app-managed scenario: " .. tostring(err))
recorded.script_messages["subminer-managed-subtitles-loading"]()
fire_event(recorded, "end-file", { reason = "error" })
scenario.path = "/media/next-normal.mkv"
scenario.media_title = "Next Normal"
fire_event(recorded, "file-loaded")
assert_true(
count_start_calls(recorded.async_calls) == 1,
"aborted app-managed playback should not leak pending state into the next item"
)
end
do do
local scenario = { local scenario = {
process_list = "", process_list = "",
@@ -683,6 +715,236 @@ do
) )
end end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-app-toggle-initial.m3u8",
media_title = "Jellyfin App Toggle",
paused = true,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for app-side hide Jellyfin redirect: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-visible-overlay-hidden"]()
fire_event(recorded, "end-file", { reason = "redirect" })
scenario.path = "/media/jellyfin-app-toggle-final.m3u8"
scenario.media_title = ""
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"app-side hide sync should suppress path-changing Jellyfin redirect visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"app-side hide sync followed by Jellyfin redirect should keep paused playback paused"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-duplicate-toggle.m3u8",
media_title = "Jellyfin Duplicate Toggle",
paused = true,
now = 10,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for duplicate visible overlay toggle: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"duplicate same-tick visible overlay toggles should hide once"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"duplicate same-tick visible overlay toggles should not immediately show the overlay again"
)
scenario.now = 10.5
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"later visible overlay toggle should still show after duplicate suppression window"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
},
now = 20,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for visible overlay state sync scenario: " .. tostring(err))
assert_true(
recorded.script_messages["subminer-visible-overlay-hidden"] ~= nil,
"hidden visibility sync message should be registered"
)
assert_true(
recorded.script_messages["subminer-visible-overlay-shown"] ~= nil,
"shown visibility sync message should be registered"
)
recorded.script_messages["subminer-visible-overlay-hidden"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"toggle after app-side hide should explicitly show SubMiner overlay through plugin state"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"toggle after app-side hide should avoid app-side visible overlay toggle"
)
scenario.now = 20.5
recorded.script_messages["subminer-visible-overlay-shown"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"toggle after app-side show should explicitly hide SubMiner overlay through plugin state"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for y-t hide visible overlay scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local toggle_binding = nil
for _, candidate in ipairs(recorded.key_bindings) do
if candidate.name == "subminer-toggle" then
toggle_binding = candidate
break
end
end
assert_true(toggle_binding ~= nil, "y-t toggle binding should be registered")
toggle_binding.fn()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"y-t should hide the known visible overlay explicitly instead of app-side toggle"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"y-t should avoid app-side toggle when plugin knows the overlay is visible"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"manual y-t hide should suppress duplicate auto-start and ready-time visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual y-t hide should not resume paused Jellyfin playback"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "no",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Jellyfin Managed Playback",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for managed Jellyfin subtitle preload scenario: " .. tostring(err))
assert_true(
recorded.script_messages["subminer-managed-subtitles-loading"] ~= nil,
"managed subtitle preload script message should be registered"
)
recorded.script_messages["subminer-managed-subtitles-loading"]()
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
fire_event(recorded, "file-loaded")
assert_true(
not has_property_set(recorded.property_sets, "sid", "auto"),
"managed Jellyfin preload should not rearm primary subtitle auto-selection before app-selected subtitles load"
)
assert_true(
not has_property_set(recorded.property_sets, "secondary-sid", "auto"),
"managed Jellyfin preload should not rearm secondary subtitle auto-selection before app-selected subtitles load"
)
assert_true(
not has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
"managed Jellyfin preload should not re-enable subtitle autoloading before app-selected subtitles load"
)
assert_true(
count_start_calls(recorded.async_calls) == 0,
"managed Jellyfin preload should let the app show the overlay after subtitle preload instead of plugin auto-start"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"managed Jellyfin preload should not reassert the visible overlay during duplicate file-loaded events"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 0,
"managed Jellyfin preload should not arm the plugin pause gate before app-selected subtitles load"
)
fire_event(recorded, "end-file", { reason = "stop" })
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_property_set(recorded.property_sets, "sid", "auto") == 1,
"managed subtitle preload suppression should only apply to one playback lifecycle"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"plugin auto-start should resume after the managed Jellyfin lifecycle ends"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -932,8 +1194,8 @@ do
recorded.script_messages["subminer-restart"]() recorded.script_messages["subminer-restart"]()
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled" "manual restart should avoid a second visible overlay restore after launch already requested visibility"
) )
end end
@@ -1328,8 +1590,8 @@ do
"auto-start with visible overlay enabled should not include --hide-visible-overlay on --start" "auto-start with visible overlay enabled should not include --hide-visible-overlay on --start"
) )
assert_true( assert_true(
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, find_control_call(recorded.async_calls, "--show-visible-overlay") == nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command" "auto-start with visible overlay enabled should rely on the --start visibility flag instead of a separate --show-visible-overlay command"
) )
assert_true( assert_true(
not has_property_set(recorded.property_sets, "pause", true), not has_property_set(recorded.property_sets, "pause", true),
@@ -1360,8 +1622,8 @@ do
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running" "duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"duplicate auto-start should re-assert visible overlay state when overlay is already running" "duplicate auto-start should not re-assert visible overlay state when it is already requested"
) )
assert_true( assert_true(
count_osd_message(recorded.osd, "SubMiner: Already running") == 0, count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
@@ -1396,8 +1658,8 @@ do
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load" "duplicate pause-until-ready auto-start should not re-assert visible overlay after the start command already requested it"
) )
assert_true( assert_true(
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 1, count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 1,
@@ -1458,8 +1720,8 @@ do
"autoplay-ready should show loaded OSD message" "autoplay-ready should show loaded OSD message"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"autoplay-ready should re-assert visible overlay state" "autoplay-ready should not re-assert visible overlay state after the start command already requested it"
) )
assert_true( assert_true(
#recorded.periodic_timers == 1, #recorded.periodic_timers == 1,
@@ -1471,6 +1733,33 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for duplicate autoplay-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"duplicate autoplay-ready signals should not spawn visible overlay restore commands when start already requested visibility"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1523,14 +1812,22 @@ do
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]() recorded.script_messages["subminer-toggle"]()
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"manual toggle should use explicit visible-overlay toggle command" "manual toggle-off should hide a known visible overlay explicitly"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"manual toggle-off should avoid app-side toggle when plugin knows the overlay is visible"
) )
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( 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" "manual toggle-off before readiness should suppress ready-time visible overlay restore"
) )
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off before readiness should not resume playback when readiness arrives"
)
end end
do do
@@ -1559,11 +1856,127 @@ do
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( 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" "manual toggle-off should suppress repeated ready-time visible overlay restores for the same session"
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual hide duplicate auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"manual toggle-off should suppress duplicate auto-start visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by duplicate auto-start should keep paused playback paused"
)
end
do
local media_path = "/media/jellyfin-redirect.m3u8"
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = media_path,
media_title = "Jellyfin Redirect",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual hide same-media reload scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "end-file", { reason = "redirect" })
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"manual toggle-off should suppress same-media reload visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by same-media reload should keep paused playback paused"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-redirect-initial.m3u8",
media_title = "Jellyfin Redirect",
paused = true,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for manual hide path-changing Jellyfin redirect: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "end-file", { reason = "redirect" })
scenario.path = "/media/jellyfin-redirect-final.m3u8"
scenario.media_title = ""
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
"manual toggle-off should suppress path-changing Jellyfin redirect visible overlay reassertion even if media-title drops"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by path-changing Jellyfin reload should keep paused playback paused"
)
assert_true(
count_property_set(recorded.property_sets, "sid", "auto") == 2,
"path-changing Jellyfin redirect should rearm primary subtitle selection before mpv loads tracks"
)
assert_true(
count_property_set(recorded.property_sets, "secondary-sid", "auto") == 2,
"path-changing Jellyfin redirect should rearm secondary subtitle selection before mpv loads tracks"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1721,8 +2134,8 @@ do
"auto-start with visible overlay disabled should not include --show-visible-overlay on --start" "auto-start with visible overlay disabled should not include --show-visible-overlay on --start"
) )
assert_true( assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil, find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
"auto-start with visible overlay disabled should issue a separate --hide-visible-overlay command" "auto-start with visible overlay disabled should rely on the --start visibility flag instead of a separate --hide-visible-overlay command"
) )
end end
+10 -3
View File
@@ -75,7 +75,10 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false); assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
assert.equal('deviceId' in config.jellyfin, false);
assert.equal('clientVersion' in config.jellyfin, false);
assert.equal(config.ai.enabled, false); assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.ai.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false); assert.equal(config.texthooker.openBrowser, false);
@@ -832,7 +835,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns
); );
}); });
test('parses jellyfin remote control fields', () => { test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
@@ -843,6 +846,7 @@ test('parses jellyfin remote control fields', () => {
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": true, "autoAnnounce": true,
"clientName": "Custom Client",
"remoteControlDeviceName": "SubMiner" "remoteControlDeviceName": "SubMiner"
} }
}`, }`,
@@ -857,7 +861,8 @@ test('parses jellyfin remote control fields', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true); assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
}); });
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => { test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
@@ -2469,6 +2474,8 @@ test('template generator includes known keys', () => {
assert.match(output, /"startupWarmups":/); assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/); assert.match(output, /"updates":/);
assert.match(output, /"youtube":/); assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"deviceId":/);
assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/); assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"preserveLineBreaks": false/);
@@ -130,14 +130,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
serverUrl: '', serverUrl: '',
recentServers: [], recentServers: [],
username: '', username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
clientVersion: '0.1.0',
defaultLibraryId: '', defaultLibraryId: '',
remoteControlEnabled: true, remoteControlEnabled: true,
remoteControlAutoConnect: true, remoteControlAutoConnect: true,
autoAnnounce: false, autoAnnounce: false,
remoteControlDeviceName: 'SubMiner',
pullPictures: false, pullPictures: false,
iconCacheDir: '/tmp/subminer-jellyfin-icons', iconCacheDir: '/tmp/subminer-jellyfin-icons',
directPlayPreferred: true, directPlayPreferred: true,
+2 -27
View File
@@ -265,7 +265,8 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'enum', kind: 'enum',
enumValues: ['headword', 'surface'], enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.knownWords.matchMode, defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
description: 'Known-word matching strategy for subtitle annotations.', description:
'Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types.',
}, },
{ {
path: 'ankiConnect.knownWords.highlightEnabled', path: 'ankiConnect.knownWords.highlightEnabled',
@@ -548,26 +549,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.username, defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.', description: 'Default Jellyfin username used during CLI login.',
}, },
{
path: 'jellyfin.deviceId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.deviceId,
description:
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientName,
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientVersion',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientVersion,
description:
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
},
{ {
path: 'jellyfin.defaultLibraryId', path: 'jellyfin.defaultLibraryId',
kind: 'string', kind: 'string',
@@ -593,12 +574,6 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'When enabled, automatically trigger remote announce/visibility check on websocket connect.', 'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
}, },
{
path: 'jellyfin.remoteControlDeviceName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
description: 'Device name reported for Jellyfin remote control sessions.',
},
{ {
path: 'jellyfin.pullPictures', path: 'jellyfin.pullPictures',
kind: 'boolean', kind: 'boolean',
-3
View File
@@ -371,9 +371,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
const stringKeys = [ const stringKeys = [
'serverUrl', 'serverUrl',
'username', 'username',
'deviceId',
'clientName',
'clientVersion',
'defaultLibraryId', 'defaultLibraryId',
'iconCacheDir', 'iconCacheDir',
'transcodeVideoCodec', 'transcodeVideoCodec',
-4
View File
@@ -59,7 +59,6 @@ test('settings registry hides removed modal-only fields', () => {
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) { ]) {
assert.equal( assert.equal(
fields.some((candidate) => candidate.configPath === path), fields.some((candidate) => candidate.configPath === path),
@@ -246,10 +245,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadLabel', 'controller.preferredGamepadLabel',
'controller.profiles', 'controller.profiles',
'youtubeSubgen.whisperBin', 'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey', 'subtitleSidebar.toggleKey',
'jellyfin.recentServers', 'jellyfin.recentServers',
]) { ]) {
-4
View File
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.accessToken', 'jellyfin.accessToken',
'jellyfin.userId', 'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices', 'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey', 'subtitleSidebar.toggleKey',
+38
View File
@@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']); assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
}); });
test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => {
const handled: string[] = [];
let controlArgvHandler: ((argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
const { deps } = createDeps({
shouldStartApp: () => true,
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
startControlServer: (handler) => {
controlArgvHandler = handler;
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
handled.push('ready');
throw new Error('ready failed');
},
});
startAppLifecycle(makeArgs({ background: true }), deps);
assert.ok(controlArgvHandler);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, []);
assert.ok(readyHandler);
await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/);
assert.deepEqual(handled, ['ready', 'second-instance:start']);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => { test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null; let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({ const { deps, calls } = createDeps({
+6 -3
View File
@@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} }
deps.whenReady(async () => { deps.whenReady(async () => {
await deps.onReady(); try {
appReadyRuntimeComplete = true; await deps.onReady();
flushPendingSecondInstanceCommands(); } finally {
appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands();
}
}); });
deps.onWindowAllClosed(() => { deps.onWindowAllClosed(() => {
@@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
} }
}); });
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaTitle: null,
mediaPath:
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
});
assert.equal(payload.details, 'Unknown media');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
const serialized = JSON.stringify(payload);
assert.equal(serialized.includes('api_key'), false);
assert.equal(serialized.includes('secret-token'), false);
assert.equal(serialized.includes('/Videos/item-1/stream'), false);
});
test('service deduplicates identical updates and sends changed timeline', async () => { test('service deduplicates identical updates and sends changed timeline', async () => {
const sent: DiscordActivityPayload[] = []; const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>(); const timers = new Map<number, () => void>();
+13 -1
View File
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
return parts[parts.length - 1] ?? ''; return parts[parts.length - 1] ?? '';
} }
function fallbackTitleFromMediaPath(mediaPath: string | null): string {
const trimmed = mediaPath?.trim();
if (!trimmed) return '';
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) {
return '';
}
return basename(trimmed).split(/[?#]/)[0] ?? '';
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string { function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle'; if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused'; if (snapshot.paused) return 'Paused';
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
): DiscordActivityPayload { ): DiscordActivityPayload {
const style = resolvePresenceStyle(config.presenceStyle); const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot); const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); const title = sanitizeText(
snapshot.mediaTitle,
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details = const details =
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails; snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
@@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
} }
}); });
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.destroy();
tracker = null;
tracker = new Ctor({ dbPath });
tracker.recordJellyfinPlaybackMetadata({
mediaPath:
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
itemTitle: 'The Princess Begins Adventuring',
seriesTitle: 'The Beginning After the End',
seasonNumber: 2,
episodeNumber: 2,
itemId: 'item-2',
});
tracker.handleMediaChange(
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
tracker.handleMediaChange(null, null);
tracker.recordJellyfinPlaybackMetadata({
mediaPath:
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
itemTitle: 'The Princess Begins Adventuring',
seriesTitle: 'The Beginning After the End',
seasonNumber: 2,
episodeNumber: 2,
itemId: 'item-2',
});
tracker.handleMediaChange(
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
const privateApi = tracker as unknown as { db: DatabaseSync };
const rows = privateApi.db
.prepare(
`
SELECT
v.source_url,
v.canonical_title AS video_title,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
v.parser_source,
a.canonical_title AS anime_title
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
ORDER BY v.video_id
`,
)
.all() as Array<{
source_url: string | null;
video_title: string;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
parser_source: string | null;
anime_title: string;
}>;
assert.equal(rows.length, 2);
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
const jellyfinRow = rows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
);
assert.ok(jellyfinRow);
assert.equal(
jellyfinRow.video_title,
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End');
assert.equal(jellyfinRow.parsed_season, 2);
assert.equal(jellyfinRow.parsed_episode, 2);
assert.equal(jellyfinRow.parser_source, 'jellyfin');
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('applies configurable queue, flush, and retention policy', async () => { test('applies configurable queue, flush, and retention policy', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
+107 -2
View File
@@ -301,6 +301,33 @@ export type {
VocabularyStatsRow, VocabularyStatsRow,
} from './immersion-tracker/types'; } from './immersion-tracker/types';
export interface JellyfinPlaybackMetadataInput {
mediaPath: string;
displayTitle: string;
itemTitle: string;
seriesTitle: string | null;
seasonNumber: number | null;
episodeNumber: number | null;
itemId: string;
}
function normalizeMetadataInt(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
}
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
const normalizedItemId = normalizeText(itemId);
if (!normalizedItemId) {
return mediaPath;
}
try {
const parsed = new URL(mediaPath);
return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`;
} catch {
return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`;
}
}
export class ImmersionTrackerService { export class ImmersionTrackerService {
private readonly logger = createLogger('main:immersion-tracker'); private readonly logger = createLogger('main:immersion-tracker');
private readonly db: DatabaseSync; private readonly db: DatabaseSync;
@@ -337,6 +364,7 @@ export class ImmersionTrackerService {
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>(); private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
private readonly recordedSubtitleKeys = new Set<string>(); private readonly recordedSubtitleKeys = new Set<string>();
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>(); private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
private readonly mediaPathAliases = new Map<string, string>();
private readonly resolveLegacyVocabularyPos: private readonly resolveLegacyVocabularyPos:
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>) | ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
| undefined; | undefined;
@@ -1115,8 +1143,85 @@ export class ImmersionTrackerService {
rebuildLifetimeSummaryTables(this.db); rebuildLifetimeSummaryTables(this.db);
} }
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
const rawPath = normalizeMediaPath(metadata.mediaPath);
if (!rawPath) {
return;
}
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
this.mediaPathAliases.set(rawPath, normalizedPath);
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
deriveCanonicalTitle(normalizedPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const libraryTitle = seriesTitle || itemTitle;
if (!libraryTitle) {
return;
}
const videoId = getOrCreateVideoRecord(
this.db,
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
{
canonicalTitle: displayTitle,
sourcePath: null,
sourceUrl: normalizedPath,
sourceType: SOURCE_TYPE_REMOTE,
},
);
const previousLink = this.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
const metadataJson = JSON.stringify({
source: 'jellyfin',
itemId: normalizeText(metadata.itemId) || null,
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
});
const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle,
canonicalTitle: libraryTitle,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson,
});
linkVideoToAnimeRecord(this.db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: libraryTitle,
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
parserSource: 'jellyfin',
parserConfidence: 1,
parseMetadataJson: metadataJson,
});
const hasLifetimeMedia = Boolean(
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
);
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
rebuildLifetimeSummaryTables(this.db);
}
}
private hasJellyfinMetadata(videoId: number): boolean {
const row = this.db
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
.get(videoId) as { parserSource: string | null } | null;
return row?.parserSource === 'jellyfin';
}
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const normalizedPath = normalizeMediaPath(mediaPath); const rawPath = normalizeMediaPath(mediaPath);
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle); const normalizedTitle = normalizeText(mediaTitle);
this.logger.info( this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`, `handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
@@ -1164,7 +1269,7 @@ export class ImmersionTrackerService {
if (youtubeVideoId) { if (youtubeVideoId) {
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
} else { } else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
} }
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
+6
View File
@@ -116,6 +116,12 @@ export {
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime, resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime, ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from './jellyfin'; } 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 { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
export { export {
broadcastRuntimeOptionsChangedRuntime, broadcastRuntimeOptionsChangedRuntime,
+38
View File
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload); assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
}); });
test('timeline payload omits websocket-only event names', () => {
const payload = buildJellyfinTimelinePayload({
itemId: 'movie-2',
positionTicks: 123456,
eventName: 'TimeUpdate',
});
assert.equal('EventName' in payload, false);
});
test('reportStopped posts final position and explicit non-failed state', async () => {
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop-payload',
deviceId: 'device-stop-payload',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
const ok = await service.reportStopped({
itemId: 'movie-stop',
positionTicks: 7654321,
failed: false,
});
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
assert.equal(ok, true);
assert.ok(stoppedCall);
assert.ok(typeof stoppedCall.init.body === 'string');
const posted = JSON.parse(String(stoppedCall.init.body));
assert.equal(posted.PositionTicks, 7654321);
assert.equal(posted.Failed, false);
});
test('advertiseNow validates server registration using Sessions endpoint', async () => { test('advertiseNow validates server registration using Sessions endpoint', async () => {
const sockets: FakeWebSocket[] = []; const sockets: FakeWebSocket[] = [];
const calls: string[] = []; const calls: string[] = [];
+5 -7
View File
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
playlistItemId?: string | null; playlistItemId?: string | null;
eventName?: string; eventName?: string;
failed?: boolean;
} }
export interface JellyfinTimelinePayload { export interface JellyfinTimelinePayload {
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
AudioStreamIndex?: number | null; AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null; SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null; PlaylistItemId?: string | null;
EventName: string; Failed?: boolean;
} }
interface JellyfinRemoteSocket { interface JellyfinRemoteSocket {
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
AudioStreamIndex: asNullableInteger(state.audioStreamIndex), AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex), SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId, PlaylistItemId: state.playlistItemId,
EventName: state.eventName || 'timeupdate', Failed: state.failed,
}; };
} }
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
} }
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', { return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'start',
});
} }
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', { return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state), ...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'stop', Failed: state.failed === true,
}); });
} }
@@ -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,
);
});
@@ -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;
}
}
+6 -1
View File
@@ -229,6 +229,7 @@ test('resolvePlaybackPlan chooses direct play when allowed', async () => {
assert.equal(plan.mode, 'direct'); assert.equal(plan.mode, 'direct');
assert.match(plan.url, /Videos\/movie-1\/stream\?/); assert.match(plan.url, /Videos\/movie-1\/stream\?/);
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/); assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
assert.equal(new URL(plan.url).searchParams.get('StartTimeTicks'), null);
assert.equal(plan.subtitleStreamIndex, null); assert.equal(plan.subtitleStreamIndex, null);
assert.equal(ticksToSeconds(plan.startTimeTicks), 2); assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
} finally { } finally {
@@ -560,13 +561,17 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
assert.equal(plan.mode, 'direct'); assert.equal(plan.mode, 'direct');
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope'); assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
assert.equal(plan.itemTitle, 'A New Hope');
assert.equal(plan.seriesTitle, 'Galaxy Quest');
assert.equal(plan.seasonNumber, 2);
assert.equal(plan.episodeNumber, 7);
assert.equal(plan.audioStreamIndex, 6); assert.equal(plan.audioStreamIndex, 6);
assert.equal(plan.subtitleStreamIndex, 9); assert.equal(plan.subtitleStreamIndex, 9);
assert.equal(plan.startTimeTicks, 35_000_000); assert.equal(plan.startTimeTicks, 35_000_000);
const url = new URL(plan.url); const url = new URL(plan.url);
assert.equal(url.searchParams.get('AudioStreamIndex'), '6'); assert.equal(url.searchParams.get('AudioStreamIndex'), '6');
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9'); assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9');
assert.equal(url.searchParams.get('StartTimeTicks'), '35000000'); assert.equal(url.searchParams.get('StartTimeTicks'), null);
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
+23 -6
View File
@@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan {
mode: 'direct' | 'transcode'; mode: 'direct' | 'transcode';
url: string; url: string;
title: string; title: string;
itemTitle: string;
seriesTitle: string | null;
seasonNumber: number | null;
episodeNumber: number | null;
startTimeTicks: number; startTimeTicks: number;
audioStreamIndex: number | null; audioStreamIndex: number | null;
subtitleStreamIndex: number | null; subtitleStreamIndex: number | null;
@@ -229,9 +233,6 @@ function createDirectPlayUrl(
if (plan.subtitleStreamIndex !== null) { if (plan.subtitleStreamIndex !== null) {
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex)); query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
} }
if (plan.startTimeTicks > 0) {
query.set('StartTimeTicks', String(plan.startTimeTicks));
}
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`; return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
} }
@@ -292,14 +293,24 @@ function getStreamDefaults(source: JellyfinMediaSource): {
}; };
} }
function getItemTitle(item: JellyfinItem): string {
return ensureString(item.Name).trim() || 'Jellyfin Item';
}
function getSeriesTitle(item: JellyfinItem): string | null {
return ensureString(item.SeriesName).trim() || null;
}
function getDisplayTitle(item: JellyfinItem): string { function getDisplayTitle(item: JellyfinItem): string {
const itemTitle = getItemTitle(item);
if (item.Type === 'Episode') { if (item.Type === 'Episode') {
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0; const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
const episode = asIntegerOrNull(item.IndexNumber) ?? 0; const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
const prefix = item.SeriesName ? `${item.SeriesName} ` : ''; const seriesTitle = getSeriesTitle(item);
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim(); const prefix = seriesTitle ? `${seriesTitle} ` : '';
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim();
} }
return ensureString(item.Name).trim() || 'Jellyfin Item'; return itemTitle;
} }
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean { function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
@@ -521,10 +532,16 @@ export async function resolvePlaybackPlan(
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null; const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null; const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0); const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
const itemTitle = getItemTitle(item);
const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null;
const basePlan: JellyfinPlaybackPlan = { const basePlan: JellyfinPlaybackPlan = {
mode: 'transcode', mode: 'transcode',
url: '', url: '',
title: getDisplayTitle(item), title: getDisplayTitle(item),
itemTitle,
seriesTitle,
seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null,
episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null,
startTimeTicks, startTimeTicks,
audioStreamIndex, audioStreamIndex,
subtitleStreamIndex, subtitleStreamIndex,
+2 -2
View File
@@ -78,7 +78,7 @@ export interface MpvProtocolHandleMessageDeps {
setPendingPauseAtSubEnd: (value: boolean) => void; setPendingPauseAtSubEnd: (value: boolean) => void;
getPauseAtTime: () => number | null; getPauseAtTime: () => number | null;
setPauseAtTime: (value: number | null) => void; setPauseAtTime: (value: number | null) => void;
autoLoadSecondarySubTrack: () => void; autoLoadSecondarySubTrack: (path: string) => void;
setCurrentVideoPath: (value: string) => void; setCurrentVideoPath: (value: string) => void;
emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
setPreviousSecondarySubVisibility: (visible: boolean) => void; setPreviousSecondarySubVisibility: (visible: boolean) => void;
@@ -303,7 +303,7 @@ export async function dispatchMpvProtocolMessage(
const path = (msg.data as string) || ''; const path = (msg.data as string) || '';
deps.setCurrentVideoPath(path); deps.setCurrentVideoPath(path);
deps.emitMediaPathChange({ path }); deps.emitMediaPathChange({ path });
deps.autoLoadSecondarySubTrack(); deps.autoLoadSecondarySubTrack(path);
deps.syncCurrentAudioStreamIndex(); deps.syncCurrentAudioStreamIndex();
} else if (msg.name === 'sub-pos') { } else if (msg.name === 'sub-pos') {
deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
+51 -1
View File
@@ -6,7 +6,10 @@ import {
MpvIpcClientProtocolDeps, MpvIpcClientProtocolDeps,
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
} from './mpv'; } 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 { function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps {
return { return {
@@ -93,6 +96,53 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
assert.equal(client.currentMediaTitle, null); 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', () => { test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const seen: Array<Record<string, unknown>> = []; const seen: Array<Record<string, unknown>> = [];
+9 -3
View File
@@ -105,6 +105,7 @@ export interface MpvIpcClientProtocolDeps {
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null; getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
shouldQuitOnMpvShutdown?: () => boolean; shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void; requestAppQuit?: () => void;
} }
@@ -404,8 +405,8 @@ export class MpvIpcClient implements MpvClient {
setPauseAtTime: (value: number | null) => { setPauseAtTime: (value: number | null) => {
this.pauseAtTime = value; this.pauseAtTime = value;
}, },
autoLoadSecondarySubTrack: () => { autoLoadSecondarySubTrack: (path: string) => {
this.autoLoadSecondarySubTrack(); this.autoLoadSecondarySubTrack(path);
}, },
setCurrentVideoPath: (value: string) => { setCurrentVideoPath: (value: string) => {
this.currentVideoPath = value; 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(); const config = this.deps.getResolvedConfig();
if (!config.secondarySub?.autoLoadSecondarySub) return; if (!config.secondarySub?.autoLoadSecondarySub) return;
const languages = config.secondarySub.secondarySubLanguages; const languages = config.secondarySub.secondarySubLanguages;
+153 -5
View File
@@ -197,6 +197,68 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
const { window, calls } = createMainWindowRecorder();
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: false,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: false,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.equal(calls.includes('mouse-ignore:false:plain'), false);
assert.ok(calls.includes('mouse-ignore:true:forward'));
});
test('suspended visible overlay hides without refreshing bounds or z-order', () => { test('suspended visible overlay hides without refreshing bounds or z-order', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
@@ -244,7 +306,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
}); });
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => { test('untracked non-macOS overlay shows passively when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
let trackerWarning = false; let trackerWarning = false;
@@ -279,11 +341,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
} as never); } as never);
assert.equal(trackerWarning, false); assert.equal(trackerWarning, false);
assert.ok(calls.includes('show')); assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('focus')); assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('passive Linux visible overlay does not take keyboard focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => { test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
@@ -317,8 +417,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
} as never); } as never);
assert.deepEqual( assert.deepEqual(
calls.filter((call) => call === 'update-bounds' || call === 'show'), calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
['update-bounds', 'show', 'update-bounds'], ['update-bounds', 'show-inactive', 'update-bounds'],
); );
}); });
@@ -1260,6 +1360,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
}); });
test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
macOSForegroundProbeActive: true,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => { test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder(); const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
+24 -3
View File
@@ -71,6 +71,7 @@ export function updateVisibleOverlayVisibility(args: {
lastKnownWindowsForegroundProcessName?: string | null; lastKnownWindowsForegroundProcessName?: string | null;
windowsOverlayProcessName?: string | null; windowsOverlayProcessName?: string | null;
windowsFocusHandoffGraceActive?: boolean; windowsFocusHandoffGraceActive?: boolean;
macOSForegroundProbeActive?: boolean;
trackerNotReadyWarningShown: boolean; trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
@@ -128,6 +129,12 @@ export function updateVisibleOverlayVisibility(args: {
const isTrackedMacOSTargetMinimized = const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.(); const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const shouldPreserveMacOSOverlayDuringForegroundProbe =
args.isMacOSPlatform &&
args.macOSForegroundProbeActive === true &&
!!windowTracker &&
!isTrackedMacOSTargetMinimized &&
(windowTracker.isTracking() || windowTracker.getGeometry() !== null);
const hasTransientMacOSTrackerLoss = const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform && args.isMacOSPlatform &&
canReportMacOSTargetMinimized && canReportMacOSTargetMinimized &&
@@ -137,7 +144,10 @@ export function updateVisibleOverlayVisibility(args: {
trackedMacOSTargetFocused !== false && trackedMacOSTargetFocused !== false &&
mainWindow.isVisible(); mainWindow.isVisible();
const isTrackedMacOSTargetFocused = const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker hasTransientMacOSTrackerLoss ||
shouldPreserveMacOSOverlayDuringForegroundProbe ||
!args.isMacOSPlatform ||
!args.windowTracker
? true ? true
: (trackedMacOSTargetFocused ?? true); : (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel = const shouldReleaseMacOSOverlayLevel =
@@ -171,9 +181,12 @@ export function updateVisibleOverlayVisibility(args: {
!isTrackedWindowsTargetMinimized && !isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible; const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const isNonNativePassiveOverlay =
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
const shouldIgnoreMouseEvents = const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough || shouldUseMacOSMousePassthrough ||
forceMousePassthrough || forceMousePassthrough ||
isNonNativePassiveOverlay ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost = const shouldKeepTrackedWindowsOverlayTopmost =
@@ -217,7 +230,10 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady // skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer // callback will trigger another visibility update when the renderer
// has painted its first frame. // has painted its first frame.
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { } else if (
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
isNonNativePassiveOverlay
) {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
} }
@@ -261,7 +277,12 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.focus(); mainWindow.focus();
} }
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { if (
!args.isWindowsPlatform &&
!args.isMacOSPlatform &&
!forceMousePassthrough &&
overlayInteractionActive
) {
mainWindow.focus(); mainWindow.focus();
} }
+29
View File
@@ -0,0 +1,29 @@
export type StatsWindowLayerSuspensionState = {
count: number;
};
export function createStatsWindowLayerSuspensionState(): StatsWindowLayerSuspensionState {
return { count: 0 };
}
export function isStatsWindowLayerSuspended(state: StatsWindowLayerSuspensionState): boolean {
return state.count > 0;
}
export function suspendStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean {
state.count += 1;
return state.count === 1;
}
export function restoreStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean {
if (state.count <= 0) {
return false;
}
state.count -= 1;
return state.count === 0;
}
export function resetStatsWindowLayerSuspension(state: StatsWindowLayerSuspensionState): void {
state.count = 0;
}
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createStatsWindowLayerSuspensionState,
isStatsWindowLayerSuspended,
resetStatsWindowLayerSuspension,
restoreStatsWindowLayer,
suspendStatsWindowLayer,
} from './stats-window-layer';
test('stats window layer suspension reset clears missed native dialog closes', () => {
const state = createStatsWindowLayerSuspensionState();
assert.equal(suspendStatsWindowLayer(state), true);
assert.equal(suspendStatsWindowLayer(state), false);
assert.equal(isStatsWindowLayerSuspended(state), true);
resetStatsWindowLayerSuspension(state);
assert.equal(isStatsWindowLayerSuspended(state), false);
assert.equal(restoreStatsWindowLayer(state), false);
assert.equal(suspendStatsWindowLayer(state), true);
});
+49 -1
View File
@@ -1,4 +1,8 @@
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; import type {
BrowserWindow,
BrowserWindowConstructorOptions,
MessageBoxSyncOptions,
} from 'electron';
import type { WindowGeometry } from '../../types'; import type { WindowGeometry } from '../../types';
const DEFAULT_STATS_WINDOW_WIDTH = 900; const DEFAULT_STATS_WINDOW_WIDTH = 900;
@@ -9,6 +13,15 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type VisibleStatsWindowLevelController = StatsWindowLevelController & type VisibleStatsWindowLevelController = StatsWindowLevelController &
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>; Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
type VisibleStatsWindowDialogLayerController = Pick<
BrowserWindow,
'isDestroyed' | 'isVisible' | 'setAlwaysOnTop'
>;
type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>;
type StatsNativeConfirmDialogPresenter<WindowT> = {
showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number;
showWithoutParent: (options: MessageBoxSyncOptions) => number;
};
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>; type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> & type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
@@ -124,6 +137,41 @@ export function promoteVisibleStatsWindowAboveOverlay(
return true; return true;
} }
export function demoteVisibleStatsWindowBelowDialogs(
window: VisibleStatsWindowDialogLayerController,
): boolean {
if (window.isDestroyed() || !window.isVisible()) {
return false;
}
window.setAlwaysOnTop(false);
return true;
}
export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions {
return {
type: 'warning',
message,
buttons: ['Delete', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
};
}
export function showStatsNativeConfirmDialog<WindowT extends StatsNativeConfirmDialogWindow>(
window: WindowT | null,
message: string,
presenter: StatsNativeConfirmDialogPresenter<WindowT>,
): boolean {
const options = buildStatsNativeConfirmDialogOptions(message);
const response =
window && !window.isDestroyed()
? presenter.showWithParent(window, options)
: presenter.showWithoutParent(options);
return response === 0;
}
export function presentStatsWindow( export function presentStatsWindow(
window: StatsWindowPresentationController, window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
+87
View File
@@ -3,10 +3,13 @@ import test from 'node:test';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow, presentStatsWindow,
promoteVisibleStatsWindowAboveOverlay, promoteVisibleStatsWindowAboveOverlay,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
} from './stats-window-runtime'; } from './stats-window-runtime';
@@ -274,6 +277,90 @@ test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => {
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => {
const calls: string[] = [];
const demoted = demoteVisibleStatsWindowBelowDialogs({
isDestroyed: () => false,
isVisible: () => true,
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
} as never);
assert.equal(demoted, true);
assert.deepEqual(calls, ['always-on-top:false:none:0']);
});
test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => {
const calls: string[] = [];
const demoted = demoteVisibleStatsWindowBelowDialogs({
isDestroyed: () => false,
isVisible: () => false,
setAlwaysOnTop: () => calls.push('always-on-top'),
} as never);
assert.equal(demoted, false);
assert.deepEqual(calls, []);
});
test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => {
assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), {
type: 'warning',
message: 'Delete this session?',
buttons: ['Delete', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
});
test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => {
const calls: string[] = [];
const parent = { isDestroyed: () => false };
const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', {
showWithParent: (window, options) => {
assert.equal(window, parent);
calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`);
return 0;
},
showWithoutParent: () => {
calls.push('unparented');
return 1;
},
});
assert.equal(confirmed, true);
assert.deepEqual(calls, ['Delete this session?:1:1']);
});
test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => {
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', {
showWithParent: () => 1,
showWithoutParent: () => 0,
});
assert.equal(confirmed, false);
});
test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => {
const calls: string[] = [];
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', {
showWithParent: () => {
calls.push('parented');
return 0;
},
showWithoutParent: (options) => {
calls.push(options.message);
return 0;
},
});
assert.equal(confirmed, true);
assert.deepEqual(calls, ['Delete?']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => { test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = []; const calls: string[] = [];
+82 -1
View File
@@ -1,21 +1,32 @@
import { BrowserWindow, ipcMain } from 'electron'; import { BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path'; import * as path from 'path';
import type { WindowGeometry } from '../../types.js'; import type { WindowGeometry } from '../../types.js';
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow, presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
promoteVisibleStatsWindowAboveOverlay, promoteVisibleStatsWindowAboveOverlay,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
STATS_WINDOW_TITLE, STATS_WINDOW_TITLE,
} from './stats-window-runtime.js'; } from './stats-window-runtime.js';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
import {
createStatsWindowLayerSuspensionState,
isStatsWindowLayerSuspended,
resetStatsWindowLayerSuspension,
restoreStatsWindowLayer,
suspendStatsWindowLayer,
} from './stats-window-layer.js';
let statsWindow: BrowserWindow | null = null; let statsWindow: BrowserWindow | null = null;
let toggleRegistered = false; let toggleRegistered = false;
let nativeDialogLayerRegistered = false;
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
export interface StatsWindowOptions { export interface StatsWindowOptions {
/** Absolute path to stats/dist/ directory */ /** Absolute path to stats/dist/ directory */
@@ -63,6 +74,10 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
} }
export function promoteStatsOverlayAbovePlayback(): boolean { export function promoteStatsOverlayAbovePlayback(): boolean {
if (isStatsWindowLayerSuspended(nativeDialogLayerSuspension)) {
return false;
}
if (!statsWindow) { if (!statsWindow) {
return false; return false;
} }
@@ -74,6 +89,69 @@ export function promoteStatsOverlayAbovePlayback(): boolean {
}); });
} }
export function demoteStatsOverlayBelowDialogs(): boolean {
if (!statsWindow) {
return false;
}
return demoteVisibleStatsWindowBelowDialogs(statsWindow);
}
export function suspendStatsWindowLayerForNativeDialog(): void {
if (!suspendStatsWindowLayer(nativeDialogLayerSuspension)) {
return;
}
demoteStatsOverlayBelowDialogs();
}
export function restoreStatsWindowLayerAfterNativeDialog(): void {
if (restoreStatsWindowLayer(nativeDialogLayerSuspension)) {
promoteStatsOverlayAbovePlayback();
}
}
function resetStatsWindowLayerAfterLifecycleEnd(): void {
resetStatsWindowLayerSuspension(nativeDialogLayerSuspension);
}
export async function withStatsWindowLayerSuspendedForNativeDialog<T>(
showDialog: () => Promise<T>,
): Promise<T> {
suspendStatsWindowLayerForNativeDialog();
try {
return await showDialog();
} finally {
restoreStatsWindowLayerAfterNativeDialog();
}
}
function confirmStatsNativeDialog(message: unknown): boolean {
const dialogMessage =
typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?';
return showStatsNativeConfirmDialog(statsWindow, dialogMessage, {
showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options),
showWithoutParent: (options) => dialog.showMessageBoxSync(options),
});
}
function registerStatsNativeDialogLayerHandlers(): void {
if (nativeDialogLayerRegistered) return;
nativeDialogLayerRegistered = true;
ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => {
event.returnValue = confirmStatsNativeDialog(message);
});
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => {
suspendStatsWindowLayerForNativeDialog();
event.returnValue = true;
});
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => {
restoreStatsWindowLayerAfterNativeDialog();
});
}
/** /**
* Toggle the stats overlay window: create on first call, then show/hide. * Toggle the stats overlay window: create on first call, then show/hide.
* The React app stays mounted across toggles state is preserved. * The React app stays mounted across toggles state is preserved.
@@ -99,6 +177,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
statsWindow.on('closed', () => { statsWindow.on('closed', () => {
options.onVisibilityChanged?.(false); options.onVisibilityChanged?.(false);
statsWindow = null; statsWindow = null;
resetStatsWindowLayerAfterLifecycleEnd();
}); });
statsWindow.webContents.on('before-input-event', (event, input) => { statsWindow.webContents.on('before-input-event', (event, input) => {
@@ -132,6 +211,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
* Call this once during app initialization. * Call this once during app initialization.
*/ */
export function registerStatsOverlayToggle(options: StatsWindowOptions): void { export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
registerStatsNativeDialogLayerHandlers();
if (toggleRegistered) return; if (toggleRegistered) return;
toggleRegistered = true; toggleRegistered = true;
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
@@ -148,4 +228,5 @@ export function destroyStatsWindow(): void {
statsWindow.destroy(); statsWindow.destroy();
statsWindow = null; statsWindow = null;
} }
resetStatsWindowLayerAfterLifecycleEnd();
} }
+41
View File
@@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
test('triggerSubsyncFromConfig opens manual picker', async () => { test('triggerSubsyncFromConfig opens manual picker', async () => {
const osd: string[] = []; const osd: string[] = [];
let payloadTrackCount = 0; let payloadTrackCount = 0;
let ffsubsyncAvailable: boolean | null = null;
let inProgressState: boolean | null = null; let inProgressState: boolean | null = null;
await triggerSubsyncFromConfig( await triggerSubsyncFromConfig(
makeDeps({ makeDeps({
openManualPicker: (payload) => { openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length; payloadTrackCount = payload.sourceTracks.length;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
}, },
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
@@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
); );
assert.equal(payloadTrackCount, 1); assert.equal(payloadTrackCount, 1);
assert.equal(ffsubsyncAvailable, true);
assert.ok(osd.includes('Subsync: choose engine and source')); assert.ok(osd.includes('Subsync: choose engine and source'));
assert.equal(inProgressState, false); assert.equal(inProgressState, false);
}); });
test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => {
let ffsubsyncAvailable: boolean | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': 'https://jellyfin.example/subs/eng.srt',
},
];
}
return null;
},
}),
openManualPicker: (payload) => {
ffsubsyncAvailable = payload.ffsubsyncAvailable;
},
}),
);
assert.equal(ffsubsyncAvailable, false);
});
test('triggerSubsyncFromConfig does not run automatic sync', async () => { test('triggerSubsyncFromConfig does not run automatic sync', async () => {
const osd: string[] = []; const osd: string[] = [];
let payloadTrackCount = 0; let payloadTrackCount = 0;
+1
View File
@@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps
const client = getMpvClientForSubsync(deps); const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client); const context = await gatherSubsyncContext(client);
const payload: SubsyncManualPayload = { const payload: SubsyncManualPayload = {
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
sourceTracks: context.sourceTracks sourceTracks: context.sourceTracks
.filter((track) => typeof track.id === 'number') .filter((track) => typeof track.id === 'number')
.map((track) => ({ .map((track) => ({
@@ -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); 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 () => { test('shift subtitle delay throws when no next cue exists', async () => {
const handler = createShiftSubtitleDelayToAdjacentCueHandler({ const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () => getMpvClient: () =>
+8 -1
View File
@@ -21,6 +21,7 @@ type SubtitleDelayShiftDeps = {
loadSubtitleSourceText: (source: string) => Promise<string>; loadSubtitleSourceText: (source: string) => Promise<string>;
sendMpvCommand: (command: Array<string | number>) => void; sendMpvCommand: (command: Array<string | number>) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
onSubtitleDelayShifted?: (delaySeconds: number) => void;
}; };
function asTrackId(value: unknown): number | null { function asTrackId(value: unknown): number | null {
@@ -175,10 +176,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
throw new Error('MPV not connected.'); 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('track-list'),
client.requestProperty('sid'), client.requestProperty('sid'),
client.requestProperty('sub-start'), client.requestProperty('sub-start'),
client.requestProperty('sub-delay'),
]); ]);
const currentStart = const currentStart =
@@ -198,6 +200,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction); const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
const delta = targetStart - currentStart; const delta = targetStart - currentStart;
deps.sendMpvCommand(['add', 'sub-delay', delta]); 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}'); deps.showMpvOsd('Subtitle delay: ${sub-delay}');
}; };
} }
@@ -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);
});
+153
View File
@@ -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;
}
+10 -10
View File
@@ -129,7 +129,7 @@ test('tokenizeSubtitle splits same-line grammar endings before applying annotati
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); assert.equal(result.tokens?.[0]?.jlptLevel, 'N5');
assert.equal(result.tokens?.[0]?.frequencyRank, 40); assert.equal(result.tokens?.[0]?.frequencyRank, 40);
assert.equal(result.tokens?.[1]?.surface, 'です'); assert.equal(result.tokens?.[1]?.surface, 'です');
assert.equal(result.tokens?.[1]?.isKnown, false); assert.equal(result.tokens?.[1]?.isKnown, true);
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
assert.equal(result.tokens?.[1]?.jlptLevel, undefined); assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
@@ -3365,7 +3365,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
}); });
test('tokenizeSubtitle clears known-word highlight for exact non-independent kanji noun tokens', async () => { test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanji noun tokens', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'その点', 'その点',
makeDepsFromYomitanTokens( makeDepsFromYomitanTokens(
@@ -3413,7 +3413,7 @@ test('tokenizeSubtitle clears known-word highlight for exact non-independent kan
assert.equal(result.tokens?.length, 2); assert.equal(result.tokens?.length, 2);
assert.equal(result.tokens?.[0]?.isKnown, false); assert.equal(result.tokens?.[0]?.isKnown, false);
assert.equal(result.tokens?.[1]?.surface, '点'); assert.equal(result.tokens?.[1]?.surface, '点');
assert.equal(result.tokens?.[1]?.isKnown, false); assert.equal(result.tokens?.[1]?.isKnown, true);
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
assert.equal(result.tokens?.[1]?.jlptLevel, undefined); assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
@@ -4028,7 +4028,7 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper
{ {
surface: 'これで', surface: 'これで',
headword: 'これ', headword: 'これ',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -4143,7 +4143,7 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
{ {
surface: 'のかな', surface: 'のかな',
headword: 'の', headword: 'の',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -4672,7 +4672,7 @@ test('tokenizeSubtitle clears annotations for ja-nai explanatory endings and aru
{ {
surface: 'ある', surface: 'ある',
headword: 'ある', headword: 'ある',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -4717,7 +4717,7 @@ test('tokenizeSubtitle clears annotations for standalone polite copula endings w
{ {
surface: 'ですよ', surface: 'ですよ',
headword: 'です', headword: 'です',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -5044,7 +5044,7 @@ test('tokenizeSubtitle clears annotations for auxiliary inflection fragments whi
{ {
surface: 'れた', surface: 'れた',
headword: 'れる', headword: 'れる',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -5181,7 +5181,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
{ {
surface: 'てく', surface: 'てく',
headword: 'てく', headword: 'てく',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -5192,7 +5192,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
{ {
surface: 'れた', surface: 'れた',
headword: 'れる', headword: 'れる',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -425,6 +425,21 @@ test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside exp
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
}); });
test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-independent kanji nouns from non-known annotations', () => {
const token = makeToken({
surface: '以外',
headword: '以外',
reading: 'イガイ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '副詞可能',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => { test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
@@ -971,8 +986,8 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, false);
assert.equal(result[1]?.isKnown, false); assert.equal(result[1]?.isKnown, true);
assert.equal(result[2]?.isKnown, false); assert.equal(result[2]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
@@ -1186,7 +1201,7 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
test('annotateTokens clears known-word status for non-independent kanji noun tokens', () => { test('annotateTokens keeps known-word status for non-independent kanji noun tokens', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: '点', surface: '点',
@@ -1211,12 +1226,41 @@ test('annotateTokens clears known-word status for non-independent kanji noun tok
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => {
const tokens = [
makeToken({
surface: '以外',
reading: 'イガイ',
headword: '以外',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '副詞可能',
startPos: 2,
endPos: 4,
frequencyRank: 437,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '以外',
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => { test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
@@ -1401,7 +1445,7 @@ test('annotateTokens excludes composite tokens when all component pos tags are e
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
test('annotateTokens applies one shared exclusion gate across known N+1 frequency and JLPT', () => { test('annotateTokens lets known words bypass the shared exclusion gate for known status only', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'これで', surface: 'これで',
@@ -1425,13 +1469,13 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for kana-only non-independent noun helper merges', () => { test('annotateTokens keeps known status while clearing other annotations for kana-only non-independent noun helper merges', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'ことに', surface: 'ことに',
@@ -1455,13 +1499,13 @@ test('annotateTokens clears known status and other annotations for kana-only non
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for standalone auxiliary inflection fragments', () => { test('annotateTokens keeps known status while clearing other annotations for standalone auxiliary inflection fragments', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'れる', surface: 'れる',
@@ -1497,14 +1541,14 @@ test('annotateTokens clears known status and other annotations for standalone au
); );
for (const token of result) { for (const token of result) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
} }
}); });
test('annotateTokens clears known status and other annotations for auxiliary-only te-kureru helper spans', () => { test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'てく', surface: 'てく',
@@ -1540,7 +1584,7 @@ test('annotateTokens clears known status and other annotations for auxiliary-onl
); );
for (const token of result) { for (const token of result) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
@@ -1576,7 +1620,7 @@ test('annotateTokens keeps lexical くれる forms eligible for annotation', ()
assert.equal(result[0]?.jlptLevel, 'N4'); assert.equal(result[0]?.jlptLevel, 'N4');
}); });
test('annotateTokens clears known status and other annotations for standalone して helper fragments', () => { test('annotateTokens keeps known status while clearing other annotations for standalone して helper fragments', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'してる', surface: 'してる',
@@ -1600,13 +1644,13 @@ test('annotateTokens clears known status and other annotations for standalone
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for standalone particle fragments without POS tags', () => { test('annotateTokens keeps known status while clearing other annotations for standalone particle fragments without POS tags', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'と', surface: 'と',
@@ -1630,13 +1674,13 @@ test('annotateTokens clears known status and other annotations for standalone pa
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status from standalone particles even when the known-word cache contains them', () => { test('annotateTokens keeps known status on standalone particles when the known-word cache contains them', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'に', surface: 'に',
@@ -1671,7 +1715,7 @@ test('annotateTokens clears known status from standalone particles even when the
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
@@ -1728,7 +1772,7 @@ test('annotateTokens does not mark standalone connective particles as N+1', () =
assert.equal(result[1]?.jlptLevel, undefined); assert.equal(result[1]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for rhetorical もんか grammar particle phrases', () => { test('annotateTokens keeps known status while clearing other annotations for rhetorical もんか grammar particle phrases', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'もんか', surface: 'もんか',
@@ -1752,13 +1796,13 @@ test('annotateTokens clears known status and other annotations for rhetorical
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for bare くれ auxiliary fragments', () => { test('annotateTokens keeps known status while clearing other annotations for bare くれ auxiliary fragments', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'くれ', surface: 'くれ',
@@ -1782,13 +1826,13 @@ test('annotateTokens clears known status and other annotations for bare くれ a
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for aru existence verbs', () => { test('annotateTokens keeps known status while clearing other annotations for aru existence verbs', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: '有る', surface: '有る',
@@ -1818,14 +1862,14 @@ test('annotateTokens clears known status and other annotations for aru existence
assert.equal(result[0]?.surface, '有る'); assert.equal(result[0]?.surface, '有る');
assert.equal(result[0]?.headword, '有る'); assert.equal(result[0]?.headword, '有る');
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.isNameMatch, false); assert.equal(result[0]?.isNameMatch, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for standalone quote particle and auxiliary grammar terms', () => { test('annotateTokens keeps known status while clearing other annotations for standalone quote particle and auxiliary grammar terms', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'って', surface: 'って',
@@ -1861,14 +1905,14 @@ test('annotateTokens clears known status and other annotations for standalone qu
); );
for (const token of result) { for (const token of result) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
} }
}); });
test('annotateTokens clears known status and other annotations from standalone あ interjections without POS tags', () => { test('annotateTokens keeps known status while clearing other annotations from standalone あ interjections without POS tags', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'あ', surface: 'あ',
@@ -1898,13 +1942,13 @@ test('annotateTokens clears known status and other annotations from standalone
assert.equal(result[0]?.surface, 'あ'); assert.equal(result[0]?.surface, 'あ');
assert.equal(result[0]?.headword, 'あ'); assert.equal(result[0]?.headword, 'あ');
assert.equal(result[0]?.reading, 'あ'); assert.equal(result[0]?.reading, 'あ');
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears all annotations from expressive subtitle interjections without POS tags', () => { test('annotateTokens keeps known status while clearing other annotations from expressive subtitle interjections without POS tags', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'ハァ', surface: 'ハァ',
@@ -1960,7 +2004,7 @@ test('annotateTokens clears all annotations from expressive subtitle interjectio
); );
for (const token of result.slice(0, 2)) { for (const token of result.slice(0, 2)) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
@@ -680,6 +680,11 @@ export function annotateTokens(
// Single pass: compute known word status, frequency filtering, and JLPT level together // Single pass: compute known word status, frequency filtering, and JLPT level together
const annotated = tokens.map((token, index) => { const annotated = tokens.map((token, index) => {
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
if ( if (
sharedShouldExcludeTokenFromSubtitleAnnotations(token, { sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
pos1Exclusions, pos1Exclusions,
@@ -690,18 +695,13 @@ export function annotateTokens(
pos1Exclusions, pos1Exclusions,
pos2Exclusions, pos2Exclusions,
}); });
nPlusOneKnownStatuses[index] = false;
return { return {
...strippedToken, ...strippedToken,
isKnown: false, isKnown: knownWordsEnabled ? isKnownForMatching : false,
}; };
} }
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true; const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
const frequencyRank = const frequencyRank =
frequencyEnabled && !prioritizedNameMatch frequencyEnabled && !prioritizedNameMatch
+71
View File
@@ -15,6 +15,9 @@ import {
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue,
} from './main-entry-runtime'; } from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
@@ -106,6 +109,74 @@ test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({}), false); assert.equal(hasTransportedStartupArgs({}), false);
}); });
test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => {
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret');
assert.equal(
resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'),
'gnome-libsecret',
);
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null);
});
test('resolveLinuxPasswordStoreValue keeps scanning after a bare password-store flag', () => {
assert.equal(
resolveLinuxPasswordStoreValue(
['SubMiner.AppImage', '--password-store', '--start', '--password-store=kwallet6'],
'linux',
),
'kwallet6',
);
});
test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => {
const switches: Array<[string, string | undefined]> = [];
applyEarlyLinuxCommandLineSwitches(
{
appendSwitch: (name, value) => {
switches.push([name, value]);
},
},
['SubMiner.AppImage', '--password-store=kwallet6'],
'linux',
);
assert.deepEqual(switches, [
['enable-features', 'GlobalShortcutsPortal'],
['password-store', 'kwallet6'],
]);
});
test('transported AppImage visibility commands should forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--hide-visible-overlay',
}),
true,
);
});
test('app control forwarding is only for transported runtime commands', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--app-ping',
}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--launch-mpv',
}),
false,
);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
+74 -1
View File
@@ -1,11 +1,12 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { CliArgs, parseArgs, shouldStartApp } from './cli/args'; import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
const BACKGROUND_ARG = '--background'; const BACKGROUND_ARG = '--background';
const START_ARG = '--start'; const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store'; const PASSWORD_STORE_ARG = '--password-store';
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
@@ -34,6 +35,10 @@ type EarlyAppLike = {
setPath: (name: 'userData', value: string) => void; setPath: (name: 'userData', value: string) => void;
}; };
type CommandLineLike = {
appendSwitch: (name: string, value?: string) => void;
};
type EarlyAppPathOptions = { type EarlyAppPathOptions = {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
appDataDir?: string; appDataDir?: string;
@@ -73,6 +78,60 @@ function removePassiveStartupArgs(argv: string[]): string[] {
return filtered; return filtered;
} }
function getPasswordStoreArg(argv: string[]): string | null {
let resolved: string | null = null;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
continue;
}
if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1];
if (value && !value.startsWith('--')) {
resolved = value.trim();
i += 1;
}
continue;
}
const [prefix, value] = arg.split('=', 2);
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
resolved = value.trim();
}
}
return resolved;
}
function normalizePasswordStoreArg(value: string): string {
const normalized = value.trim();
if (normalized.toLowerCase() === 'gnome') {
return DEFAULT_LINUX_PASSWORD_STORE;
}
return normalized;
}
export function resolveLinuxPasswordStoreValue(
argv: string[],
platform: NodeJS.Platform = process.platform,
): string | null {
if (platform !== 'linux') return null;
return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE);
}
export function applyEarlyLinuxCommandLineSwitches(
commandLine: CommandLineLike,
argv: string[],
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== 'linux') return;
commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
commandLine.appendSwitch(
'password-store',
resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE,
);
}
function consumesLaunchMpvValue(token: string): boolean { function consumesLaunchMpvValue(token: string): boolean {
return ( return (
token.startsWith('--') && token.startsWith('--') &&
@@ -90,6 +149,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string'; return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
} }
export function shouldForwardStartupArgvViaAppControl(
argv: string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!hasTransportedStartupArgs(env)) return false;
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
return hasExplicitCommand(args);
}
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) { if (rawCount === undefined) {
+51 -12
View File
@@ -9,17 +9,20 @@ import {
normalizeLaunchMpvExtraArgs, normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
normalizeStartupArgv, normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches,
sanitizeStartupEnv, sanitizeStartupEnv,
sanitizeBackgroundEnv, sanitizeBackgroundEnv,
sanitizeHelpEnv, sanitizeHelpEnv,
sanitizeLaunchMpvEnv, sanitizeLaunchMpvEnv,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
shouldDetachBackgroundLaunch, shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry, shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime'; } from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { sendAppControlCommand } from './shared/app-control-client';
import { import {
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
@@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
} }
process.argv = normalizeStartupArgv(process.argv, process.env); process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env)); applySanitizedEnv(sanitizeStartupEnv(process.env));
const userDataPath = configureEarlyAppPaths(app); const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({ const reportFatalError = createFatalErrorReporter({
@@ -184,6 +188,44 @@ registerFatalErrorHandlers({
exit: (code) => app.exit(code), exit: (code) => app.exit(code),
}); });
function startMainProcess(): void {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
return;
}
try {
require('./main.js');
} catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
});
app.exit(1);
}
}
async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) {
return false;
}
const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath,
timeoutMs: 500,
});
if (result.ok) {
app.exit(0);
return true;
}
if (!result.unavailable) {
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
app.exit(1);
return true;
}
return false;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) { if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, { const child = spawn(process.execPath, childArgs, {
@@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
app.exit(exitCode); app.exit(exitCode);
}); });
} else { } else {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); void forwardStartupArgvViaAppControlIfAvailable()
if (!gotSingleInstanceLock) { .then((forwarded) => {
app.exit(0); if (!forwarded) {
} startMainProcess();
try { }
require('./main.js'); })
} catch (error) { .catch((error) => {
reportFatalError(error, { console.error('SubMiner app-control handoff failed:', error);
title: 'SubMiner startup failed', startMainProcess();
context: 'SubMiner failed while loading the main process.',
}); });
app.exit(1);
}
} }
+181 -21
View File
@@ -35,6 +35,10 @@ import { applyControllerConfigUpdate } from './main/controller-config-update.js'
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server'; import { startAppControlServer } from './main/runtime/app-control-server';
import {
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
} from './main/runtime/jellyfin-remote-playback';
import { getAppControlSocketPath } from './shared/app-control'; import { getAppControlSocketPath } from './shared/app-control';
import { import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst, type CancelLinuxMpvFullscreenOverlayRefreshBurst,
@@ -44,6 +48,7 @@ import {
import { mergeAiConfig } from './ai/config'; import { mergeAiConfig } from './ai/config';
function getPasswordStoreArg(argv: string[]): string | null { function getPasswordStoreArg(argv: string[]): string | null {
let resolved: string | null = null;
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
if (!arg?.startsWith('--password-store')) { if (!arg?.startsWith('--password-store')) {
@@ -53,17 +58,18 @@ function getPasswordStoreArg(argv: string[]): string | null {
if (arg === '--password-store') { if (arg === '--password-store') {
const value = argv[i + 1]; const value = argv[i + 1];
if (value && !value.startsWith('--')) { if (value && !value.startsWith('--')) {
return value; resolved = value.trim();
i += 1;
} }
return null; continue;
} }
const [prefix, value] = arg.split('=', 2); const [prefix, value] = arg.split('=', 2);
if (prefix === '--password-store' && value && value.trim().length > 0) { if (prefix === '--password-store' && value && value.trim().length > 0) {
return value.trim(); resolved = value.trim();
} }
} }
return null; return resolved;
} }
function normalizePasswordStoreArg(value: string): string { function normalizePasswordStoreArg(value: string): string {
@@ -319,6 +325,7 @@ import {
listJellyfinItemsRuntime, listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime, listJellyfinLibrariesRuntime,
listJellyfinSubtitleTracksRuntime, listJellyfinSubtitleTracksRuntime,
loadJellyfinSubtitleDelay,
loadSubtitlePosition as loadSubtitlePositionCore, loadSubtitlePosition as loadSubtitlePositionCore,
loadYomitanExtension as loadYomitanExtensionCore, loadYomitanExtension as loadYomitanExtensionCore,
markLastCardAsAudioCard as markLastCardAsAudioCardCore, markLastCardAsAudioCard as markLastCardAsAudioCardCore,
@@ -329,6 +336,7 @@ import {
replayCurrentSubtitleRuntime, replayCurrentSubtitleRuntime,
resolveJellyfinPlaybackPlanRuntime, resolveJellyfinPlaybackPlanRuntime,
runStartupBootstrapRuntime, runStartupBootstrapRuntime,
saveJellyfinSubtitleDelay,
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
addYomitanNoteViaSearch, addYomitanNoteViaSearch,
clearYomitanParserCachesForWindow, clearYomitanParserCachesForWindow,
@@ -356,6 +364,7 @@ import {
promoteStatsOverlayAbovePlayback, promoteStatsOverlayAbovePlayback,
registerStatsOverlayToggle, registerStatsOverlayToggle,
toggleStatsOverlay as toggleStatsOverlayWindow, toggleStatsOverlay as toggleStatsOverlayWindow,
withStatsWindowLayerSuspendedForNativeDialog,
} from './core/services/stats-window.js'; } from './core/services/stats-window.js';
import { import {
createFirstRunSetupService, createFirstRunSetupService,
@@ -403,6 +412,11 @@ import {
launchWindowsMpv, launchWindowsMpv,
} from './main/runtime/windows-mpv-launch'; } from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './main/runtime/jellyfin-device-identity';
import { import {
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime, clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
@@ -508,6 +522,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { import {
createElectronAppUpdater, createElectronAppUpdater,
@@ -619,6 +634,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
appDataDir: process.env.APPDATA, appDataDir: process.env.APPDATA,
}); });
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => os.tmpdir(),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir, options) => {
fs.rmSync(dir, options);
},
fetch: (url) => fetch(url),
});
const ANILIST_SETUP_RESPONSE_TYPE = 'token'; const ANILIST_SETUP_RESPONSE_TYPE = 'token';
const ANILIST_DEFAULT_CLIENT_ID = '36084'; const ANILIST_DEFAULT_CLIENT_ID = '36084';
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
@@ -639,6 +663,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
@@ -647,16 +672,18 @@ const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best'; const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b'; const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
const MPV_JELLYFIN_DEFAULT_ARGS = [ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--sub-auto=fuzzy', '--sub-auto=no',
'--sub-file-paths=.;subs;subtitles', '--sub-file-paths=.;subs;subtitles',
'--sid=auto', '--sid=no',
'--secondary-sid=auto', '--secondary-sid=no',
'--sub-visibility=no',
'--secondary-sub-visibility=no', '--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const; ] as const;
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null;
let jellyfinRemoteLastProgressAtMs = 0; let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null; let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
let backgroundWarmupsStarted = false; let backgroundWarmupsStarted = false;
@@ -1803,12 +1830,13 @@ async function refreshSubtitleSidebarFromSource(
if (!normalizedSourcePath) { if (!normalizedSourcePath) {
return; return;
} }
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
await subtitlePrefetchInitController.initSubtitlePrefetch( await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath, normalizedSourcePath,
lastObservedTimePos, lastObservedTimePos,
normalizedSourcePath, normalizedSourcePath,
); );
appState.activeParsedSubtitleMediaPath = nextMediaPath;
} }
const refreshSubtitlePrefetchFromActiveTrackHandler = const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({ createRefreshSubtitlePrefetchFromActiveTrackHandler({
@@ -2115,6 +2143,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback;
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); 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( const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({ createBuildMediaRuntimeMainDepsHandler({
@@ -2280,6 +2309,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown; appState.trackerNotReadyWarningShown = shown;
@@ -2323,6 +2353,7 @@ const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncInFlight = false;
@@ -2331,6 +2362,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0; let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false; let visibleOverlayInteractionActive = false;
let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => { setStatsOverlayVisibleState: (visible) => {
@@ -2357,6 +2391,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
windowsVisibleOverlayZOrderRetryTimeouts = []; windowsVisibleOverlayZOrderRetryTimeouts = [];
} }
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
if (token !== macOSVisibleOverlayForegroundProbeToken) {
return;
}
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
macOSVisibleOverlayForegroundProbeTimeout = null;
}
if (!macOSVisibleOverlayForegroundProbeActive) {
return;
}
macOSVisibleOverlayForegroundProbeActive = false;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
function startMacOSVisibleOverlayForegroundProbe(): void {
if (process.platform !== 'darwin') {
return;
}
const tracker = appState.windowTracker;
if (!tracker) {
return;
}
macOSVisibleOverlayForegroundProbeActive = true;
const token = ++macOSVisibleOverlayForegroundProbeToken;
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
}
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
void tracker
.refreshNow()
.catch((error) => {
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
})
.finally(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
});
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string { function getWindowsNativeWindowHandle(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle(); const handle = window.getNativeWindowHandle();
return handle.length >= 8 return handle.length >= 8
@@ -2555,6 +2632,7 @@ function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform === 'win32') { if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now(); lastWindowsVisibleOverlayBlurredAtMs = Date.now();
} }
startMacOSVisibleOverlayForegroundProbe();
clearVisibleOverlayBlurRefreshTimeouts(); clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => { const refreshTimeout = setTimeout(() => {
@@ -2801,6 +2879,7 @@ const {
reportJellyfinRemoteStopped, reportJellyfinRemoteStopped,
startJellyfinRemoteSession, startJellyfinRemoteSession,
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
cleanupJellyfinSubtitleCache,
runJellyfinCommand, runJellyfinCommand,
openJellyfinSetupWindow, openJellyfinSetupWindow,
getJellyfinClientInfo, getJellyfinClientInfo,
@@ -2812,7 +2891,9 @@ const {
}, },
getJellyfinClientInfoMainDeps: { getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, getHostName: () => os.hostname(),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
}, },
waitForMpvConnectedMainDeps: { waitForMpvConnectedMainDeps: {
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
@@ -2824,6 +2905,15 @@ const {
getLaunchMode: () => getResolvedConfig().mpv.launchMode, getLaunchMode: () => getResolvedConfig().mpv.launchMode,
platform: process.platform, platform: process.platform,
execPath: process.execPath, execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
@@ -2859,6 +2949,25 @@ const {
sendMpvCommandRuntime(appState.mpvClient, command); sendMpvCommandRuntime(appState.mpvClient, command);
}, },
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), 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) => { logDebug: (message, error) => {
logger.debug(message, error); logger.debug(message, error);
}, },
@@ -2877,6 +2986,7 @@ const {
}, },
), ),
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
showVisibleOverlay: () => setVisibleOverlayVisible(true),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
armQuitOnDisconnect: () => { armQuitOnDisconnect: () => {
jellyfinPlayQuitOnDisconnectArmed = false; jellyfinPlayQuitOnDisconnectArmed = false;
@@ -2889,7 +2999,11 @@ const {
}, },
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
setActivePlayback: (state) => { setActivePlayback: (state) => {
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; activeJellyfinRemotePlayback = {
...(state as ActiveJellyfinRemotePlaybackState),
stopReportsAfterMs:
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
};
}, },
setLastProgressAtMs: (value) => { setLastProgressAtMs: (value) => {
jellyfinRemoteLastProgressAtMs = value; jellyfinRemoteLastProgressAtMs = value;
@@ -2900,6 +3014,13 @@ const {
showMpvOsd: (text) => { showMpvOsd: (text) => {
showMpvOsd(text); showMpvOsd(text);
}, },
updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title);
},
recordJellyfinPlaybackMetadata: (metadata) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
},
}, },
remoteComposerOptions: { remoteComposerOptions: {
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
@@ -2910,6 +3031,7 @@ const {
getActivePlayback: () => activeJellyfinRemotePlayback, getActivePlayback: () => activeJellyfinRemotePlayback,
clearActivePlayback: () => { clearActivePlayback: () => {
activeJellyfinRemotePlayback = null; activeJellyfinRemotePlayback = null;
activeJellyfinSubtitleDelayKey = null;
}, },
getSession: () => appState.jellyfinRemoteSession, getSession: () => appState.jellyfinRemoteSession,
getNow: () => Date.now(), getNow: () => Date.now(),
@@ -2960,11 +3082,13 @@ const {
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
}, },
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, getHostName: () => os.hostname(),
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message, details) => logger.warn(message, details), logWarn: (message, details) => logger.warn(message, details),
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
stopJellyfinRemoteSessionMainDeps: { stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => appState.jellyfinRemoteSession, getCurrentSession: () => appState.jellyfinRemoteSession,
@@ -2974,6 +3098,7 @@ const {
clearActivePlayback: () => { clearActivePlayback: () => {
activeJellyfinRemotePlayback = null; activeJellyfinRemotePlayback = null;
}, },
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
runJellyfinCommandMainDeps: { runJellyfinCommandMainDeps: {
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
@@ -2994,7 +3119,6 @@ const {
clearStoredSession: () => clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()), clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => { patchJellyfinConfig: (session) => {
const clientInfo = getJellyfinClientInfo();
const recentServers = mergeJellyfinRecentServers( const recentServers = mergeJellyfinRecentServers(
session.serverUrl, session.serverUrl,
getResolvedConfig().jellyfin.recentServers || [], getResolvedConfig().jellyfin.recentServers || [],
@@ -3004,9 +3128,6 @@ const {
enabled: true, enabled: true,
serverUrl: session.serverUrl, serverUrl: session.serverUrl,
username: session.username, username: session.username,
deviceId: clientInfo.deviceId,
clientName: clientInfo.clientName,
clientVersion: clientInfo.clientVersion,
recentServers, recentServers,
}, },
}); });
@@ -3670,6 +3791,7 @@ const {
}, },
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => { stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop(); void appState.discordPresenceService?.stop();
appState.discordPresenceService = null; appState.discordPresenceService = null;
@@ -4331,11 +4453,12 @@ const {
appState.activeParsedSubtitleSource = null; appState.activeParsedSubtitleSource = null;
appState.activeParsedSubtitleMediaPath = null; appState.activeParsedSubtitleMediaPath = null;
} }
activeJellyfinSubtitleDelayKey = null;
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
} }
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
currentMediaTokenizationGate.updateCurrentMediaPath(path); currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
startupOsdSequencer.reset(); startupOsdSequencer.reset();
@@ -4372,6 +4495,9 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState(); immersionMediaRuntime.syncFromCurrentMediaState();
}, },
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
markJellyfinRemotePlaybackLoaded: (path) => {
markJellyfinRemotePlaybackLoadedState(activeJellyfinRemotePlayback, path);
},
scheduleCharacterDictionarySync: () => { scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return; return;
@@ -4435,6 +4561,8 @@ const {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer; appState.reconnectTimer = timer;
}, },
shouldAutoLoadSecondarySubTrack: (path: string) =>
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(activeJellyfinRemotePlayback, path),
shouldQuitOnMpvShutdown: () => shouldQuitOnMpvShutdown: () =>
shouldQuitOnMpvShutdownForTrayState({ shouldQuitOnMpvShutdownForTrayState({
managedPlayback: appState.initialArgs?.managedPlayback === true, managedPlayback: appState.initialArgs?.managedPlayback === true,
@@ -5088,6 +5216,8 @@ function getUpdateService() {
}); });
app.focus({ steal: true }); app.focus({ steal: true });
}, },
withStatsWindowLayerSuspended: (showDialog) =>
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options), showMessageBox: (options) => dialog.showMessageBox(options),
}); });
updateService = createUpdateService({ updateService = createUpdateService({
@@ -5418,6 +5548,19 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
loadSubtitleSourceText, loadSubtitleSourceText,
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), 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), showMpvOsd: (text) => showMpvOsd(text),
}); });
@@ -6062,6 +6205,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildTrayMenuTemplateDeps: { buildTrayMenuTemplateDeps: {
buildTrayMenuTemplateRuntime, buildTrayMenuTemplateRuntime,
platform: process.platform,
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
openSessionHelpModal: () => openSessionHelpOverlay(), openSessionHelpModal: () => openSessionHelpOverlay(),
@@ -6077,8 +6221,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
isJellyfinConfigured: () => isJellyfinConfigured: () =>
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
toggleJellyfinDiscovery: () => toggleJellyfinDiscovery: (checked: boolean) =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()), toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
desiredActive: checked,
}),
openAnilistSetupWindow: () => openAnilistSetupWindow(), openAnilistSetupWindow: () => openAnilistSetupWindow(),
checkForUpdates: () => { checkForUpdates: () => {
void getUpdateService().checkForUpdates({ source: 'manual' }); void getUpdateService().checkForUpdates({ source: 'manual' });
@@ -6307,36 +6453,50 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
} }
} }
function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden',
]);
}
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
setVisibleOverlayVisibleHandler(visible); setVisibleOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (overlayManager.getVisibleOverlayVisible()) { const nextVisible = !overlayManager.getVisibleOverlayVisible();
if (!nextVisible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else { } else {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
toggleVisibleOverlayHandler(); toggleVisibleOverlayHandler();
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
setOverlayVisibleHandler(visible); setOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function handleOverlayModalClosed(modal: OverlayHostedModal): void { function handleOverlayModalClosed(modal: OverlayHostedModal): void {
+59
View File
@@ -46,6 +46,65 @@ test('media path changes clear rendered subtitle state without clearing same-you
); );
}); });
test('same media path updates do not reset autoplay ready fallback state', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/,
);
});
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
const source = readMainSource();
const actionBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
});
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
const source = readMainSource();
const actionBlock = source.match(
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
);
assert.ok(
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
);
});
test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
});
test('main process uses one shared mpv plugin runtime config helper', () => { test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource(); const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
+15 -3
View File
@@ -9,6 +9,7 @@ type MockWindow = {
ignoreMouseEvents: boolean; ignoreMouseEvents: boolean;
forwardedIgnoreMouseEvents: boolean; forwardedIgnoreMouseEvents: boolean;
webContentsFocused: boolean; webContentsFocused: boolean;
alwaysOnTopCalls: string[];
showCount: number; showCount: number;
hideCount: number; hideCount: number;
sent: unknown[][]; sent: unknown[][];
@@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & {
ignoreMouseEvents: false, ignoreMouseEvents: false,
forwardedIgnoreMouseEvents: false, forwardedIgnoreMouseEvents: false,
webContentsFocused: false, webContentsFocused: false,
alwaysOnTopCalls: [],
showCount: 0, showCount: 0,
hideCount: 0, hideCount: 0,
sent: [], sent: [],
@@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & {
state.ignoreMouseEvents = ignore; state.ignoreMouseEvents = ignore;
state.forwardedIgnoreMouseEvents = options?.forward === true; state.forwardedIgnoreMouseEvents = options?.forward === true;
}, },
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
},
moveTop: () => {}, moveTop: () => {},
getShowCount: () => state.showCount, getShowCount: () => state.showCount,
getHideCount: () => state.hideCount, getHideCount: () => state.hideCount,
@@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & {
}, },
}); });
Object.defineProperty(window, 'alwaysOnTopCalls', {
get: () => state.alwaysOnTopCalls,
set: (value: string[]) => {
state.alwaysOnTopCalls = value;
},
});
Object.defineProperty(window, 'url', { Object.defineProperty(window, 'url', {
get: () => state.url, get: () => state.url,
set: (value: string) => { set: (value: string) => {
@@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.equal(window.getShowCount(), 1); assert.equal(window.getShowCount(), 1);
assert.equal(window.isFocused(), true); assert.equal(window.isFocused(), true);
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
}); });
@@ -313,7 +325,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
}); });
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'subsync:open-manual', 'subsync:open-manual',
{ sourceTracks: [] }, { ffsubsyncAvailable: true, sourceTracks: [] },
{ {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}, },
@@ -459,7 +471,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact
}); });
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'subsync:open-manual', 'subsync:open-manual',
{ sourceTracks: [] }, { ffsubsyncAvailable: true, sourceTracks: [] },
{ {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}, },
+1 -1
View File
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
const elevateModalWindow = (window: BrowserWindow): void => { const elevateModalWindow = (window: BrowserWindow): void => {
if (window.isDestroyed()) return; if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 1); window.setAlwaysOnTop(true, 'screen-saver', 3);
window.moveTop(); window.moveTop();
}; };
+2
View File
@@ -17,6 +17,7 @@ export interface OverlayVisibilityRuntimeDeps {
getLastKnownWindowsForegroundProcessName?: () => string | null; getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null;
getWindowsFocusHandoffGraceActive?: () => boolean; getWindowsFocusHandoffGraceActive?: () => boolean;
getMacOSForegroundProbeActive?: () => boolean;
getTrackerNotReadyWarningShown: () => boolean; getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
@@ -59,6 +60,7 @@ export function createOverlayVisibilityRuntimeService(
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null, windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false, windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false,
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
deps.setTrackerNotReadyWarningShown(shown); deps.setTrackerNotReadyWarningShown(shown);
+47 -1
View File
@@ -41,19 +41,65 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
cleanup(); cleanup();
assert.equal(calls.length, 31); assert.equal(calls.length, 32);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
}); });
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
});
test('should restore windows on activate requires initialized runtime and no windows', () => { test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false; let initialized = false;
let windowCount = 1; let windowCount = 1;
+6 -1
View File
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return (): void => { return (): void => {
@@ -60,7 +61,11 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow(); deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow(); deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow(); deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession(); try {
deps.stopJellyfinRemoteSession();
} finally {
deps.cleanupJellyfinSubtitleCache();
}
deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeSubtitleTempDirs();
deps.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
}; };
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -91,6 +92,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
@@ -145,6 +147,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -194,6 +197,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return () => ({ return () => ({
@@ -141,6 +142,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
}); });
} }
+80 -1
View File
@@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
), ),
); );
assert.equal(scheduled.length > 0, true);
}); });
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
@@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
); );
}); });
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let playbackPaused = true;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
const retry = scheduled.shift();
retry?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
const commands: Array<Array<string | boolean>> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.markCurrentMediaAutoplayReady();
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => { test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = []; const commands: Array<Array<string | boolean>> = [];
let targetReady = false; let targetReady = false;
+16
View File
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const getSignalMediaPath = (): string => const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__'; deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const markCurrentMediaAutoplayReady = (): void => {
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = getSignalMediaPath();
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = ( const maybeSignalPluginAutoplayReady = (
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
forceWhilePaused: options?.forceWhilePaused === true, forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs, retryDelayMs: releaseRetryDelayMs,
}); });
let releaseUnpauseSent = false;
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => { const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
try { try {
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
deps.logDebug(
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
);
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient); const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!shouldUnpause) { if (!shouldUnpause) {
return; return;
} }
mpvClient.send({ command: ['set_property', 'pause', false] }); mpvClient.send({ command: ['set_property', 'pause', false] });
releaseUnpauseSent = true;
if (attempt < maxReleaseAttempts) { if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
} }
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
flushPendingAutoplayReadySignal, flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath, getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks, invalidatePendingAutoplayReadyFallbacks,
markCurrentMediaAutoplayReady,
maybeSignalPluginAutoplayReady, maybeSignalPluginAutoplayReady,
}; };
} }
@@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers(
getActivePlayback: options.getActivePlayback, getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback, clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession, getSession: options.getSession,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug, logDebug: options.logDebug,
}); });
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
@@ -101,6 +104,7 @@ export function composeJellyfinRemoteHandlers(
getConfiguredSession: options.getConfiguredSession, getConfiguredSession: options.getConfiguredSession,
getClientInfo: options.getClientInfo, getClientInfo: options.getClientInfo,
getJellyfinConfig: options.getJellyfinConfig, getJellyfinConfig: options.getJellyfinConfig,
getActivePlayback: options.getActivePlayback,
playJellyfinItem: options.playJellyfinItem, playJellyfinItem: options.playJellyfinItem,
logWarn: options.logWarn, logWarn: options.logWarn,
}); });
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
}, },
getJellyfinClientInfoMainDeps: { getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never, getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({ getHostName: () => 'workstation',
clientName: 'SubMiner', defaultClientName: 'SubMiner',
clientVersion: 'test', defaultClientVersion: 'test',
deviceId: 'dev',
}),
}, },
waitForMpvConnectedMainDeps: { waitForMpvConnectedMainDeps: {
getMpvClient: () => null, getMpvClient: () => null,
@@ -50,6 +48,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
getMpvClient: () => null, getMpvClient: () => null,
sendMpvCommand: () => {}, sendMpvCommand: () => {},
wait: async () => {}, wait: async () => {},
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
cleanupCachedSubtitles: () => {},
logDebug: () => {}, logDebug: () => {},
}, },
playJellyfinItemInMpvMainDeps: { playJellyfinItemInMpvMainDeps: {
@@ -58,11 +58,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
mode: 'direct', mode: 'direct',
url: 'https://example.test/video.m3u8', url: 'https://example.test/video.m3u8',
title: 'Episode 1', title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0, startTimeTicks: 0,
audioStreamIndex: null, audioStreamIndex: null,
subtitleStreamIndex: null, subtitleStreamIndex: null,
}), }),
applyJellyfinMpvDefaults: () => {}, applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
armQuitOnDisconnect: () => {}, armQuitOnDisconnect: () => {},
schedule: () => undefined, schedule: () => undefined,
@@ -133,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
defaultDeviceId: 'dev', defaultDeviceId: 'dev',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: 'test', defaultClientVersion: 'test',
getHostName: () => 'workstation',
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
}, },
@@ -189,6 +195,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
assert.equal(typeof composed.playJellyfinItemInMpv, 'function'); assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
assert.equal(typeof composed.startJellyfinRemoteSession, 'function'); assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function'); assert.equal(typeof composed.runJellyfinCommand, 'function');
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
>; >;
startJellyfinRemoteSessionMainDeps: Omit< startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps, StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand' | 'getJellyfinConfig'
| 'getClientInfo'
| 'handlePlay'
| 'handlePlaystate'
| 'handleGeneralCommand'
>; >;
stopJellyfinRemoteSessionMainDeps: Parameters< stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
@@ -142,6 +146,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
typeof composeJellyfinRemoteHandlers typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand']; >['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>; playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
cleanupJellyfinSubtitleCache: () => void;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>; startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>; stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>; runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
@@ -235,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
createBuildStartJellyfinRemoteSessionMainDepsHandler({ createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps, ...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(), getJellyfinConfig: () => getResolvedJellyfinConfig(),
getClientInfo: () => getJellyfinClientInfo(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload), handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
@@ -280,6 +286,7 @@ export function composeJellyfinRuntimeHandlers(
handleJellyfinRemotePlaystate, handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand, handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv, playJellyfinItemInMpv,
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
startJellyfinRemoteSession, startJellyfinRemoteSession,
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
runJellyfinCommand, runJellyfinCommand,
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}, },
shouldRestoreWindowsOnActivateMainDeps: { shouldRestoreWindowsOnActivateMainDeps: {
+1
View File
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
export * from '../jellyfin-client-info-main-deps'; export * from '../jellyfin-client-info-main-deps';
export * from '../jellyfin-command-dispatch'; export * from '../jellyfin-command-dispatch';
export * from '../jellyfin-command-dispatch-main-deps'; export * from '../jellyfin-command-dispatch-main-deps';
export * from '../jellyfin-device-identity';
export * from '../jellyfin-playback-launch'; export * from '../jellyfin-playback-launch';
export * from '../jellyfin-playback-launch-main-deps'; export * from '../jellyfin-playback-launch-main-deps';
export * from '../jellyfin-remote-commands'; export * from '../jellyfin-remote-commands';
+33 -7
View File
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
enabled: true, enabled: true,
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
username: 'user', username: 'user',
deviceId: 'd1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: ['http://localhost'], recentServers: ['http://localhost'],
}, },
}); });
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
}); });
test('persistJellyfinAuthSession stores client metadata and recent servers', () => { test('persistJellyfinAuthSession stores session config and recent servers', () => {
let patchPayload: unknown = null; let patchPayload: unknown = null;
let storedSession: unknown = null; let storedSession: unknown = null;
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
enabled: true, enabled: true,
serverUrl: 'http://localhost:8096', serverUrl: 'http://localhost:8096',
username: 'alice', username: 'alice',
deviceId: 'device-1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: [ recentServers: [
'http://localhost:8096', 'http://localhost:8096',
'http://old.example:8096', 'http://old.example:8096',
@@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
}); });
}); });
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
let patchPayload: unknown = null;
persistJellyfinAuthSession({
session: {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
clientInfo: {
deviceId: 'subminer-local-pc',
clientName: 'SubMiner',
clientVersion: '1.0',
},
existingRecentServers: [],
saveStoredSession: () => {},
patchRawConfig: (patch) => {
patchPayload = patch;
},
});
assert.deepEqual(patchPayload, {
jellyfin: {
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: ['http://localhost:8096'],
},
});
});
test('jellyfin auth handler no-ops when no auth command', async () => { test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({ const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {}, patchRawConfig: () => {},

Some files were not shown because too many files have changed in this diff Show More