Compare commits

...

6 Commits

Author SHA1 Message Date
sudacode 098375c647 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
2026-05-22 02:27:40 -07:00
sudacode 83fdccb752 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
2026-05-22 02:07:10 -07:00
sudacode 58fd648185 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
2026-05-22 01:39:34 -07:00
sudacode 536d99251e 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
2026-05-22 01:37:37 -07:00
sudacode 1a7f015f4e 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
2026-05-22 00:31:36 -07:00
sudacode 3a2d7a282d 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
2026-05-22 00:29:14 -07:00
123 changed files with 3367 additions and 426 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.
@@ -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.
+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.
@@ -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.
@@ -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: 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
@@ -519,7 +519,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": {
@@ -636,14 +636,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
@@ -1050,6 +1050,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.
@@ -1258,7 +1259,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"],
@@ -1273,21 +1273,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.
@@ -1304,6 +1300,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
+4 -3
View File
@@ -29,7 +29,6 @@ SubMiner includes an optional Jellyfin CLI integration for:
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"pullPictures": false, "pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons",
@@ -50,7 +49,7 @@ subminer jellyfin -l \
--password 'your-password' --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. `subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username, and refreshes recent servers. Passwords are never stored.
3. List libraries: 3. List libraries:
@@ -70,7 +69,7 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d 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. After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. By default, Jellyfin sees the cast target as the OS hostname (`uname -n` on Linux). If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app: Stop discovery session/app:
@@ -124,6 +123,8 @@ Optional stream overrides:
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`. - 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. - Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`. - Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
- When SubMiner auto-launches mpv for Jellyfin playback, it injects the bundled mpv plugin unless an installed SubMiner mpv plugin is already present. This keeps mpv-side keybindings available without clicking the overlay first.
- Jellyfin playback shows the SubMiner visible overlay before selecting subtitle tracks, so `subtitleStyle` controls the rendered subtitle appearance. Use the overlay toggle shortcut if you want to hide it for a session.
## Cast To Device Mode (jellyfin-mpv-shim style) ## Cast To Device Mode (jellyfin-mpv-shim style)
+2
View File
@@ -190,6 +190,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.
## N+1 Word Highlighting ## N+1 Word Highlighting
+1 -5
View File
@@ -519,7 +519,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": {
@@ -636,14 +636,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
@@ -833,6 +833,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[][] = [];
+10 -1
View File
@@ -144,12 +144,21 @@ function M.create(ctx)
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.current_media_identity = media_identity state.current_media_identity = media_identity
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.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,
}) })
+28 -16
View File
@@ -31,6 +31,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
@@ -129,7 +139,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 +150,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 +190,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
@@ -224,7 +238,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")
@@ -399,9 +413,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,13 +435,13 @@ 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" if visibility_action ~= nil then
or "hide-visible-overlay" run_control_command_async(visibility_action, {
run_control_command_async(visibility_action, { socket_path = socket_path,
socket_path = socket_path, log_level = overrides.log_level,
log_level = overrides.log_level, })
}) end
return return
end end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
@@ -495,13 +506,13 @@ 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" if visibility_action ~= nil then
or "hide-visible-overlay"
run_control_command_async(visibility_action, { run_control_command_async(visibility_action, {
socket_path = socket_path, socket_path = socket_path,
log_level = overrides.log_level, log_level = overrides.log_level,
}) })
end
end end
end) end)
@@ -576,6 +587,7 @@ function M.create(ctx)
return return
end 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
+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
+101 -1
View File
@@ -1396,7 +1396,7 @@ 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") == 3,
"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 re-assert visible overlay on initial start, ready, and later file load"
) )
assert_true( assert_true(
@@ -1471,6 +1471,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") == 2,
"duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1531,6 +1558,10 @@ do
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"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
@@ -1564,6 +1595,75 @@ 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",
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") == 1,
"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") == 2,
"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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
+10 -3
View File
@@ -74,7 +74,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);
@@ -825,7 +828,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'),
@@ -836,6 +839,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"
} }
}`, }`,
@@ -850,7 +854,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', () => {
@@ -2462,6 +2467,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/);
@@ -126,14 +126,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
@@ -258,7 +258,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',
@@ -520,26 +521,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',
@@ -565,12 +546,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
@@ -364,9 +364,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
@@ -57,7 +57,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),
@@ -244,10 +243,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);
+4
View File
@@ -560,6 +560,10 @@ 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);
+23 -3
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;
@@ -292,14 +296,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 +535,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,
@@ -1213,6 +1213,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 = {
+11 -1
View File
@@ -70,6 +70,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;
@@ -115,6 +116,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 &&
@@ -124,7 +131,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 =
+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) => ({
+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
+103 -11
View File
@@ -399,6 +399,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,
@@ -502,6 +507,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,
@@ -608,6 +614,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/';
@@ -2237,6 +2252,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;
@@ -2280,6 +2296,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;
@@ -2288,6 +2305,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;
function clearVisibleOverlayBlurRefreshTimeouts(): void { function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) { for (const timeout of visibleOverlayBlurRefreshTimeouts) {
@@ -2303,6 +2323,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
@@ -2501,6 +2564,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(() => {
@@ -2747,6 +2811,7 @@ const {
reportJellyfinRemoteStopped, reportJellyfinRemoteStopped,
startJellyfinRemoteSession, startJellyfinRemoteSession,
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
cleanupJellyfinSubtitleCache,
runJellyfinCommand, runJellyfinCommand,
openJellyfinSetupWindow, openJellyfinSetupWindow,
getJellyfinClientInfo, getJellyfinClientInfo,
@@ -2758,7 +2823,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,
@@ -2770,6 +2837,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,
@@ -2805,6 +2881,8 @@ 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),
logDebug: (message, error) => { logDebug: (message, error) => {
logger.debug(message, error); logger.debug(message, error);
}, },
@@ -2823,6 +2901,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;
@@ -2846,6 +2925,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()),
@@ -2906,11 +2992,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,
@@ -2920,6 +3008,7 @@ const {
clearActivePlayback: () => { clearActivePlayback: () => {
activeJellyfinRemotePlayback = null; activeJellyfinRemotePlayback = null;
}, },
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
runJellyfinCommandMainDeps: { runJellyfinCommandMainDeps: {
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
@@ -2940,7 +3029,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 || [],
@@ -2950,9 +3038,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,
}, },
}); });
@@ -3615,6 +3700,7 @@ const {
appState.yomitanSettingsWindow = null; appState.yomitanSettingsWindow = null;
}, },
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => { stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop(); void appState.discordPresenceService?.stop();
appState.discordPresenceService = null; appState.discordPresenceService = null;
@@ -4273,8 +4359,8 @@ const {
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();
@@ -5961,6 +6047,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(),
@@ -5976,8 +6063,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' });
@@ -6209,6 +6298,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
@@ -6220,6 +6310,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
autoplayReadyGate.markCurrentMediaAutoplayReady();
if (overlayManager.getVisibleOverlayVisible()) { if (overlayManager.getVisibleOverlayVisible()) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else { } else {
@@ -6230,6 +6321,7 @@ function toggleVisibleOverlay(): void {
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
+27
View File
@@ -43,6 +43,33 @@ test('media path changes clear rendered subtitle state', () => {
); );
}); });
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 suppress current-media autoplay release', () => {
const source = readMainSource();
const actionBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);/);
assert.ok(
actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') <
actionBlock.indexOf('toggleVisibleOverlayHandler();'),
);
});
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\(\)/);
+2 -2
View File
@@ -313,7 +313,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 +459,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',
}, },
+2
View File
@@ -16,6 +16,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;
@@ -56,6 +57,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);
+46 -1
View File
@@ -40,18 +40,63 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'), destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
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'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
cleanup(); cleanup();
assert.equal(calls.length, 30); assert.equal(calls.length, 31);
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.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');
},
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
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void; destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return (): void => { return (): void => {
@@ -59,7 +60,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.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
}; };
} }
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
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'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-first-run-window')); assert.ok(calls.includes('destroy-first-run-window'));
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-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'));
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return () => ({ return () => ({
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
}, },
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
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,
}; };
} }
@@ -101,6 +101,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,
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
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: () => {},
-9
View File
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
recentServers: string[]; recentServers: string[];
}>; }>;
}) => void; }) => void;
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: true, enabled: true,
serverUrl: deps.session.serverUrl, serverUrl: deps.session.serverUrl,
username: deps.session.username, username: deps.session.username,
deviceId: deps.clientInfo.deviceId,
clientName: deps.clientInfo.clientName,
clientVersion: deps.clientInfo.clientVersion,
recentServers: mergeJellyfinRecentServers( recentServers: mergeJellyfinRecentServers(
deps.session.serverUrl, deps.session.serverUrl,
deps.existingRecentServers || [], deps.existingRecentServers || [],
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
}>; }>;
}) => void; }) => void;
authenticateWithPassword: ( authenticateWithPassword: (
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
test('get jellyfin client info main deps builder maps callbacks', () => { test('get jellyfin client info main deps builder maps callbacks', () => {
const configured = { clientName: 'Configured' }; const configured = { clientName: 'Configured' };
const defaults = { clientName: 'Default' };
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({ const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
getResolvedJellyfinConfig: () => configured as never, getResolvedJellyfinConfig: () => configured as never,
getDefaultJellyfinConfig: () => defaults as never, getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
})(); })();
assert.equal(deps.getResolvedJellyfinConfig(), configured); assert.equal(deps.getResolvedJellyfinConfig(), configured);
assert.equal(deps.getDefaultJellyfinConfig(), defaults); assert.equal(deps.getHostName?.(), 'workstation');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0.0');
}); });
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
) { ) {
return (): GetJellyfinClientInfoMainDeps => ({ return (): GetJellyfinClientInfoMainDeps => ({
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(), getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion,
}); });
} }
+32 -18
View File
@@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou
test('jellyfin client info resolves defaults when fields are missing', () => { test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({ const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never, getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
getDefaultJellyfinConfig: () => getHostName: () => 'workstation',
({ defaultClientName: 'SubMiner',
clientName: 'SubMiner', defaultClientVersion: '1.0.0',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
}); });
assert.deepEqual(getClientInfo(), { assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner', clientName: 'SubMiner',
clientVersion: '1.0.0', clientVersion: '1.0.0',
deviceId: 'default-device', deviceId: 'workstation',
}); });
}); });
test('jellyfin client info keeps explicit config values', () => { test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({ const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig: () =>
({ ({
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
clientVersion: '2.3.4', clientVersion: '2.3.4',
deviceId: 'custom-device', deviceId: 'custom-device',
}) as never, }) as never,
getDefaultJellyfinConfig: () => getHostName: () => 'Kyle-PC',
({ defaultClientName: 'SubMiner',
clientName: 'SubMiner', defaultClientVersion: '1.0.0',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
}); });
assert.deepEqual(getClientInfo(), { assert.deepEqual(getClientInfo(), {
clientName: 'Custom', clientName: 'SubMiner',
clientVersion: '2.3.4', clientVersion: '1.0.0',
deviceId: 'custom-device', deviceId: 'Kyle-PC',
});
});
test('jellyfin client info ignores legacy configured device id and client version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '9.9.9',
deviceId: 'custom-device',
}) as never,
getHostName: () => 'media-box',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'media-box',
}); });
}); });
+13 -11
View File
@@ -1,5 +1,10 @@
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store'; import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
import type { ResolvedConfig } from '../../types'; import type { ResolvedConfig } from '../../types';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './jellyfin-device-identity';
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin']; type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & { type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
} }
export function createGetJellyfinClientInfoHandler(deps: { export function createGetJellyfinClientInfoHandler(deps: {
getResolvedJellyfinConfig: () => Partial< getResolvedJellyfinConfig: () => unknown;
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'> getHostName?: () => string;
>; defaultClientName?: string;
getDefaultJellyfinConfig: () => Partial< defaultClientVersion?: string;
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
}) { }) {
return ( return (
config = deps.getResolvedJellyfinConfig(), _config = deps.getResolvedJellyfinConfig(),
): { ): {
clientName: string; clientName: string;
clientVersion: string; clientVersion: string;
deviceId: string; deviceId: string;
} => { } => {
const defaults = deps.getDefaultJellyfinConfig();
return { return {
clientName: config.clientName || defaults.clientName || '', clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
clientVersion: config.clientVersion || defaults.clientVersion || '', clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
deviceId: config.deviceId || defaults.deviceId || '', deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
}; };
}; };
} }
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHostDerivedJellyfinDeviceId,
resolveJellyfinRemoteDeviceName,
} from './jellyfin-device-identity';
test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => {
assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local');
assert.equal(createHostDerivedJellyfinDeviceId(''), 'device');
});
test('resolveJellyfinRemoteDeviceName uses hostname by default', () => {
assert.equal(
resolveJellyfinRemoteDeviceName({
hostName: 'kyle-pc',
}),
'kyle-pc',
);
});
test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => {
assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device');
});
@@ -0,0 +1,18 @@
export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0';
export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner';
export function normalizeJellyfinHostName(value: string): string {
return value.trim();
}
export function createHostDerivedJellyfinDeviceId(hostName: string): string {
return normalizeJellyfinHostName(hostName) || 'device';
}
export function resolveJellyfinDeviceId(params: { hostName: string }): string {
return createHostDerivedJellyfinDeviceId(params.hostName);
}
export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string {
return normalizeJellyfinHostName(params.hostName) || 'device';
}
@@ -11,11 +11,16 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
url: 'u', url: 'u',
mode: 'direct', mode: 'direct',
title: 't', title: 't',
itemTitle: 't',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0, startTimeTicks: 0,
audioStreamIndex: null, audioStreamIndex: null,
subtitleStreamIndex: null, subtitleStreamIndex: null,
}), }),
applyJellyfinMpvDefaults: () => calls.push('defaults'), applyJellyfinMpvDefaults: () => calls.push('defaults'),
showVisibleOverlay: () => calls.push('visible-overlay'),
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`), sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
armQuitOnDisconnect: () => calls.push('arm'), armQuitOnDisconnect: () => calls.push('arm'),
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`), schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
@@ -49,12 +54,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
url: 'u', url: 'u',
mode: 'direct', mode: 'direct',
title: 't', title: 't',
itemTitle: 't',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0, startTimeTicks: 0,
audioStreamIndex: null, audioStreamIndex: null,
subtitleStreamIndex: null, subtitleStreamIndex: null,
}, },
); );
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} }); deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
deps.showVisibleOverlay();
deps.sendMpvCommand(['show-text', 'x']); deps.sendMpvCommand(['show-text', 'x']);
deps.armQuitOnDisconnect(); deps.armQuitOnDisconnect();
deps.schedule(() => {}, 500); deps.schedule(() => {}, 500);
@@ -85,6 +95,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'defaults', 'defaults',
'visible-overlay',
'cmd:show-text', 'cmd:show-text',
'arm', 'arm',
'schedule:500', 'schedule:500',
@@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
getMpvClient: () => deps.getMpvClient(), getMpvClient: () => deps.getMpvClient(),
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params), resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient), applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
showVisibleOverlay: () => deps.showVisibleOverlay(),
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command), sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(), armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs), schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
@@ -19,5 +20,11 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value), setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
reportPlaying: (payload) => deps.reportPlaying(payload), reportPlaying: (payload) => deps.reportPlaying(payload),
showMpvOsd: (text: string) => deps.showMpvOsd(text), showMpvOsd: (text: string) => deps.showMpvOsd(text),
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
: undefined,
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
? (title) => deps.updateCurrentMediaTitle!(title)
: undefined,
}); });
} }
@@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => {
throw new Error('unreachable'); throw new Error('unreachable');
}, },
applyJellyfinMpvDefaults: () => {}, applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
armQuitOnDisconnect: () => {}, armQuitOnDisconnect: () => {},
schedule: () => {}, schedule: () => {},
@@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => {
const calls: string[] = []; const calls: string[] = [];
const activeStates: Array<Record<string, unknown>> = []; const activeStates: Array<Record<string, unknown>> = [];
const reportPayloads: Array<Record<string, unknown>> = []; const reportPayloads: Array<Record<string, unknown>> = [];
const statsMetadata: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({ const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true, ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }), getMpvClient: () => ({ connected: true, send: () => {} }),
@@ -59,11 +61,16 @@ test('playback handler drives mpv commands and playback state', async () => {
url: 'https://stream.example/video.m3u8', url: 'https://stream.example/video.m3u8',
mode: 'direct', mode: 'direct',
title: 'Episode 1', title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: 'Show Title',
seasonNumber: 1,
episodeNumber: 1,
startTimeTicks: 12_000_000, startTimeTicks: 12_000_000,
audioStreamIndex: 1, audioStreamIndex: 1,
subtitleStreamIndex: 2, subtitleStreamIndex: 2,
}), }),
applyJellyfinMpvDefaults: () => calls.push('defaults'), applyJellyfinMpvDefaults: () => calls.push('defaults'),
showVisibleOverlay: () => calls.push('visible-overlay'),
sendMpvCommand: (command) => commands.push(command), sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => calls.push('arm'), armQuitOnDisconnect: () => calls.push('arm'),
schedule: (callback, delayMs) => { schedule: (callback, delayMs) => {
@@ -75,6 +82,8 @@ test('playback handler drives mpv commands and playback state', async () => {
setLastProgressAtMs: (value) => calls.push(`progress:${value}`), setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>), reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
recordJellyfinPlaybackMetadata: (metadata) =>
statsMetadata.push(metadata as Record<string, unknown>),
}); });
await handler({ await handler({
@@ -87,16 +96,22 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.deepEqual(commands.slice(0, 5), [ assert.deepEqual(commands.slice(0, 5), [
['set_property', 'sub-auto', 'no'], ['set_property', 'sub-auto', 'no'],
['loadfile', 'https://stream.example/video.m3u8', 'replace'], ['loadfile', 'https://stream.example/video.m3u8', 'replace'],
['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'], ['set_property', 'force-media-title', 'Episode 1'],
['set_property', 'sid', 'no'], ['set_property', 'sid', 'no'],
['seek', 1.2, 'absolute+exact'], ['seek', 1.2, 'absolute+exact'],
]); ]);
assert.equal(scheduled.length, 1); assert.equal(scheduled.length, 0);
assert.equal(scheduled[0]?.delay, 500); assert.equal(
scheduled[0]?.callback(); commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); 1,
);
assert.ok(calls.includes('defaults')); assert.ok(calls.includes('defaults'));
assert.ok(calls.includes('visible-overlay'));
assert.ok(
calls.indexOf('visible-overlay') < calls.indexOf('preload'),
'visible overlay should be shown before Jellyfin subtitles are selected',
);
assert.ok(calls.includes('arm')); assert.ok(calls.includes('arm'));
assert.ok(calls.includes('preload')); assert.ok(calls.includes('preload'));
assert.ok(calls.includes('progress:0')); assert.ok(calls.includes('progress:0'));
@@ -106,6 +121,63 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.equal(activeStates[0]?.playMethod, 'DirectPlay'); assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start'); assert.equal(reportPayloads[0]?.eventName, 'start');
assert.deepEqual(statsMetadata, [
{
mediaPath: 'https://stream.example/video.m3u8',
displayTitle: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: 'Show Title',
seasonNumber: 1,
episodeNumber: 1,
itemId: 'item-1',
},
]);
});
test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => {
const timeline: string[] = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
mode: 'direct',
title: 'Galaxy Quest S02E07 A New Hope',
itemTitle: 'A New Hope',
seriesTitle: 'Galaxy Quest',
seasonNumber: 2,
episodeNumber: 7,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}:${String(command[1] ?? '')}`),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
updateCurrentMediaTitle: (title) => timeline.push(`title:${title}`),
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'ep-1',
});
const titleIndex = timeline.indexOf('title:Galaxy Quest S02E07 A New Hope');
const loadIndex = timeline.findIndex((entry) => entry.startsWith('cmd:loadfile:'));
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
}); });
test('playback handler applies start override to stream url for remote resume', async () => { test('playback handler applies start override to stream url for remote resume', async () => {
@@ -117,11 +189,16 @@ test('playback handler applies start override to stream url for remote resume',
url: 'https://stream.example/video.m3u8?api_key=token', url: 'https://stream.example/video.m3u8?api_key=token',
mode: 'transcode', mode: 'transcode',
title: 'Episode 2', title: 'Episode 2',
itemTitle: 'Episode 2',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0, startTimeTicks: 0,
audioStreamIndex: null, audioStreamIndex: null,
subtitleStreamIndex: null, subtitleStreamIndex: null,
}), }),
applyJellyfinMpvDefaults: () => {}, applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command), sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {}, armQuitOnDisconnect: () => {},
schedule: () => {}, schedule: () => {},
@@ -147,3 +224,89 @@ test('playback handler applies start override to stream url for remote resume',
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000'); assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']); assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
}); });
test('playback handler does not let stats metadata failures block playback startup', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 3',
itemTitle: 'Episode 3',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
recordJellyfinPlaybackMetadata: () => {
throw new Error('stats db unavailable');
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-3',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
});
test('playback handler does not let media title failures block playback startup', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 4',
itemTitle: 'Episode 4',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
updateCurrentMediaTitle: () => {
throw new Error('title state unavailable');
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-4',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
});
+29 -8
View File
@@ -16,6 +16,16 @@ type ActivePlaybackState = {
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
}; };
export type JellyfinPlaybackStatsMetadata = {
mediaPath: string;
displayTitle: string;
itemTitle: string;
seriesTitle: string | null;
seasonNumber: number | null;
episodeNumber: number | null;
itemId: string;
};
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string { function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
if (typeof startTimeTicksOverride !== 'number') return url; if (typeof startTimeTicksOverride !== 'number') return url;
try { try {
@@ -43,6 +53,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
}) => Promise<JellyfinPlaybackPlan>; }) => Promise<JellyfinPlaybackPlan>;
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void; applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
showVisibleOverlay: () => void;
sendMpvCommand: (command: Array<string | number>) => void; sendMpvCommand: (command: Array<string | number>) => void;
armQuitOnDisconnect: () => void; armQuitOnDisconnect: () => void;
schedule: (callback: () => void, delayMs: number) => void; schedule: (callback: () => void, delayMs: number) => void;
@@ -63,6 +74,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
eventName: 'start'; eventName: 'start';
}) => void; }) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
updateCurrentMediaTitle?: (title: string) => void;
}) { }) {
return async (params: { return async (params: {
session: JellyfinAuthSession; session: JellyfinAuthSession;
@@ -94,19 +107,26 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient); deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
try {
deps.updateCurrentMediaTitle?.(plan.title);
deps.recordJellyfinPlaybackMetadata?.({
mediaPath: playbackUrl,
displayTitle: plan.title,
itemTitle: plan.itemTitle,
seriesTitle: plan.seriesTitle,
seasonNumber: plan.seasonNumber,
episodeNumber: plan.episodeNumber,
itemId: params.itemId,
});
} catch {
// Best-effort metadata/title hooks must not block playback startup.
}
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) { if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect(); deps.armQuitOnDisconnect();
} }
deps.sendMpvCommand([ deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
'set_property',
'force-media-title',
`[Jellyfin/${plan.mode}] ${plan.title}`,
]);
deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.schedule(() => {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}, 500);
const startTimeTicks = const startTimeTicks =
typeof params.startTimeTicksOverride === 'number' typeof params.startTimeTicksOverride === 'number'
@@ -116,6 +136,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']); deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
} }
deps.showVisibleOverlay();
deps.preloadExternalSubtitles({ deps.preloadExternalSubtitles({
session: params.session, session: params.session,
clientInfo: params.clientInfo, clientInfo: params.clientInfo,
@@ -101,6 +101,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']); assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
}); });
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
let playCalls = 0;
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
playJellyfinItem: async () => {
playCalls += 1;
},
logWarn: () => {},
});
await handlePlay({ ItemIds: ['item-1'] });
assert.equal(playCalls, 0);
});
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => { test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
const mpvClient = {}; const mpvClient = {};
const commands: Array<(string | number)[]> = []; const commands: Array<(string | number)[]> = [];
@@ -51,6 +51,7 @@ export type JellyfinRemotePlayHandlerDeps = {
getConfiguredSession: () => JellyfinSession | null; getConfiguredSession: () => JellyfinSession | null;
getClientInfo: () => JellyfinClientInfo; getClientInfo: () => JellyfinClientInfo;
getJellyfinConfig: () => unknown; getJellyfinConfig: () => unknown;
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
playJellyfinItem: (params: { playJellyfinItem: (params: {
session: JellyfinSession; session: JellyfinSession;
clientInfo: JellyfinClientInfo; clientInfo: JellyfinClientInfo;
@@ -79,6 +80,9 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.'); deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
return; return;
} }
if (deps.getActivePlayback?.()?.itemId === itemId) {
return;
}
await deps.playJellyfinItem({ await deps.playJellyfinItem({
session, session,
clientInfo, clientInfo,
@@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
getLaunchMode: () => 'fullscreen', getLaunchMode: () => 'fullscreen',
platform: 'darwin', platform: 'darwin',
execPath: '/tmp/subminer', execPath: '/tmp/subminer',
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
getInstalledPluginDetection: () => ({
installed: false,
path: null,
version: null,
source: null,
message: null,
}),
defaultMpvLogPath: '/tmp/mpv.log', defaultMpvLogPath: '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'], defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`), removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
@@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
assert.equal(deps.getLaunchMode(), 'fullscreen'); assert.equal(deps.getLaunchMode(), 'fullscreen');
assert.equal(deps.platform, 'darwin'); assert.equal(deps.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer'); assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']); assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock'); deps.removeSocketPath('/tmp/mpv.sock');
@@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getLaunchMode: () => deps.getLaunchMode(), getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform, platform: deps.platform,
execPath: deps.execPath, execPath: deps.execPath,
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
getInstalledPluginDetection: deps.getInstalledPluginDetection,
getPluginRuntimeConfig: deps.getPluginRuntimeConfig, getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
defaultMpvLogPath: deps.defaultMpvLogPath, defaultMpvLogPath: deps.defaultMpvLogPath,
defaultMpvArgs: deps.defaultMpvArgs, defaultMpvArgs: deps.defaultMpvArgs,
@@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
getLaunchMode: () => 'maximized', getLaunchMode: () => 'maximized',
platform: 'darwin', platform: 'darwin',
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
getRuntimePluginEntrypoint: () =>
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
defaultMpvLogPath: '/tmp/mp.log', defaultMpvLogPath: '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'], defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {}, removeSocketPath: () => {},
@@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
assert.equal(spawnedArgs.length, 1); assert.equal(spawnedArgs.length, 1);
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes')); assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
assert.ok(spawnedArgs[0]!.includes('--idle=yes')); assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
assert.ok(
spawnedArgs[0]!.includes(
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
),
);
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock'))); assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
}); });
@@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/); assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
}); });
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: '/opt/SubMiner/SubMiner.AppImage',
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
getInstalledPluginDetection: () => ({
installed: true,
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
version: '0.1.0',
source: 'default-config',
message: null,
}),
defaultMpvLogPath: '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: () => {},
logInfo: () => {},
});
launch();
assert.equal(
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
false,
);
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => { test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
let autoLaunchInFlight: Promise<boolean> | null = null; let autoLaunchInFlight: Promise<boolean> | null = null;
let launchCalls = 0; let launchCalls = 0;
@@ -4,6 +4,7 @@ import {
type SubminerPluginRuntimeScriptOptConfig, type SubminerPluginRuntimeScriptOptConfig,
} from '../../shared/subminer-plugin-script-opts'; } from '../../shared/subminer-plugin-script-opts';
import type { MpvLaunchMode } from '../../types/config'; import type { MpvLaunchMode } from '../../types/config';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
type MpvClientLike = { type MpvClientLike = {
connected: boolean; connected: boolean;
@@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = {
getLaunchMode: () => MpvLaunchMode; getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform; platform: NodeJS.Platform;
execPath: string; execPath: string;
getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig; getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
defaultMpvLogPath: string; defaultMpvLogPath: string;
defaultMpvArgs: readonly string[]; defaultMpvArgs: readonly string[];
@@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
) )
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`]; : [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`; const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
const installedPlugin = deps.getInstalledPluginDetection?.();
const runtimePluginEntrypoint = installedPlugin?.installed
? ''
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
if (installedPlugin?.installed && installedPlugin.path) {
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
}
const mpvArgs = [ const mpvArgs = [
...deps.defaultMpvArgs, ...deps.defaultMpvArgs,
...buildMpvLaunchModeArgs(deps.getLaunchMode()), ...buildMpvLaunchModeArgs(deps.getLaunchMode()),
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
'--idle=yes', '--idle=yes',
scriptOpts, scriptOpts,
`--log-file=${deps.defaultMpvLogPath}`, `--log-file=${deps.defaultMpvLogPath}`,
@@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
getConfiguredSession: () => deps.getConfiguredSession(), getConfiguredSession: () => deps.getConfiguredSession(),
getClientInfo: () => deps.getClientInfo(), getClientInfo: () => deps.getClientInfo(),
getJellyfinConfig: () => deps.getJellyfinConfig(), getJellyfinConfig: () => deps.getJellyfinConfig(),
...(deps.getActivePlayback
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
: {}),
playJellyfinItem: (params) => deps.playJellyfinItem(params), playJellyfinItem: (params) => deps.playJellyfinItem(params),
logWarn: (message: string) => deps.logWarn(message), logWarn: (message: string) => deps.logWarn(message),
}); });
@@ -61,6 +61,38 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
assert.equal(lastProgressAtMs, 5000); assert.equal(lastProgressAtMs, 5000);
}); });
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
const reportPayloads: Array<{ isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
}),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
assert.deepEqual(reportPayloads, [{ isPaused: true }]);
});
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => { test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
let called = false; let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler({ const reportProgress = createReportJellyfinRemoteProgressHandler({
+14 -1
View File
@@ -31,6 +31,19 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
return Math.max(0, Math.floor(seconds * ticksPerSecond)); return Math.max(0, Math.floor(seconds * ticksPerSecond));
} }
function isMpvPauseEnabled(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
return true;
}
return false;
}
export type JellyfinRemoteProgressReporterDeps = { export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void; clearActivePlayback: () => void;
@@ -64,7 +77,7 @@ export function createReportJellyfinRemoteProgressHandler(
itemId: playback.itemId, itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId, mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond), positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
isPaused: paused === true, isPaused: isMpvPauseEnabled(paused),
playMethod: playback.playMethod, playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex, audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex,
@@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
accessToken: 'token', accessToken: 'token',
userId: 'user-id', userId: 'user-id',
deviceId: '',
clientName: '',
clientVersion: '',
remoteControlDeviceName: '',
autoAnnounce: false, autoAnnounce: false,
...(overrides || {}), ...(overrides || {}),
} as never; } as never;
@@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => {
} | null = null; } | null = null;
let started = false; let started = false;
const infos: string[] = []; const infos: string[] = [];
let stateChanges = 0;
const startRemote = createStartJellyfinRemoteSessionHandler({ const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }), getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
getCurrentSession: () => null, getCurrentSession: () => null,
@@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => {
storedSession = session as never; storedSession = session as never;
}, },
createRemoteSessionService: (options) => { createRemoteSessionService: (options) => {
assert.equal(options.deviceName, 'Desk'); assert.equal(options.deviceName, 'workstation');
return { return {
start: () => { start: () => {
started = true; started = true;
@@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
logInfo: (message) => infos.push(message), logInfo: (message) => infos.push(message),
logWarn: () => {}, logWarn: () => {},
onSessionStateChanged: () => {
stateChanges += 1;
},
}); });
await startRemote(); await startRemote();
assert.equal(started, true); assert.equal(started, true);
assert.ok(storedSession); assert.ok(storedSession);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).'))); assert.equal(stateChanges, 1);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).')));
});
test('start handler uses hostname-derived client info and visible device name', async () => {
let createdOptions: {
deviceId: string;
clientName: string;
clientVersion: string;
deviceName: string;
} | null = null;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
clientName: 'SubMiner',
}),
getClientInfo: () => ({
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'kyle-pc',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdOptions = {
deviceId: options.deviceId,
clientName: options.clientName,
clientVersion: options.clientVersion,
deviceName: options.deviceName,
};
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.deepEqual(createdOptions, {
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
deviceName: 'kyle-pc',
});
});
test('start handler ignores configured visible device name', async () => {
let createdDeviceName = '';
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
remoteControlDeviceName: 'SubMiner Cachy sudacode',
}),
getClientInfo: () => ({
deviceId: 'cachy',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'cachy',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdDeviceName = options.deviceName;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.equal(createdDeviceName, 'cachy');
}); });
test('start handler stops previous session before replacing', async () => { test('start handler stops previous session before replacing', async () => {
@@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => {
test('stop handler stops active session and clears playback', () => { test('stop handler stops active session and clears playback', () => {
let stopCalls = 0; let stopCalls = 0;
let clearCalls = 0; let clearCalls = 0;
let stateChanges = 0;
let currentSession: { stop: () => void } | null = { let currentSession: { stop: () => void } | null = {
stop: () => { stop: () => {
stopCalls += 1; stopCalls += 1;
@@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => {
clearActivePlayback: () => { clearActivePlayback: () => {
clearCalls += 1; clearCalls += 1;
}, },
onSessionStateChanged: () => {
stateChanges += 1;
},
}); });
stopRemote(); stopRemote();
assert.equal(stopCalls, 1); assert.equal(stopCalls, 1);
assert.equal(clearCalls, 1); assert.equal(clearCalls, 1);
assert.equal(currentSession, null); assert.equal(currentSession, null);
assert.equal(stateChanges, 1);
}); });
@@ -1,3 +1,5 @@
import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity';
type JellyfinRemoteConfig = { type JellyfinRemoteConfig = {
enabled: boolean; enabled: boolean;
remoteControlEnabled: boolean; remoteControlEnabled: boolean;
@@ -5,11 +7,13 @@ type JellyfinRemoteConfig = {
serverUrl: string; serverUrl: string;
accessToken?: string; accessToken?: string;
userId?: string; userId?: string;
autoAnnounce: boolean;
};
type JellyfinClientInfo = {
deviceId: string; deviceId: string;
clientName: string; clientName: string;
clientVersion: string; clientVersion: string;
remoteControlDeviceName: string;
autoAnnounce: boolean;
}; };
type JellyfinRemoteService = { type JellyfinRemoteService = {
@@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
getCurrentSession: () => JellyfinRemoteService | null; getCurrentSession: () => JellyfinRemoteService | null;
setCurrentSession: (session: JellyfinRemoteService | null) => void; setCurrentSession: (session: JellyfinRemoteService | null) => void;
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService; createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
getClientInfo: () => JellyfinClientInfo;
getHostName: () => string;
defaultDeviceId: string; defaultDeviceId: string;
defaultClientName: string; defaultClientName: string;
defaultClientVersion: string; defaultClientVersion: string;
@@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>; handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void; logWarn: (message: string, details?: unknown) => void;
onSessionStateChanged?: () => void;
}) { }) {
return async (options?: { explicit?: boolean }): Promise<void> => { return async (options?: { explicit?: boolean }): Promise<void> => {
const jellyfinConfig = deps.getJellyfinConfig(); const jellyfinConfig = deps.getJellyfinConfig();
@@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return; if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
const clientInfo = deps.getClientInfo();
const clientName = clientInfo.clientName || deps.defaultClientName;
const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion;
const deviceName = resolveJellyfinRemoteDeviceName({
hostName: deps.getHostName(),
});
const existing = deps.getCurrentSession(); const existing = deps.getCurrentSession();
if (existing) { if (existing) {
existing.stop(); existing.stop();
@@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
const service = deps.createRemoteSessionService({ const service = deps.createRemoteSessionService({
serverUrl: jellyfinConfig.serverUrl, serverUrl: jellyfinConfig.serverUrl,
accessToken: jellyfinConfig.accessToken, accessToken: jellyfinConfig.accessToken,
deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId, deviceId: clientInfo.deviceId || deps.defaultDeviceId,
clientName: jellyfinConfig.clientName || deps.defaultClientName, clientName,
clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion, clientVersion,
deviceName: deviceName,
jellyfinConfig.remoteControlDeviceName ||
jellyfinConfig.clientName ||
deps.defaultClientName,
capabilities: { capabilities: {
PlayableMediaTypes: 'Video,Audio', PlayableMediaTypes: 'Video,Audio',
SupportedCommands: SupportedCommands:
@@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
service.start(); service.start();
deps.setCurrentSession(service); deps.setCurrentSession(service);
deps.logInfo( deps.onSessionStateChanged?.();
`Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`);
);
}; };
} }
@@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
getCurrentSession: () => JellyfinRemoteService | null; getCurrentSession: () => JellyfinRemoteService | null;
setCurrentSession: (session: JellyfinRemoteService | null) => void; setCurrentSession: (session: JellyfinRemoteService | null) => void;
clearActivePlayback: () => void; clearActivePlayback: () => void;
onSessionStateChanged?: () => void;
}) { }) {
return (): void => { return (): void => {
const session = deps.getCurrentSession(); const session = deps.getCurrentSession();
@@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
session.stop(); session.stop();
deps.setCurrentSession(null); deps.setCurrentSession(null);
deps.clearActivePlayback(); deps.clearActivePlayback();
deps.onSessionStateChanged?.();
}; };
} }
@@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
getCurrentSession: () => null, getCurrentSession: () => null,
setCurrentSession: () => calls.push('set-session'), setCurrentSession: () => calls.push('set-session'),
createRemoteSessionService: () => session as never, createRemoteSessionService: () => session as never,
getClientInfo: () =>
({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}) as never,
getHostName: () => 'workstation',
defaultDeviceId: 'device', defaultDeviceId: 'device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
@@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
}, },
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
onSessionStateChanged: () => calls.push('state-changed'),
})(); })();
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' }); assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
assert.equal(deps.defaultDeviceId, 'device'); assert.equal(deps.defaultDeviceId, 'device');
assert.equal(deps.defaultClientName, 'SubMiner'); assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0'); assert.equal(deps.defaultClientVersion, '1.0');
assert.equal(deps.getHostName(), 'workstation');
assert.deepEqual(deps.getClientInfo(), {
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
});
assert.equal(deps.createRemoteSessionService({} as never), session); assert.equal(deps.createRemoteSessionService({} as never), session);
await deps.handlePlay({}); await deps.handlePlay({});
await deps.handlePlaystate({}); await deps.handlePlaystate({});
await deps.handleGeneralCommand({}); await deps.handleGeneralCommand({});
deps.logInfo('connected'); deps.logInfo('connected');
deps.logWarn('missing'); deps.logWarn('missing');
assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']); deps.onSessionStateChanged?.();
assert.deepEqual(calls, [
'play',
'playstate',
'general',
'info:connected',
'warn:missing',
'state-changed',
]);
}); });
test('stop jellyfin remote session main deps builder maps callbacks', () => { test('stop jellyfin remote session main deps builder maps callbacks', () => {
@@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => {
getCurrentSession: () => session as never, getCurrentSession: () => session as never,
setCurrentSession: () => calls.push('set-null'), setCurrentSession: () => calls.push('set-null'),
clearActivePlayback: () => calls.push('clear'), clearActivePlayback: () => calls.push('clear'),
onSessionStateChanged: () => calls.push('state-changed'),
})(); })();
assert.equal(deps.getCurrentSession(), session); assert.equal(deps.getCurrentSession(), session);
deps.setCurrentSession(null); deps.setCurrentSession(null);
deps.clearActivePlayback(); deps.clearActivePlayback();
assert.deepEqual(calls, ['set-null', 'clear']); deps.onSessionStateChanged?.();
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
}); });
@@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
getCurrentSession: () => deps.getCurrentSession(), getCurrentSession: () => deps.getCurrentSession(),
setCurrentSession: (session) => deps.setCurrentSession(session), setCurrentSession: (session) => deps.setCurrentSession(session),
createRemoteSessionService: (options) => deps.createRemoteSessionService(options), createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
getClientInfo: () => deps.getClientInfo(),
getHostName: () => deps.getHostName(),
defaultDeviceId: deps.defaultDeviceId, defaultDeviceId: deps.defaultDeviceId,
defaultClientName: deps.defaultClientName, defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion, defaultClientVersion: deps.defaultClientVersion,
@@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload), handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
onSessionStateChanged: deps.onSessionStateChanged,
}); });
} }
@@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler(
getCurrentSession: () => deps.getCurrentSession(), getCurrentSession: () => deps.getCurrentSession(),
setCurrentSession: (session) => deps.setCurrentSession(session), setCurrentSession: (session) => deps.setCurrentSession(session),
clearActivePlayback: () => deps.clearActivePlayback(), clearActivePlayback: () => deps.clearActivePlayback(),
onSessionStateChanged: deps.onSessionStateChanged,
}); });
} }
+133 -16
View File
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Jellyfin Setup</title> <title>Jellyfin Setup</title>
<style> <style>
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; } :root {
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); } color-scheme: dark;
main { padding: 22px; } --ctp-red: #ed8796;
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; } --ctp-peach: #f5a97f;
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; } --ctp-yellow: #eed49f;
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } --ctp-green: #a6da95;
input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; } --ctp-blue: #8aadf4;
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; } --ctp-lavender: #b7bdf8;
button:disabled { cursor: wait; opacity: .68; } --ctp-text: #cad3f5;
button.secondary { background: transparent; color: var(--text); border-color: var(--line); } --ctp-subtext1: #b8c0e0;
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; } --ctp-subtext0: #a5adcb;
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; } --ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--line: rgba(110, 115, 141, 0.28);
--line-soft: rgba(110, 115, 141, 0.14);
--text: var(--ctp-text);
--muted: var(--ctp-subtext0);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
html { background: var(--ctp-base); }
body {
min-height: 100vh;
background: var(--ctp-base);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main { padding: 32px 22px; max-width: 520px; margin: 0 auto; }
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 800; color: var(--ctp-text); letter-spacing: -0.01em; }
p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.5; }
label { display: block; margin: 14px 0 6px; font-size: 11px; font-weight: 800; color: var(--ctp-overlay2); text-transform: uppercase; letter-spacing: 0.1em; }
input {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(24, 25, 38, 0.85);
color: var(--text);
font: inherit;
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
input::placeholder { color: var(--ctp-overlay0); }
input:hover { border-color: rgba(138, 173, 244, 0.32); }
input:focus {
border-color: rgba(138, 173, 244, 0.65);
background: rgba(24, 25, 38, 0.95);
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
}
button {
height: 36px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 8px;
font: inherit;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 60ms ease;
}
button:active { transform: translateY(1px); }
button:disabled { cursor: wait; opacity: 0.7; }
button.primary {
border-color: transparent;
background: var(--ctp-blue);
color: var(--ctp-crust);
}
button.primary:hover:not(:disabled) { filter: brightness(1.06); }
button.primary:disabled {
background: rgba(54, 58, 79, 0.55);
color: var(--ctp-overlay0);
border-color: var(--line);
}
button.secondary {
background: rgba(54, 58, 79, 0.5);
color: var(--text);
}
button.secondary:hover:not(:disabled) {
border-color: rgba(138, 173, 244, 0.45);
background: rgba(73, 77, 100, 0.6);
color: var(--ctp-lavender);
}
button.danger {
background: rgba(237, 135, 150, 0.12);
color: var(--ctp-red);
border-color: rgba(237, 135, 150, 0.45);
}
button.danger:hover:not(:disabled) {
background: rgba(237, 135, 150, 0.22);
}
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
.actions .primary { grid-column: 1 / -1; } .actions .primary { grid-column: 1 / -1; }
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); } .status {
.status.success { color: var(--accent); } min-height: 18px;
.status.error { color: var(--danger); } margin-top: 14px;
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); } font-size: 12.5px;
color: var(--muted);
}
.status:empty { display: none; }
.status.loading,
.status.success,
.status.error {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--ctp-surface0);
font-weight: 600;
}
.status.success {
border-color: rgba(166, 218, 149, 0.45);
background: rgba(166, 218, 149, 0.1);
color: var(--ctp-green);
}
.status.error {
border-color: rgba(237, 135, 150, 0.55);
background: rgba(237, 135, 150, 0.1);
color: var(--ctp-red);
}
.hint {
margin-top: 16px;
font-size: 11.5px;
color: var(--ctp-overlay2);
line-height: 1.55;
}
</style> </style>
</head> </head>
<body> <body>
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createJellyfinSubtitleCacheIo } from './jellyfin-subtitle-cache-io';
test('jellyfin subtitle cache io downloads tracks to temp files and cleans cache dirs', async () => {
const writes: Array<{ filePath: string; bytes: string }> = [];
const removed: Array<{ dir: string; recursive: boolean; force: boolean }> = [];
const cacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => '/tmp',
makeTempDir: async (prefix) => {
assert.equal(prefix, '/tmp/subminer-jellyfin-subtitles-');
return '/tmp/subminer-jellyfin-subtitles-abc';
},
writeFile: async (filePath, bytes) => {
writes.push({ filePath, bytes: new TextDecoder().decode(bytes) });
},
removeDir: (dir, options) => {
removed.push({ dir, ...options });
},
fetch: async () => ({
ok: true,
status: 200,
arrayBuffer: async () => new TextEncoder().encode('subtitle body').buffer as ArrayBuffer,
}),
});
const cached = await cacheIo.cacheSubtitleTrack({
index: 7,
deliveryUrl: 'https://example.test/Items/1/Subtitles/7/Stream.ass?api_key=secret',
});
cacheIo.cleanupCachedSubtitles([cached.cleanupDir]);
assert.deepEqual(cached, {
path: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass',
cleanupDir: '/tmp/subminer-jellyfin-subtitles-abc',
});
assert.deepEqual(writes, [
{
filePath: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass',
bytes: 'subtitle body',
},
]);
assert.deepEqual(removed, [
{ dir: '/tmp/subminer-jellyfin-subtitles-abc', recursive: true, force: true },
]);
});
test('jellyfin subtitle cache io removes temp dir when download fails', async () => {
const removed: string[] = [];
const cacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => '/tmp',
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
writeFile: async () => {},
removeDir: (dir) => {
removed.push(dir);
},
fetch: async () => ({
ok: false,
status: 500,
arrayBuffer: async () => new ArrayBuffer(0),
}),
});
await assert.rejects(
() => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }),
/HTTP 500/,
);
assert.deepEqual(removed, ['/tmp/subminer-jellyfin-subtitles-failed']);
});
@@ -0,0 +1,73 @@
import * as path from 'path';
type JellyfinSubtitleCacheTrack = {
index: number;
deliveryUrl?: string | null;
};
type JellyfinSubtitleCacheEntry = {
path: string;
cleanupDir: string;
};
type FetchResponseLike = {
ok: boolean;
status: number;
arrayBuffer: () => Promise<ArrayBuffer>;
};
type JellyfinSubtitleCacheIoDeps = {
tmpDir: () => string;
makeTempDir: (prefix: string) => Promise<string>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
removeDir: (dir: string, options: { recursive: true; force: true }) => void;
fetch: (url: string) => Promise<FetchResponseLike>;
};
function getSubtitleExtension(deliveryUrl: string): string {
const urlPath = (() => {
try {
return new URL(deliveryUrl).pathname;
} catch {
return deliveryUrl;
}
})();
return path.extname(urlPath).slice(0, 16) || '.srt';
}
export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps) {
return {
async cacheSubtitleTrack(
track: JellyfinSubtitleCacheTrack,
): Promise<JellyfinSubtitleCacheEntry> {
if (!track.deliveryUrl) {
throw new Error('Jellyfin subtitle track has no delivery URL');
}
const cacheDir = await deps.makeTempDir(
path.join(deps.tmpDir(), 'subminer-jellyfin-subtitles-'),
);
const subtitlePath = path.join(
cacheDir,
`track-${track.index}${getSubtitleExtension(track.deliveryUrl)}`,
);
try {
const response = await deps.fetch(track.deliveryUrl);
if (!response.ok) {
throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
await deps.writeFile(subtitlePath, bytes);
} catch (error) {
deps.removeDir(cacheDir, { recursive: true, force: true });
throw error;
}
return { path: subtitlePath, cleanupDir: cacheDir };
},
cleanupCachedSubtitles(dirs: string[]): void {
for (const dir of dirs) {
deps.removeDir(dir, { recursive: true, force: true });
}
},
};
}
@@ -14,6 +14,11 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
wait: async () => { wait: async () => {
calls.push('wait'); calls.push('wait');
}, },
cacheSubtitleTrack: async () => {
calls.push('cache');
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
},
cleanupCachedSubtitles: () => calls.push('cleanup'),
logDebug: (message) => calls.push(`debug:${message}`), logDebug: (message) => calls.push(`debug:${message}`),
})(); })();
@@ -21,6 +26,8 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function'); assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
deps.sendMpvCommand(['set_property', 'sid', 'auto']); deps.sendMpvCommand(['set_property', 'sid', 'auto']);
await deps.wait(1); await deps.wait(1);
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
deps.cleanupCachedSubtitles(['/tmp/subs']);
deps.logDebug('oops', null); deps.logDebug('oops', null);
assert.deepEqual(calls, ['list', 'send', 'wait', 'debug:oops']); assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
}); });
@@ -13,6 +13,8 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
getMpvClient: () => deps.getMpvClient(), getMpvClient: () => deps.getMpvClient(),
sendMpvCommand: (command) => deps.sendMpvCommand(command), sendMpvCommand: (command) => deps.sendMpvCommand(command),
wait: (ms: number) => deps.wait(ms), wait: (ms: number) => deps.wait(ms),
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
logDebug: (message: string, error: unknown) => deps.logDebug(message, error), logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
}); });
} }
@@ -15,47 +15,424 @@ const clientInfo = {
deviceId: 'dev', deviceId: 'dev',
}; };
test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => { function makeDeps(overrides: {
listJellyfinSubtitleTracks?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['listJellyfinSubtitleTracks'];
getMpvClient?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['getMpvClient'];
sendMpvCommand?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['sendMpvCommand'];
wait?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['wait'];
cacheSubtitleTrack?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cacheSubtitleTrack'];
cleanupCachedSubtitles?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cleanupCachedSubtitles'];
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
}) {
return {
listJellyfinSubtitleTracks: overrides.listJellyfinSubtitleTracks ?? (async () => []),
getMpvClient: overrides.getMpvClient ?? (() => null),
sendMpvCommand: overrides.sendMpvCommand ?? (() => {}),
wait: overrides.wait ?? (async () => {}),
cacheSubtitleTrack:
overrides.cacheSubtitleTrack ??
(async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
})),
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
logDebug: overrides.logDebug ?? (() => {}),
};
}
test('preload jellyfin subtitles caches external tracks locally and chooses japanese+english tracks', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler({ const preload = createPreloadJellyfinExternalSubtitlesHandler(
listJellyfinSubtitleTracks: async () => [ makeDeps({
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
], { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
getMpvClient: () => ({
requestProperty: async () => [
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
], ],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
}), }),
sendMpvCommand: (command) => commands.push(command), );
wait: async () => {},
logDebug: () => {},
});
await preload({ session, clientInfo, itemId: 'item-1' }); await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(commands, [ assert.deepEqual(commands, [
['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'], ['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'], ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
['set_property', 'sid', 5], ['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6], ['set_property', 'secondary-sid', 6],
]); ]);
}); });
test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount < 3) {
return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }];
}
return [
{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false },
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
],
);
});
test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount < 3) {
return [{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }];
}
return [
{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' },
{
type: 'sub',
id: 42,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 43,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 42],
['set_property', 'secondary-sid', 43],
],
);
});
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 10, language: 'deu', title: 'German', deliveryUrl: 'https://sub/deu.ass' },
{ index: 12, language: 'rus', title: 'Russian', deliveryUrl: 'https://sub/rus.ass' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount === 1) {
return [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
}
return [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
{
type: 'sub',
id: 18,
lang: 'deu',
title: 'German',
external: true,
selected: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/10.srt',
},
{
type: 'sub',
id: 20,
lang: 'rus',
title: 'Russian',
external: true,
selected: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/12.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 2);
assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add'),
[
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/10.srt', 'auto', 'German', 'deu'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'],
],
);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[['set_property', 'sid', 11]],
);
});
test('preload jellyfin subtitles leaves current track alone when reported japanese track never appears', async () => {
const commands: Array<Array<string | number>> = [];
const logs: string[] = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }];
},
}),
sendMpvCommand: (command) => commands.push(command),
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 10);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
),
false,
);
assert.deepEqual(logs, ['Timed out waiting for Jellyfin Japanese subtitle track']);
});
test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => {
const cleanupCalls: string[][] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({ requestProperty: async () => [] }),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`,
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
}),
cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
await preload({ session, clientInfo, itemId: 'item-2' });
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]);
});
test('preload jellyfin subtitles continues after cleanup failures', async () => {
const commands: Array<Array<string | number>> = [];
const logs: string[] = [];
let cleanupShouldFail = false;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({ requestProperty: async () => [] }),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`,
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
}),
sendMpvCommand: (command) => commands.push(command),
cleanupCachedSubtitles: () => {
if (cleanupShouldFail) {
throw new Error('cleanup failed');
}
},
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
cleanupShouldFail = true;
await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' }));
assert.deepEqual(logs, ['Failed to cleanup Jellyfin cached subtitles']);
assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add'),
[
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'],
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'],
],
);
});
test('preload jellyfin subtitles serializes overlapping preload runs', async () => {
let releaseFirstList!: () => void;
const firstListBlocked = new Promise<void>((resolve) => {
releaseFirstList = resolve;
});
const listCalls: string[] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => {
listCalls.push(itemId);
if (itemId === 'item-1') {
await firstListBlocked;
}
return [];
},
}),
);
const first = preload({ session, clientInfo, itemId: 'item-1' });
const second = preload({ session, clientInfo, itemId: 'item-2' });
await Promise.resolve();
assert.deepEqual(listCalls, ['item-1']);
releaseFirstList();
await Promise.all([first, second]);
assert.deepEqual(listCalls, ['item-1', 'item-2']);
});
test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => {
const cleanupCalls: string[][] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({ requestProperty: async () => [] }),
cacheSubtitleTrack: async () => ({
path: '/tmp/subminer-jellyfin-subtitles-active/track.srt',
cleanupDir: '/tmp/subminer-jellyfin-subtitles-active',
}),
cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
preload.cleanupCachedSubtitles();
preload.cleanupCachedSubtitles();
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-active']]);
});
test('preload jellyfin subtitles exits quietly when no external tracks', async () => { test('preload jellyfin subtitles exits quietly when no external tracks', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
let waited = false; let waited = false;
const preload = createPreloadJellyfinExternalSubtitlesHandler({ const preload = createPreloadJellyfinExternalSubtitlesHandler(
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], makeDeps({
getMpvClient: () => ({ requestProperty: async () => [] }), listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
sendMpvCommand: (command) => commands.push(command), getMpvClient: () => ({ requestProperty: async () => [] }),
wait: async () => { sendMpvCommand: (command) => commands.push(command),
waited = true; wait: async () => {
}, waited = true;
logDebug: () => {}, },
}); }),
);
await preload({ session, clientInfo, itemId: 'item-1' }); await preload({ session, clientInfo, itemId: 'item-1' });
@@ -65,15 +442,17 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async (
test('preload jellyfin subtitles logs debug on failure', async () => { test('preload jellyfin subtitles logs debug on failure', async () => {
const logs: string[] = []; const logs: string[] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler({ const preload = createPreloadJellyfinExternalSubtitlesHandler(
listJellyfinSubtitleTracks: async () => { makeDeps({
throw new Error('network down'); listJellyfinSubtitleTracks: async () => {
}, throw new Error('network down');
getMpvClient: () => null, },
sendMpvCommand: () => {}, getMpvClient: () => null,
wait: async () => {}, sendMpvCommand: () => {},
logDebug: (message) => logs.push(message), wait: async () => {},
}); logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' }); await preload({ session, clientInfo, itemId: 'item-1' });
+207 -30
View File
@@ -18,10 +18,40 @@ type JellyfinSubtitleTrack = {
deliveryUrl?: string | null; deliveryUrl?: string | null;
}; };
type CachedSubtitleTrack = {
path: string;
cleanupDir: string;
};
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
title: string;
external: boolean;
externalFilename: string;
};
type MpvClientLike = { type MpvClientLike = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>; requestProperty: (name: string) => Promise<unknown>;
}; };
const TRACK_SELECTION_INITIAL_WAIT_MS = 250;
const TRACK_SELECTION_RETRY_MS = 150;
const TRACK_SELECTION_MAX_ATTEMPTS = 10;
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}) => Promise<void>) & {
cleanupCachedSubtitles: () => void;
};
function normalizeLang(value: unknown): string { function normalizeLang(value: unknown): string {
return String(value || '') return String(value || '')
.trim() .trim()
@@ -58,17 +88,12 @@ function isLikelyHearingImpaired(title: string): boolean {
} }
function pickBestTrackId( function pickBestTrackId(
tracks: Array<{ tracks: MpvSubtitleTrack[],
id: number;
lang: string;
title: string;
external: boolean;
}>,
languageMatcher: (value: string) => boolean, languageMatcher: (value: string) => boolean,
excludeId: number | null = null, excludeId: number | null = null,
): number | null { ): number | null {
const ranked = tracks const ranked = tracks
.filter((track) => languageMatcher(track.lang)) .filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
.filter((track) => track.id !== excludeId) .filter((track) => track.id !== excludeId)
.map((track) => ({ .map((track) => ({
track, track,
@@ -81,6 +106,117 @@ function pickBestTrackId(
return ranked[0]?.track.id ?? null; return ranked[0]?.track.id ?? null;
} }
function pickBestCachedTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
sourceMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track]));
const ranked = tracks
.map((track) => ({
track,
cached: cachedByPath.get(track.externalFilename),
}))
.filter(({ cached }) =>
cached
? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '')
: false,
)
.filter(({ track }) => track.id !== excludeId)
.map(({ track, cached }) => {
const title = cached?.source.title || track.title;
return {
track,
score:
(track.external ? 100 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0),
};
})
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
function isJapaneseTrack(track: MpvSubtitleTrack): boolean {
return isJapanese(track.lang) || isJapanese(track.title);
}
function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean {
return tracks.some((track) => track.external && isJapaneseTrack(track));
}
function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
return Array.isArray(trackListRaw)
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
Boolean(track) &&
typeof track === 'object' &&
track.type === 'sub' &&
typeof track.id === 'number',
)
.map((track) => ({
id: track.id as number,
lang: String(track.lang || ''),
title: String(track.title || ''),
external: track.external === true,
externalFilename: String(track['external-filename'] || ''),
}))
: [];
}
function hasExpectedExternalSubtitleTracks(
tracks: MpvSubtitleTrack[],
expectedExternalFilenames: string[],
): boolean {
if (expectedExternalFilenames.length === 0) {
return true;
}
const loadedExternalFilenames = new Set(
tracks.filter((track) => track.externalFilename).map((track) => track.externalFilename),
);
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
}
async function readMpvSubtitleTracks(deps: {
getMpvClient: () => MpvClientLike | null;
}): Promise<MpvSubtitleTrack[] | null> {
const client = deps.getMpvClient();
if (!client || client.connected === false) {
return null;
}
const trackListRaw = await client.requestProperty('track-list');
return parseMpvSubtitleTracks(trackListRaw);
}
async function waitForPreferredSubtitleTracks(
deps: {
getMpvClient: () => MpvClientLike | null;
wait: (ms: number) => Promise<void>;
},
shouldWaitForExternalJapanese: boolean,
expectedExternalFilenames: string[],
): Promise<MpvSubtitleTrack[] | null> {
let subtitleTracks: MpvSubtitleTrack[] = [];
for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) {
const nextTracks = await readMpvSubtitleTracks(deps);
if (nextTracks !== null) {
subtitleTracks = nextTracks;
if (
(!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) &&
hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames)
) {
return subtitleTracks;
}
}
if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) {
await deps.wait(TRACK_SELECTION_RETRY_MS);
}
}
return subtitleTracks;
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: { export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: ( listJellyfinSubtitleTracks: (
session: JellyfinSession, session: JellyfinSession,
@@ -90,14 +226,31 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (command: Array<string | number>) => void; sendMpvCommand: (command: Array<string | number>) => void;
wait: (ms: number) => Promise<void>; wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
}) { }): PreloadJellyfinExternalSubtitlesHandler {
return async (params: { const activeCacheDirs = new Set<string>();
let preloadQueue: Promise<void> = Promise.resolve();
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
activeCacheDirs.clear();
if (dirs.length === 0) return;
deps.cleanupCachedSubtitles(dirs);
}
const runPreload = async (params: {
session: JellyfinSession; session: JellyfinSession;
clientInfo: JellyfinClientInfo; clientInfo: JellyfinClientInfo;
itemId: string; itemId: string;
}): Promise<void> => { }): Promise<void> => {
try { try {
try {
cleanupActiveCache();
} catch (error) {
deps.logDebug('Failed to cleanup Jellyfin cached subtitles', error);
}
const tracks = await deps.listJellyfinSubtitleTracks( const tracks = await deps.listJellyfinSubtitleTracks(
params.session, params.session,
params.clientInfo, params.clientInfo,
@@ -110,6 +263,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
await deps.wait(300); await deps.wait(300);
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
const cachedTracks: CachedExternalSubtitleTrack[] = [];
for (const track of externalTracks) { for (const track of externalTracks) {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) { if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
continue; continue;
@@ -117,36 +271,43 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
seenUrls.add(track.deliveryUrl); seenUrls.add(track.deliveryUrl);
const labelBase = (track.title || track.language || '').trim(); const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`; const label = labelBase || `Jellyfin Subtitle ${track.index}`;
deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']); const cached = await deps.cacheSubtitleTrack(track);
activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push({ ...cached, source: track });
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
} }
await deps.wait(250); await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list'); const shouldWaitForExternalJapanese = externalTracks.some(
const subtitleTracks = Array.isArray(trackListRaw) (track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
? trackListRaw );
.filter( const subtitleTracks = await waitForPreferredSubtitleTracks(
(track): track is Record<string, unknown> => deps,
Boolean(track) && shouldWaitForExternalJapanese,
typeof track === 'object' && cachedTracks.map((track) => track.path),
track.type === 'sub' && );
typeof track.id === 'number', if (
) shouldWaitForExternalJapanese &&
.map((track) => ({ (!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
id: track.id as number, ) {
lang: String(track.lang || ''), deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
title: String(track.title || ''), itemId: params.itemId,
external: track.external === true, });
})) return;
: []; }
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); const japanesePrimaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
pickBestTrackId(subtitleTracks ?? [], isJapanese);
if (japanesePrimaryId !== null) { if (japanesePrimaryId !== null) {
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
} else { } else {
deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.sendMpvCommand(['set_property', 'sid', 'no']);
} }
const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); const englishSecondaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) { if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
} }
@@ -154,4 +315,20 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.logDebug('Failed to preload Jellyfin external subtitles', error); deps.logDebug('Failed to preload Jellyfin external subtitles', error);
} }
}; };
const preload = (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}): Promise<void> => {
preloadQueue = preloadQueue.then(
() => runPreload(params),
() => runPreload(params),
);
return preloadQueue;
};
return Object.assign(preload, {
cleanupCachedSubtitles: cleanupActiveCache,
});
} }
@@ -194,6 +194,124 @@ test('stops active discovery from tray', async () => {
]); ]);
}); });
test('uses checked tray state to start discovery instead of blind toggling', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray(
{
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
},
{ desiredActive: true },
);
assert.deepEqual(calls, [
'start',
'advertise',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('uses unchecked tray state to stop discovery without visibility probing', async () => {
const calls: string[] = [];
await toggleJellyfinDiscoveryFromTray(
{
getRemoteSession: () => ({
advertiseNow: async () => {
calls.push('advertise');
return true;
},
}),
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async () => {
calls.push('start');
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
},
{ desiredActive: false },
);
assert.deepEqual(calls, [
'stop',
'info:Jellyfin discovery stopped.',
'osd:Jellyfin discovery stopped',
'refresh',
]);
});
test('restarts active discovery when current session is not visible', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = {
advertiseNow: async () => {
calls.push('advertise-stale');
return false;
},
};
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => {
calls.push('stop');
session = null;
},
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise-fresh');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'advertise-stale',
'warn:Jellyfin discovery was active but not visible; restarting.',
'stop',
'start',
'advertise-fresh',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('warns and refreshes tray when explicit discovery cannot create a session', async () => { test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
const calls: string[] = []; const calls: string[] = [];
+30 -4
View File
@@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinT
| 'logger' | 'logger'
| 'showMpvOsd' | 'showMpvOsd'
>, >,
options: { desiredActive?: boolean } = {},
): Promise<void> { ): Promise<void> {
try { try {
const activeSession = deps.getRemoteSession(); const activeSession = deps.getRemoteSession();
if (activeSession) { if (options.desiredActive === false) {
deps.stopRemoteSession(); if (activeSession) {
deps.logger.info('Jellyfin discovery stopped.'); deps.stopRemoteSession();
deps.showMpvOsd('Jellyfin discovery stopped'); deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
}
return; return;
} }
if (activeSession) {
let visible = false;
try {
visible = await activeSession.advertiseNow();
} catch {
deps.logger.warn('Jellyfin discovery visibility check failed; restarting.');
}
if (visible) {
if (options.desiredActive === true) {
deps.logger.info('Jellyfin discovery already active.');
} else {
deps.stopRemoteSession();
deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
}
return;
}
deps.logger.warn('Jellyfin discovery was active but not visible; restarting.');
deps.stopRemoteSession();
}
await deps.startRemoteSession({ explicit: true }); await deps.startRemoteSession({ explicit: true });
const remoteSession = deps.getRemoteSession(); const remoteSession = deps.getRemoteSession();
if (!remoteSession) { if (!remoteSession) {
@@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec
['set_property', 'sid', 3], ['set_property', 'sid', 3],
]); ]);
}); });
test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => {
const commands: Array<Array<string | number>> = [];
const scheduled = new Map<number, () => void>();
let nextTimerId = 1;
const runtime = createManagedLocalSubtitleSelectionRuntime({
getCurrentMediaPath: () => '/videos/example.mkv',
getMpvClient: () =>
({
connected: true,
requestProperty: async (name: string) => {
if (name === 'track-list') {
return [
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
{ type: 'sub', id: 4, lang: 'en', title: 'en.srt', external: true },
];
}
throw new Error(`Unexpected property: ${name}`);
},
}) as never,
getPrimarySubtitleLanguages: () => [],
getSecondarySubtitleLanguages: () => [],
sendMpvCommand: (command) => {
commands.push(command);
},
schedule: (callback) => {
const timerId = nextTimerId++;
scheduled.set(timerId, callback);
return timerId as never;
},
clearScheduled: (timer) => {
scheduled.delete(timer as never);
},
});
runtime.handleMediaPathChange('/videos/example.mkv');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
]);
assert.deepEqual(commands, [['set_property', 'sid', 3]]);
assert.equal(scheduled.size, 1);
const refresh = [...scheduled.values()][0];
assert.ok(refresh);
refresh();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, [
['set_property', 'sid', 3],
['set_property', 'secondary-sid', 4],
]);
});
+6 -7
View File
@@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
pendingTimer = null; pendingTimer = null;
}; };
const hasAppliedSelectionForCurrentMediaPath = (): boolean =>
appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath;
const maybeApplySelection = (trackList: unknown[] | null): void => { const maybeApplySelection = (trackList: unknown[] | null): void => {
if ( if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
!currentMediaPath ||
(appliedPrimaryMediaPath === currentMediaPath &&
appliedSecondaryMediaPath === currentMediaPath)
) {
return; return;
} }
const selection = resolveManagedLocalSubtitleSelection({ const selection = resolveManagedLocalSubtitleSelection({
@@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]); deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
appliedSecondaryMediaPath = currentMediaPath; appliedSecondaryMediaPath = currentMediaPath;
} }
if (appliedPrimaryMediaPath === currentMediaPath) { if (hasAppliedSelectionForCurrentMediaPath()) {
clearPendingTimer(); clearPendingTimer();
} }
}; };
@@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
const scheduleRefresh = (): void => { const scheduleRefresh = (): void => {
clearPendingTimer(); clearPendingTimer();
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) { if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
return; return;
} }
pendingTimer = deps.schedule(() => { pendingTimer = deps.schedule(() => {

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