mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
Compare commits
6 Commits
81b941fe8c
...
098375c647
| Author | SHA1 | Date | |
|---|---|---|---|
|
098375c647
|
|||
|
83fdccb752
|
|||
|
58fd648185
|
|||
|
536d99251e
|
|||
|
1a7f015f4e
|
|||
|
3a2d7a282d
|
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Kept Jellyfin picker library discovery working when the running app log level is above info.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -519,7 +519,7 @@
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
@@ -636,14 +636,10 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||
|
||||
@@ -1050,6 +1050,7 @@ Known-word cache policy:
|
||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
||||
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
|
||||
- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
|
||||
- Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility.
|
||||
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
|
||||
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
|
||||
@@ -1258,7 +1259,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": false,
|
||||
"remoteControlDeviceName": "SubMiner",
|
||||
"defaultLibraryId": "",
|
||||
"directPlayPreferred": true,
|
||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
||||
@@ -1273,21 +1273,17 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
| `serverUrl` | string (URL) | Jellyfin server base URL |
|
||||
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
|
||||
| `username` | string | Default username used by `--jellyfin-login` |
|
||||
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
|
||||
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
|
||||
| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) |
|
||||
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
|
||||
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
|
||||
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) |
|
||||
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
|
||||
| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists |
|
||||
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
|
||||
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
|
||||
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
|
||||
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
|
||||
|
||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<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 playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
|
||||
|
||||
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
|
||||
|
||||
### Discord Rich Presence
|
||||
|
||||
@@ -29,7 +29,6 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": false,
|
||||
"remoteControlDeviceName": "SubMiner",
|
||||
"defaultLibraryId": "",
|
||||
"pullPictures": false,
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
||||
@@ -50,7 +49,7 @@ subminer jellyfin -l \
|
||||
--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:
|
||||
|
||||
@@ -70,7 +69,7 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
||||
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:
|
||||
|
||||
@@ -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`.
|
||||
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
## N+1 Word Highlighting
|
||||
|
||||
@@ -519,7 +519,7 @@
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
@@ -636,14 +636,10 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||
|
||||
@@ -114,7 +114,7 @@ Automatic checks log failures quietly so playback is not interrupted.
|
||||
|
||||
**"SubMiner is up to date" but a prerelease exists**
|
||||
|
||||
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
|
||||
SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
|
||||
|
||||
**Launcher update shows a sudo command**
|
||||
|
||||
|
||||
+16
-8
@@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection(
|
||||
fail('Selected Jellyfin item is not playable.');
|
||||
}
|
||||
|
||||
export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] {
|
||||
const forwarded = [...appArgs];
|
||||
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
|
||||
if (serverOverride) {
|
||||
forwarded.push('--jellyfin-server', serverOverride);
|
||||
}
|
||||
if (args.passwordStore) {
|
||||
forwarded.push('--password-store', args.passwordStore);
|
||||
}
|
||||
if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
async function runAppJellyfinListCommand(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
@@ -384,14 +399,7 @@ async function runAppJellyfinCommand(
|
||||
appArgs: string[],
|
||||
label: string,
|
||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
||||
const forwardedBase = [...appArgs];
|
||||
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
|
||||
if (serverOverride) {
|
||||
forwardedBase.push('--jellyfin-server', serverOverride);
|
||||
}
|
||||
if (args.passwordStore) {
|
||||
forwardedBase.push('--password-store', args.passwordStore);
|
||||
}
|
||||
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
||||
|
||||
const readLogAppendedSince = (offset: number): string => {
|
||||
const logPath = getMpvLogPath();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
parseEpisodePathFromDisplay,
|
||||
buildRootSearchGroups,
|
||||
classifyJellyfinChildSelection,
|
||||
buildForwardedJellyfinAppArgs,
|
||||
} from './jellyfin.js';
|
||||
|
||||
type RunResult = {
|
||||
@@ -878,6 +879,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
|
||||
const forwarded = buildForwardedJellyfinAppArgs(
|
||||
{
|
||||
jellyfinServer: 'https://jf.example.test/',
|
||||
passwordStore: 'gnome-libsecret',
|
||||
logLevel: 'info',
|
||||
} as never,
|
||||
['--jellyfin-libraries'],
|
||||
);
|
||||
|
||||
assert.deepEqual(forwarded, [
|
||||
'--jellyfin-libraries',
|
||||
'--jellyfin-server',
|
||||
'https://jf.example.test',
|
||||
'--password-store',
|
||||
'gnome-libsecret',
|
||||
'--log-level',
|
||||
'info',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
||||
|
||||
@@ -833,6 +833,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const configDir = path.join(dir, 'launcher-config');
|
||||
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
const receivedControlArgv: string[][] = [];
|
||||
|
||||
@@ -144,12 +144,21 @@ function M.create(ctx)
|
||||
and previous_media_identity ~= nil
|
||||
and media_identity == previous_media_identity
|
||||
)
|
||||
local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded
|
||||
state.pending_reload_media_identity = nil
|
||||
state.current_media_identity = media_identity
|
||||
if new_media_loaded then
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
|
||||
if same_media_reload then
|
||||
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
|
||||
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||
if
|
||||
state.overlay_running
|
||||
and not state.suppress_ready_overlay_restore
|
||||
and resolve_auto_start_enabled()
|
||||
and process.has_matching_mpv_ipc_socket(opts.socket_path)
|
||||
then
|
||||
process.run_control_command_async("show-visible-overlay", {
|
||||
socket_path = opts.socket_path,
|
||||
})
|
||||
|
||||
+28
-16
@@ -31,6 +31,16 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||
end
|
||||
|
||||
local function resolve_auto_start_visibility_action()
|
||||
if resolve_visible_overlay_startup() then
|
||||
if state.suppress_ready_overlay_restore then
|
||||
return nil
|
||||
end
|
||||
return "show-visible-overlay"
|
||||
end
|
||||
return "hide-visible-overlay"
|
||||
end
|
||||
|
||||
local function resolve_pause_until_ready()
|
||||
local raw_pause_until_ready = opts.auto_start_pause_until_ready
|
||||
if raw_pause_until_ready == nil then
|
||||
@@ -129,7 +139,7 @@ function M.create(ctx)
|
||||
|
||||
local function release_auto_play_ready_gate(reason)
|
||||
if not state.auto_play_ready_gate_armed then
|
||||
return
|
||||
return false
|
||||
end
|
||||
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
@@ -140,6 +150,7 @@ function M.create(ctx)
|
||||
else
|
||||
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function arm_auto_play_ready_gate()
|
||||
@@ -179,9 +190,12 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function notify_auto_play_ready()
|
||||
release_auto_play_ready_gate("tokenization-ready")
|
||||
local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
|
||||
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
|
||||
state.force_ready_overlay_restore = false
|
||||
if not released_ready_gate and not force_ready_overlay_restore then
|
||||
return
|
||||
end
|
||||
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
|
||||
return
|
||||
end
|
||||
@@ -224,7 +238,7 @@ function M.create(ctx)
|
||||
|
||||
local should_show_visible = overrides.show_visible_overlay
|
||||
if should_show_visible == nil then
|
||||
should_show_visible = resolve_visible_overlay_startup()
|
||||
should_show_visible = resolve_visible_overlay_startup() and not state.suppress_ready_overlay_restore
|
||||
end
|
||||
if should_show_visible then
|
||||
table.insert(args, "--show-visible-overlay")
|
||||
@@ -399,9 +413,6 @@ function M.create(ctx)
|
||||
|
||||
local function start_overlay(overrides)
|
||||
overrides = overrides or {}
|
||||
if overrides.auto_start_trigger == true then
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
@@ -424,13 +435,13 @@ function M.create(ctx)
|
||||
elseif not state.auto_play_ready_gate_armed then
|
||||
disarm_auto_play_ready_gate()
|
||||
end
|
||||
local visibility_action = resolve_visible_overlay_startup()
|
||||
and "show-visible-overlay"
|
||||
or "hide-visible-overlay"
|
||||
run_control_command_async(visibility_action, {
|
||||
socket_path = socket_path,
|
||||
log_level = overrides.log_level,
|
||||
})
|
||||
local visibility_action = resolve_auto_start_visibility_action()
|
||||
if visibility_action ~= nil then
|
||||
run_control_command_async(visibility_action, {
|
||||
socket_path = socket_path,
|
||||
log_level = overrides.log_level,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
subminer_log("info", "process", "Overlay already running")
|
||||
@@ -495,13 +506,13 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if overrides.auto_start_trigger == true then
|
||||
local visibility_action = resolve_visible_overlay_startup()
|
||||
and "show-visible-overlay"
|
||||
or "hide-visible-overlay"
|
||||
local visibility_action = resolve_auto_start_visibility_action()
|
||||
if visibility_action ~= nil then
|
||||
run_control_command_async(visibility_action, {
|
||||
socket_path = socket_path,
|
||||
log_level = overrides.log_level,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
end)
|
||||
@@ -576,6 +587,7 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
state.suppress_ready_overlay_restore = true
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
|
||||
run_control_command_async("toggle-visible-overlay", nil, function(ok)
|
||||
if not ok then
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
## Highlights
|
||||
### Breaking Changes
|
||||
|
||||
- **Settings Window:** The Configuration window is now called the Settings window everywhere — UI, tray menu, docs, and CLI. `--config` and `subminer config` (no action) are replaced by `--settings` and `subminer settings`; `subminer config` now only accepts `path` or `show`. The `--settings` alias that previously opened the Yomitan settings popup is removed — use `--yomitan` instead.
|
||||
|
||||
### Added
|
||||
|
||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only.
|
||||
@@ -17,16 +13,12 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings Window:** Option rows no longer display raw config paths; live/restart status is shown inline beside each option title. Known-words deck rows are now cards with the deck name on a separate header line so long names remain readable. Playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls have been reorganized.
|
||||
|
||||
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
||||
|
||||
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set.
|
||||
|
||||
- **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
|
||||
|
||||
- **Subsync:** Always opens the manual subtitle picker. The `subsync.defaultMode` config option has been removed.
|
||||
|
||||
- **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field.
|
||||
|
||||
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
||||
@@ -47,17 +39,13 @@
|
||||
|
||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
|
||||
|
||||
- **Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. Update check traffic is routed through `/usr/bin/curl` to avoid Electron network-service crashes during video startup.
|
||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater, `subminer -u` can update independently of the tray app, macOS update dialogs come to the front reliably, unsupported builds show a manual-install message, and Windows keeps the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups now avoid Electron networking on Linux and macOS.
|
||||
|
||||
- **Updater — macOS:** Update dialogs now reliably come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native `electron-updater`/Squirrel path; supplemental GitHub release lookups are routed through `/usr/bin/curl`, eliminating the last Electron-networking path from background update checks.
|
||||
|
||||
- **Updater — Windows:** Automatic updates keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch.
|
||||
|
||||
- **Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed — both return control to the terminal without requiring Ctrl+C.
|
||||
- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed - both return control to the terminal without requiring Ctrl+C.
|
||||
|
||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
|
||||
|
||||
- **Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
||||
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
||||
|
||||
- **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends.
|
||||
|
||||
@@ -79,9 +67,7 @@
|
||||
|
||||
- **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`.
|
||||
|
||||
- **Config:** User config files are preserved during legacy compatibility handling. The note-fields note-type picker now defaults to the configured Anki deck's note type, falling back to `Kiku`, then `Lapis`, then blank for manual selection.
|
||||
|
||||
- **Build — Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
||||
- **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
||||
|
||||
### Docs
|
||||
|
||||
|
||||
@@ -1396,7 +1396,7 @@ do
|
||||
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4,
|
||||
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"
|
||||
)
|
||||
assert_true(
|
||||
@@ -1471,6 +1471,33 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for duplicate autoplay-ready scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1531,6 +1558,10 @@ do
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual toggle-off before readiness should suppress ready-time visible overlay restore"
|
||||
)
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "pause", false) == 0,
|
||||
"manual toggle-off before readiness should not resume playback when readiness arrives"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
@@ -1564,6 +1595,75 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
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
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
|
||||
@@ -74,7 +74,10 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal('clientName' in config.jellyfin, false);
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
assert.equal('deviceId' in config.jellyfin, false);
|
||||
assert.equal('clientVersion' in config.jellyfin, false);
|
||||
assert.equal(config.ai.enabled, false);
|
||||
assert.equal(config.ai.apiKeyCommand, '');
|
||||
assert.equal(config.texthooker.openBrowser, false);
|
||||
@@ -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();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -836,6 +839,7 @@ test('parses jellyfin remote control fields', () => {
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": true,
|
||||
"clientName": "Custom Client",
|
||||
"remoteControlDeviceName": "SubMiner"
|
||||
}
|
||||
}`,
|
||||
@@ -850,7 +854,8 @@ test('parses jellyfin remote control fields', () => {
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, true);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal('clientName' in config.jellyfin, false);
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
});
|
||||
|
||||
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
|
||||
@@ -2462,6 +2467,8 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"updates":/);
|
||||
assert.match(output, /"youtube":/);
|
||||
assert.doesNotMatch(output, /"deviceId":/);
|
||||
assert.doesNotMatch(output, /"clientVersion":/);
|
||||
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
|
||||
@@ -126,14 +126,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
serverUrl: '',
|
||||
recentServers: [],
|
||||
username: '',
|
||||
deviceId: 'subminer',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
defaultLibraryId: '',
|
||||
remoteControlEnabled: true,
|
||||
remoteControlAutoConnect: true,
|
||||
autoAnnounce: false,
|
||||
remoteControlDeviceName: 'SubMiner',
|
||||
pullPictures: false,
|
||||
iconCacheDir: '/tmp/subminer-jellyfin-icons',
|
||||
directPlayPreferred: true,
|
||||
|
||||
@@ -258,7 +258,8 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'enum',
|
||||
enumValues: ['headword', 'surface'],
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
|
||||
description: 'Known-word matching strategy for subtitle annotations.',
|
||||
description:
|
||||
'Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||
@@ -520,26 +521,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.username,
|
||||
description: 'Default Jellyfin username used during CLI login.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.deviceId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.deviceId,
|
||||
description:
|
||||
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientName,
|
||||
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientVersion',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientVersion,
|
||||
description:
|
||||
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.defaultLibraryId',
|
||||
kind: 'string',
|
||||
@@ -565,12 +546,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.remoteControlDeviceName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
|
||||
description: 'Device name reported for Jellyfin remote control sessions.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.pullPictures',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -364,9 +364,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
const stringKeys = [
|
||||
'serverUrl',
|
||||
'username',
|
||||
'deviceId',
|
||||
'clientName',
|
||||
'clientVersion',
|
||||
'defaultLibraryId',
|
||||
'iconCacheDir',
|
||||
'transcodeVideoCodec',
|
||||
|
||||
@@ -57,7 +57,6 @@ test('settings registry hides removed modal-only fields', () => {
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
]) {
|
||||
assert.equal(
|
||||
fields.some((candidate) => candidate.configPath === path),
|
||||
@@ -244,10 +243,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'youtubeSubgen.whisperBin',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.clientName',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
|
||||
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.accessToken',
|
||||
'jellyfin.userId',
|
||||
'jellyfin.clientName',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
'controller.buttonIndices',
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'subtitleSidebar.toggleKey',
|
||||
|
||||
@@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => {
|
||||
const handled: string[] = [];
|
||||
let controlArgvHandler: ((argv: string[]) => void) | null = null;
|
||||
let readyHandler: (() => Promise<void>) | null = null;
|
||||
|
||||
const { deps } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
|
||||
handleCliCommand: (args, source) => {
|
||||
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
|
||||
},
|
||||
startControlServer: (handler) => {
|
||||
controlArgvHandler = handler;
|
||||
},
|
||||
whenReady: (handler) => {
|
||||
readyHandler = handler;
|
||||
},
|
||||
onReady: async () => {
|
||||
handled.push('ready');
|
||||
throw new Error('ready failed');
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ background: true }), deps);
|
||||
|
||||
assert.ok(controlArgvHandler);
|
||||
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||
assert.deepEqual(handled, []);
|
||||
|
||||
assert.ok(readyHandler);
|
||||
await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/);
|
||||
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||
|
||||
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
|
||||
@@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
}
|
||||
|
||||
deps.whenReady(async () => {
|
||||
await deps.onReady();
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
try {
|
||||
await deps.onReady();
|
||||
} finally {
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
}
|
||||
});
|
||||
|
||||
deps.onWindowAllClosed(() => {
|
||||
|
||||
@@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
|
||||
const payload = buildDiscordPresenceActivity(baseConfig, {
|
||||
...baseSnapshot,
|
||||
mediaTitle: null,
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
|
||||
});
|
||||
|
||||
assert.equal(payload.details, 'Unknown media');
|
||||
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
||||
const serialized = JSON.stringify(payload);
|
||||
assert.equal(serialized.includes('api_key'), false);
|
||||
assert.equal(serialized.includes('secret-token'), false);
|
||||
assert.equal(serialized.includes('/Videos/item-1/stream'), false);
|
||||
});
|
||||
|
||||
test('service deduplicates identical updates and sends changed timeline', async () => {
|
||||
const sent: DiscordActivityPayload[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
|
||||
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
|
||||
return parts[parts.length - 1] ?? '';
|
||||
}
|
||||
|
||||
function fallbackTitleFromMediaPath(mediaPath: string | null): string {
|
||||
const trimmed = mediaPath?.trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) {
|
||||
return '';
|
||||
}
|
||||
return basename(trimmed).split(/[?#]/)[0] ?? '';
|
||||
}
|
||||
|
||||
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
|
||||
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
||||
if (snapshot.paused) return 'Paused';
|
||||
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
|
||||
): DiscordActivityPayload {
|
||||
const style = resolvePresenceStyle(config.presenceStyle);
|
||||
const status = buildStatus(snapshot);
|
||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||
const title = sanitizeText(
|
||||
snapshot.mediaTitle,
|
||||
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
|
||||
);
|
||||
const details =
|
||||
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||
|
||||
@@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.destroy();
|
||||
tracker = null;
|
||||
|
||||
tracker = new Ctor({ dbPath });
|
||||
tracker.recordJellyfinPlaybackMetadata({
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
|
||||
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
itemTitle: 'The Princess Begins Adventuring',
|
||||
seriesTitle: 'The Beginning After the End',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 2,
|
||||
itemId: 'item-2',
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
tracker.handleMediaChange(null, null);
|
||||
tracker.recordJellyfinPlaybackMetadata({
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
itemTitle: 'The Princess Begins Adventuring',
|
||||
seriesTitle: 'The Beginning After the End',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 2,
|
||||
itemId: 'item-2',
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.source_url,
|
||||
v.canonical_title AS video_title,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
v.parser_source,
|
||||
a.canonical_title AS anime_title
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
ORDER BY v.video_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
source_url: string | null;
|
||||
video_title: string;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
parser_source: string | null;
|
||||
anime_title: string;
|
||||
}>;
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
|
||||
const jellyfinRow = rows.find(
|
||||
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
|
||||
);
|
||||
assert.ok(jellyfinRow);
|
||||
assert.equal(
|
||||
jellyfinRow.video_title,
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End');
|
||||
assert.equal(jellyfinRow.parsed_season, 2);
|
||||
assert.equal(jellyfinRow.parsed_episode, 2);
|
||||
assert.equal(jellyfinRow.parser_source, 'jellyfin');
|
||||
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies configurable queue, flush, and retention policy', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -301,6 +301,33 @@ export type {
|
||||
VocabularyStatsRow,
|
||||
} from './immersion-tracker/types';
|
||||
|
||||
export interface JellyfinPlaybackMetadataInput {
|
||||
mediaPath: string;
|
||||
displayTitle: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
function normalizeMetadataInt(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
|
||||
const normalizedItemId = normalizeText(itemId);
|
||||
if (!normalizedItemId) {
|
||||
return mediaPath;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`;
|
||||
} catch {
|
||||
return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImmersionTrackerService {
|
||||
private readonly logger = createLogger('main:immersion-tracker');
|
||||
private readonly db: DatabaseSync;
|
||||
@@ -337,6 +364,7 @@ export class ImmersionTrackerService {
|
||||
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||
private readonly recordedSubtitleKeys = new Set<string>();
|
||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||
private readonly mediaPathAliases = new Map<string, string>();
|
||||
private readonly resolveLegacyVocabularyPos:
|
||||
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
||||
| undefined;
|
||||
@@ -1115,8 +1143,85 @@ export class ImmersionTrackerService {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
|
||||
const rawPath = normalizeMediaPath(metadata.mediaPath);
|
||||
if (!rawPath) {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
this.mediaPathAliases.set(rawPath, normalizedPath);
|
||||
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) ||
|
||||
normalizeText(metadata.itemTitle) ||
|
||||
deriveCanonicalTitle(normalizedPath);
|
||||
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const libraryTitle = seriesTitle || itemTitle;
|
||||
if (!libraryTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = getOrCreateVideoRecord(
|
||||
this.db,
|
||||
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
|
||||
{
|
||||
canonicalTitle: displayTitle,
|
||||
sourcePath: null,
|
||||
sourceUrl: normalizedPath,
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const previousLink = this.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | null;
|
||||
const metadataJson = JSON.stringify({
|
||||
source: 'jellyfin',
|
||||
itemId: normalizeText(metadata.itemId) || null,
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
|
||||
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(this.db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: libraryTitle,
|
||||
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
|
||||
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
|
||||
parserSource: 'jellyfin',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: metadataJson,
|
||||
});
|
||||
|
||||
const hasLifetimeMedia = Boolean(
|
||||
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
|
||||
);
|
||||
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
private hasJellyfinMetadata(videoId: number): boolean {
|
||||
const row = this.db
|
||||
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { parserSource: string | null } | null;
|
||||
return row?.parserSource === 'jellyfin';
|
||||
}
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
const rawPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
this.logger.info(
|
||||
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
||||
@@ -1164,7 +1269,7 @@ export class ImmersionTrackerService {
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
} else {
|
||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||
|
||||
@@ -560,6 +560,10 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
|
||||
|
||||
assert.equal(plan.mode, 'direct');
|
||||
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
|
||||
assert.equal(plan.itemTitle, 'A New Hope');
|
||||
assert.equal(plan.seriesTitle, 'Galaxy Quest');
|
||||
assert.equal(plan.seasonNumber, 2);
|
||||
assert.equal(plan.episodeNumber, 7);
|
||||
assert.equal(plan.audioStreamIndex, 6);
|
||||
assert.equal(plan.subtitleStreamIndex, 9);
|
||||
assert.equal(plan.startTimeTicks, 35_000_000);
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan {
|
||||
mode: 'direct' | 'transcode';
|
||||
url: string;
|
||||
title: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
startTimeTicks: number;
|
||||
audioStreamIndex: number | null;
|
||||
subtitleStreamIndex: number | null;
|
||||
@@ -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 {
|
||||
const itemTitle = getItemTitle(item);
|
||||
if (item.Type === 'Episode') {
|
||||
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
|
||||
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
|
||||
const prefix = item.SeriesName ? `${item.SeriesName} ` : '';
|
||||
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim();
|
||||
const seriesTitle = getSeriesTitle(item);
|
||||
const prefix = seriesTitle ? `${seriesTitle} ` : '';
|
||||
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim();
|
||||
}
|
||||
return ensureString(item.Name).trim() || 'Jellyfin Item';
|
||||
return itemTitle;
|
||||
}
|
||||
|
||||
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
|
||||
@@ -521,10 +535,16 @@ export async function resolvePlaybackPlan(
|
||||
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
|
||||
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
|
||||
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
|
||||
const itemTitle = getItemTitle(item);
|
||||
const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null;
|
||||
const basePlan: JellyfinPlaybackPlan = {
|
||||
mode: 'transcode',
|
||||
url: '',
|
||||
title: getDisplayTitle(item),
|
||||
itemTitle,
|
||||
seriesTitle,
|
||||
seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null,
|
||||
episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null,
|
||||
startTimeTicks,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
|
||||
@@ -1213,6 +1213,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
macOSForegroundProbeActive: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
});
|
||||
|
||||
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -70,6 +70,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
lastKnownWindowsForegroundProcessName?: string | null;
|
||||
windowsOverlayProcessName?: string | null;
|
||||
windowsFocusHandoffGraceActive?: boolean;
|
||||
macOSForegroundProbeActive?: boolean;
|
||||
trackerNotReadyWarningShown: boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
@@ -115,6 +116,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||
const shouldPreserveMacOSOverlayDuringForegroundProbe =
|
||||
args.isMacOSPlatform &&
|
||||
args.macOSForegroundProbeActive === true &&
|
||||
!!windowTracker &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
(windowTracker.isTracking() || windowTracker.getGeometry() !== null);
|
||||
const hasTransientMacOSTrackerLoss =
|
||||
args.isMacOSPlatform &&
|
||||
canReportMacOSTargetMinimized &&
|
||||
@@ -124,7 +131,10 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
trackedMacOSTargetFocused !== false &&
|
||||
mainWindow.isVisible();
|
||||
const isTrackedMacOSTargetFocused =
|
||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||
hasTransientMacOSTrackerLoss ||
|
||||
shouldPreserveMacOSOverlayDuringForegroundProbe ||
|
||||
!args.isMacOSPlatform ||
|
||||
!args.windowTracker
|
||||
? true
|
||||
: (trackedMacOSTargetFocused ?? true);
|
||||
const shouldReleaseMacOSOverlayLevel =
|
||||
|
||||
@@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
|
||||
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let ffsubsyncAvailable: boolean | null = null;
|
||||
let inProgressState: boolean | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(ffsubsyncAvailable, true);
|
||||
assert.ok(osd.includes('Subsync: choose engine and source'));
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => {
|
||||
let ffsubsyncAvailable: boolean | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return null;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': 'https://jellyfin.example/subs/eng.srt',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (payload) => {
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(ffsubsyncAvailable, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
@@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps
|
||||
const client = getMpvClientForSubsync(deps);
|
||||
const context = await gatherSubsyncContext(client);
|
||||
const payload: SubsyncManualPayload = {
|
||||
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
||||
sourceTracks: context.sourceTracks
|
||||
.filter((track) => typeof track.id === 'number')
|
||||
.map((track) => ({
|
||||
|
||||
@@ -129,7 +129,7 @@ test('tokenizeSubtitle splits same-line grammar endings before applying annotati
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 40);
|
||||
assert.equal(result.tokens?.[1]?.surface, 'です');
|
||||
assert.equal(result.tokens?.[1]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.isKnown, true);
|
||||
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
|
||||
assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
|
||||
@@ -3365,7 +3365,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle clears known-word highlight for exact non-independent kanji noun tokens', async () => {
|
||||
test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanji noun tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'その点',
|
||||
makeDepsFromYomitanTokens(
|
||||
@@ -3413,7 +3413,7 @@ test('tokenizeSubtitle clears known-word highlight for exact non-independent kan
|
||||
assert.equal(result.tokens?.length, 2);
|
||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.surface, '点');
|
||||
assert.equal(result.tokens?.[1]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.isKnown, true);
|
||||
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
|
||||
assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
|
||||
@@ -4028,7 +4028,7 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper
|
||||
{
|
||||
surface: 'これで',
|
||||
headword: 'これ',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -4143,7 +4143,7 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
|
||||
{
|
||||
surface: 'のかな',
|
||||
headword: 'の',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -4672,7 +4672,7 @@ test('tokenizeSubtitle clears annotations for ja-nai explanatory endings and aru
|
||||
{
|
||||
surface: 'ある',
|
||||
headword: 'ある',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -4717,7 +4717,7 @@ test('tokenizeSubtitle clears annotations for standalone polite copula endings w
|
||||
{
|
||||
surface: 'ですよ',
|
||||
headword: 'です',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -5044,7 +5044,7 @@ test('tokenizeSubtitle clears annotations for auxiliary inflection fragments whi
|
||||
{
|
||||
surface: 'れた',
|
||||
headword: 'れる',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -5181,7 +5181,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
|
||||
{
|
||||
surface: 'てく',
|
||||
headword: 'てく',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -5192,7 +5192,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
|
||||
{
|
||||
surface: 'れた',
|
||||
headword: 'れる',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
|
||||
@@ -425,6 +425,21 @@ test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside exp
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
||||
});
|
||||
|
||||
test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-independent kanji nouns from non-known annotations', () => {
|
||||
const token = makeToken({
|
||||
surface: '以外',
|
||||
headword: '以外',
|
||||
reading: 'イガイ',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '非自立',
|
||||
pos3: '副詞可能',
|
||||
});
|
||||
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true);
|
||||
});
|
||||
|
||||
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -971,8 +986,8 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, false);
|
||||
assert.equal(result[2]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, true);
|
||||
assert.equal(result[2]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
@@ -1186,7 +1201,7 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known-word status for non-independent kanji noun tokens', () => {
|
||||
test('annotateTokens keeps known-word status for non-independent kanji noun tokens', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '点',
|
||||
@@ -1211,12 +1226,41 @@ test('annotateTokens clears known-word status for non-independent kanji noun tok
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '以外',
|
||||
reading: 'イガイ',
|
||||
headword: '以外',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '非自立',
|
||||
pos3: '副詞可能',
|
||||
startPos: 2,
|
||||
endPos: 4,
|
||||
frequencyRank: 437,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '以外',
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -1401,7 +1445,7 @@ test('annotateTokens excludes composite tokens when all component pos tags are e
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens applies one shared exclusion gate across known N+1 frequency and JLPT', () => {
|
||||
test('annotateTokens lets known words bypass the shared exclusion gate for known status only', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'これで',
|
||||
@@ -1425,13 +1469,13 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for kana-only non-independent noun helper merges', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for kana-only non-independent noun helper merges', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ことに',
|
||||
@@ -1455,13 +1499,13 @@ test('annotateTokens clears known status and other annotations for kana-only non
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone auxiliary inflection fragments', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone auxiliary inflection fragments', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'れる',
|
||||
@@ -1497,14 +1541,14 @@ test('annotateTokens clears known status and other annotations for standalone au
|
||||
);
|
||||
|
||||
for (const token of result) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for auxiliary-only te-kureru helper spans', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'てく',
|
||||
@@ -1540,7 +1584,7 @@ test('annotateTokens clears known status and other annotations for auxiliary-onl
|
||||
);
|
||||
|
||||
for (const token of result) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
@@ -1576,7 +1620,7 @@ test('annotateTokens keeps lexical くれる forms eligible for annotation', ()
|
||||
assert.equal(result[0]?.jlptLevel, 'N4');
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone して helper fragments', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone して helper fragments', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'してる',
|
||||
@@ -1600,13 +1644,13 @@ test('annotateTokens clears known status and other annotations for standalone
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone particle fragments without POS tags', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone particle fragments without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'と',
|
||||
@@ -1630,13 +1674,13 @@ test('annotateTokens clears known status and other annotations for standalone pa
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status from standalone particles even when the known-word cache contains them', () => {
|
||||
test('annotateTokens keeps known status on standalone particles when the known-word cache contains them', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'に',
|
||||
@@ -1671,7 +1715,7 @@ test('annotateTokens clears known status from standalone particles even when the
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
@@ -1728,7 +1772,7 @@ test('annotateTokens does not mark standalone connective particles as N+1', () =
|
||||
assert.equal(result[1]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for rhetorical もんか grammar particle phrases', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for rhetorical もんか grammar particle phrases', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'もんか',
|
||||
@@ -1752,13 +1796,13 @@ test('annotateTokens clears known status and other annotations for rhetorical
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for bare くれ auxiliary fragments', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for bare くれ auxiliary fragments', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'くれ',
|
||||
@@ -1782,13 +1826,13 @@ test('annotateTokens clears known status and other annotations for bare くれ a
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for aru existence verbs', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for aru existence verbs', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '有る',
|
||||
@@ -1818,14 +1862,14 @@ test('annotateTokens clears known status and other annotations for aru existence
|
||||
|
||||
assert.equal(result[0]?.surface, '有る');
|
||||
assert.equal(result[0]?.headword, '有る');
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.isNameMatch, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone quote particle and auxiliary grammar terms', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone quote particle and auxiliary grammar terms', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'って',
|
||||
@@ -1861,14 +1905,14 @@ test('annotateTokens clears known status and other annotations for standalone qu
|
||||
);
|
||||
|
||||
for (const token of result) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations from standalone あ interjections without POS tags', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations from standalone あ interjections without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'あ',
|
||||
@@ -1898,13 +1942,13 @@ test('annotateTokens clears known status and other annotations from standalone
|
||||
assert.equal(result[0]?.surface, 'あ');
|
||||
assert.equal(result[0]?.headword, 'あ');
|
||||
assert.equal(result[0]?.reading, 'あ');
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations from expressive subtitle interjections without POS tags', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations from expressive subtitle interjections without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ハァ',
|
||||
@@ -1960,7 +2004,7 @@ test('annotateTokens clears all annotations from expressive subtitle interjectio
|
||||
);
|
||||
|
||||
for (const token of result.slice(0, 2)) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
|
||||
@@ -680,6 +680,11 @@ export function annotateTokens(
|
||||
|
||||
// Single pass: compute known word status, frequency filtering, and JLPT level together
|
||||
const annotated = tokens.map((token, index) => {
|
||||
const isKnownForMatching = shouldComputeKnownStatus
|
||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
if (
|
||||
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
||||
pos1Exclusions,
|
||||
@@ -690,18 +695,13 @@ export function annotateTokens(
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
});
|
||||
nPlusOneKnownStatuses[index] = false;
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: false,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
};
|
||||
}
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
const isKnownForMatching = shouldComputeKnownStatus
|
||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const frequencyRank =
|
||||
frequencyEnabled && !prioritizedNameMatch
|
||||
|
||||
+103
-11
@@ -399,6 +399,11 @@ import {
|
||||
launchWindowsMpv,
|
||||
} from './main/runtime/windows-mpv-launch';
|
||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||
import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
} from './main/runtime/jellyfin-device-identity';
|
||||
import {
|
||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
|
||||
@@ -502,6 +507,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
|
||||
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import {
|
||||
createElectronAppUpdater,
|
||||
@@ -608,6 +614,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
|
||||
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => os.tmpdir(),
|
||||
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
|
||||
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
|
||||
removeDir: (dir, options) => {
|
||||
fs.rmSync(dir, options);
|
||||
},
|
||||
fetch: (url) => fetch(url),
|
||||
});
|
||||
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
|
||||
const ANILIST_DEFAULT_CLIENT_ID = '36084';
|
||||
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
|
||||
@@ -2237,6 +2252,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
||||
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
|
||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
appState.trackerNotReadyWarningShown = shown;
|
||||
@@ -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_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
@@ -2288,6 +2305,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let visibleOverlayInteractionActive = false;
|
||||
let macOSVisibleOverlayForegroundProbeActive = false;
|
||||
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||
@@ -2303,6 +2323,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||
}
|
||||
|
||||
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
|
||||
if (token !== macOSVisibleOverlayForegroundProbeToken) {
|
||||
return;
|
||||
}
|
||||
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||
macOSVisibleOverlayForegroundProbeTimeout = null;
|
||||
}
|
||||
if (!macOSVisibleOverlayForegroundProbeActive) {
|
||||
return;
|
||||
}
|
||||
macOSVisibleOverlayForegroundProbeActive = false;
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function startMacOSVisibleOverlayForegroundProbe(): void {
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
const tracker = appState.windowTracker;
|
||||
if (!tracker) {
|
||||
return;
|
||||
}
|
||||
|
||||
macOSVisibleOverlayForegroundProbeActive = true;
|
||||
const token = ++macOSVisibleOverlayForegroundProbeToken;
|
||||
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||
}
|
||||
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
|
||||
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
|
||||
|
||||
void tracker
|
||||
.refreshNow()
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
|
||||
})
|
||||
.finally(() => {
|
||||
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||
});
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8
|
||||
@@ -2501,6 +2564,7 @@ function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
if (process.platform === 'win32') {
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
}
|
||||
startMacOSVisibleOverlayForegroundProbe();
|
||||
clearVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
@@ -2747,6 +2811,7 @@ const {
|
||||
reportJellyfinRemoteStopped,
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
cleanupJellyfinSubtitleCache,
|
||||
runJellyfinCommand,
|
||||
openJellyfinSetupWindow,
|
||||
getJellyfinClientInfo,
|
||||
@@ -2758,7 +2823,9 @@ const {
|
||||
},
|
||||
getJellyfinClientInfoMainDeps: {
|
||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin,
|
||||
getHostName: () => os.hostname(),
|
||||
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
},
|
||||
waitForMpvConnectedMainDeps: {
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
@@ -2770,6 +2837,15 @@ const {
|
||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||
platform: process.platform,
|
||||
execPath: process.execPath,
|
||||
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
|
||||
getInstalledPluginDetection: () =>
|
||||
detectInstalledMpvPlugin({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: app.getPath('appData'),
|
||||
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
|
||||
}),
|
||||
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||
@@ -2805,6 +2881,8 @@ const {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
|
||||
logDebug: (message, error) => {
|
||||
logger.debug(message, error);
|
||||
},
|
||||
@@ -2823,6 +2901,7 @@ const {
|
||||
},
|
||||
),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => setVisibleOverlayVisible(true),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
armQuitOnDisconnect: () => {
|
||||
jellyfinPlayQuitOnDisconnectArmed = false;
|
||||
@@ -2846,6 +2925,13 @@ const {
|
||||
showMpvOsd: (text) => {
|
||||
showMpvOsd(text);
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
mediaRuntime.updateCurrentMediaTitle(title);
|
||||
},
|
||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
||||
},
|
||||
},
|
||||
remoteComposerOptions: {
|
||||
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
|
||||
@@ -2906,11 +2992,13 @@ const {
|
||||
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
|
||||
},
|
||||
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
|
||||
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
|
||||
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
|
||||
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
|
||||
getHostName: () => os.hostname(),
|
||||
defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
|
||||
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, details) => logger.warn(message, details),
|
||||
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||
},
|
||||
stopJellyfinRemoteSessionMainDeps: {
|
||||
getCurrentSession: () => appState.jellyfinRemoteSession,
|
||||
@@ -2920,6 +3008,7 @@ const {
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
},
|
||||
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||
},
|
||||
runJellyfinCommandMainDeps: {
|
||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||
@@ -2940,7 +3029,6 @@ const {
|
||||
clearStoredSession: () =>
|
||||
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
patchJellyfinConfig: (session) => {
|
||||
const clientInfo = getJellyfinClientInfo();
|
||||
const recentServers = mergeJellyfinRecentServers(
|
||||
session.serverUrl,
|
||||
getResolvedConfig().jellyfin.recentServers || [],
|
||||
@@ -2950,9 +3038,6 @@ const {
|
||||
enabled: true,
|
||||
serverUrl: session.serverUrl,
|
||||
username: session.username,
|
||||
deviceId: clientInfo.deviceId,
|
||||
clientName: clientInfo.clientName,
|
||||
clientVersion: clientInfo.clientVersion,
|
||||
recentServers,
|
||||
},
|
||||
});
|
||||
@@ -3615,6 +3700,7 @@ const {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
appState.discordPresenceService = null;
|
||||
@@ -4273,8 +4359,8 @@ const {
|
||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
}
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
startupOsdSequencer.reset();
|
||||
@@ -5961,6 +6047,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime,
|
||||
platform: process.platform,
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
@@ -5976,8 +6063,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
isJellyfinConfigured: () =>
|
||||
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
|
||||
toggleJellyfinDiscovery: () =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
toggleJellyfinDiscovery: (checked: boolean) =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
|
||||
desiredActive: checked,
|
||||
}),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||
checkForUpdates: () => {
|
||||
void getUpdateService().checkForUpdates({ source: 'manual' });
|
||||
@@ -6209,6 +6298,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
@@ -6220,6 +6310,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
@@ -6230,6 +6321,7 @@ function toggleVisibleOverlay(): void {
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
|
||||
@@ -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', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
|
||||
@@ -313,7 +313,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ sourceTracks: [] },
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
@@ -459,7 +459,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ sourceTracks: [] },
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
getWindowsOverlayProcessName?: () => string | null;
|
||||
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||
getMacOSForegroundProbeActive?: () => boolean;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
@@ -56,6 +57,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false,
|
||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
deps.setTrackerNotReadyWarningShown(shown);
|
||||
|
||||
@@ -40,18 +40,63 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.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', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -59,7 +60,11 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.clearFirstRunSetupWindow();
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
try {
|
||||
deps.stopJellyfinRemoteSession();
|
||||
} finally {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
}
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
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-yomitan-settings-window'));
|
||||
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('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
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,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
);
|
||||
assert.equal(scheduled.length > 0, true);
|
||||
});
|
||||
|
||||
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
|
||||
@@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let playbackPaused = true;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => playbackPaused,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => playbackPaused,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
|
||||
playbackPaused = false;
|
||||
}
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
playbackPaused = true;
|
||||
const retry = scheduled.shift();
|
||||
retry?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.markCurrentMediaAutoplayReady();
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
|
||||
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
const getSignalMediaPath = (): string =>
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
|
||||
const markCurrentMediaAutoplayReady = (): void => {
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
retryDelayMs: releaseRetryDelayMs,
|
||||
});
|
||||
let releaseUnpauseSent = false;
|
||||
|
||||
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
|
||||
try {
|
||||
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||
if (!shouldUnpause) {
|
||||
return;
|
||||
}
|
||||
|
||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||
releaseUnpauseSent = true;
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
flushPendingAutoplayReadySignal,
|
||||
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
||||
invalidatePendingAutoplayReadyFallbacks,
|
||||
markCurrentMediaAutoplayReady,
|
||||
maybeSignalPluginAutoplayReady,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export function composeJellyfinRemoteHandlers(
|
||||
getConfiguredSession: options.getConfiguredSession,
|
||||
getClientInfo: options.getClientInfo,
|
||||
getJellyfinConfig: options.getJellyfinConfig,
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
playJellyfinItem: options.playJellyfinItem,
|
||||
logWarn: options.logWarn,
|
||||
});
|
||||
|
||||
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
},
|
||||
getJellyfinClientInfoMainDeps: {
|
||||
getResolvedJellyfinConfig: () => ({}) as never,
|
||||
getDefaultJellyfinConfig: () => ({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: 'test',
|
||||
deviceId: 'dev',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
},
|
||||
waitForMpvConnectedMainDeps: {
|
||||
getMpvClient: () => null,
|
||||
@@ -50,6 +48,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
|
||||
cleanupCachedSubtitles: () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
playJellyfinItemInMpvMainDeps: {
|
||||
@@ -58,11 +58,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
mode: 'direct',
|
||||
url: 'https://example.test/video.m3u8',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => undefined,
|
||||
@@ -133,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
defaultDeviceId: 'dev',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
getHostName: () => 'workstation',
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
@@ -189,6 +195,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
||||
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
|
||||
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
||||
|
||||
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
||||
>;
|
||||
startJellyfinRemoteSessionMainDeps: Omit<
|
||||
StartRemoteSessionMainDeps,
|
||||
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
|
||||
| 'getJellyfinConfig'
|
||||
| 'getClientInfo'
|
||||
| 'handlePlay'
|
||||
| 'handlePlaystate'
|
||||
| 'handleGeneralCommand'
|
||||
>;
|
||||
stopJellyfinRemoteSessionMainDeps: Parameters<
|
||||
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
||||
@@ -142,6 +146,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemoteGeneralCommand'];
|
||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
||||
@@ -235,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
createBuildStartJellyfinRemoteSessionMainDepsHandler({
|
||||
...options.startJellyfinRemoteSessionMainDeps,
|
||||
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getClientInfo: () => getJellyfinClientInfo(),
|
||||
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
|
||||
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
|
||||
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
|
||||
@@ -280,6 +286,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
playJellyfinItemInMpv,
|
||||
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
runJellyfinCommand,
|
||||
|
||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
|
||||
export * from '../jellyfin-client-info-main-deps';
|
||||
export * from '../jellyfin-command-dispatch';
|
||||
export * from '../jellyfin-command-dispatch-main-deps';
|
||||
export * from '../jellyfin-device-identity';
|
||||
export * from '../jellyfin-playback-launch';
|
||||
export * from '../jellyfin-playback-launch-main-deps';
|
||||
export * from '../jellyfin-remote-commands';
|
||||
|
||||
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
|
||||
enabled: true,
|
||||
serverUrl: 'http://localhost',
|
||||
username: 'user',
|
||||
deviceId: 'd1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
recentServers: ['http://localhost'],
|
||||
},
|
||||
});
|
||||
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
|
||||
});
|
||||
|
||||
test('persistJellyfinAuthSession stores client metadata and recent servers', () => {
|
||||
test('persistJellyfinAuthSession stores session config and recent servers', () => {
|
||||
let patchPayload: unknown = null;
|
||||
let storedSession: unknown = null;
|
||||
|
||||
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
|
||||
enabled: true,
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
deviceId: 'device-1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
recentServers: [
|
||||
'http://localhost:8096',
|
||||
'http://old.example:8096',
|
||||
@@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
|
||||
let patchPayload: unknown = null;
|
||||
|
||||
persistJellyfinAuthSession({
|
||||
session: {
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
},
|
||||
clientInfo: {
|
||||
deviceId: 'subminer-local-pc',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
},
|
||||
existingRecentServers: [],
|
||||
saveStoredSession: () => {},
|
||||
patchRawConfig: (patch) => {
|
||||
patchPayload = patch;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(patchPayload, {
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
recentServers: ['http://localhost:8096'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin auth handler no-ops when no auth command', async () => {
|
||||
const handleAuth = createHandleJellyfinAuthCommands({
|
||||
patchRawConfig: () => {},
|
||||
|
||||
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
recentServers: string[];
|
||||
}>;
|
||||
}) => void;
|
||||
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
|
||||
enabled: true,
|
||||
serverUrl: deps.session.serverUrl,
|
||||
username: deps.session.username,
|
||||
deviceId: deps.clientInfo.deviceId,
|
||||
clientName: deps.clientInfo.clientName,
|
||||
clientVersion: deps.clientInfo.clientVersion,
|
||||
recentServers: mergeJellyfinRecentServers(
|
||||
deps.session.serverUrl,
|
||||
deps.existingRecentServers || [],
|
||||
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
}>;
|
||||
}) => void;
|
||||
authenticateWithPassword: (
|
||||
|
||||
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
|
||||
|
||||
test('get jellyfin client info main deps builder maps callbacks', () => {
|
||||
const configured = { clientName: 'Configured' };
|
||||
const defaults = { clientName: 'Default' };
|
||||
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
|
||||
getResolvedJellyfinConfig: () => configured as never,
|
||||
getDefaultJellyfinConfig: () => defaults as never,
|
||||
getHostName: () => 'workstation',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
})();
|
||||
|
||||
assert.equal(deps.getResolvedJellyfinConfig(), configured);
|
||||
assert.equal(deps.getDefaultJellyfinConfig(), defaults);
|
||||
assert.equal(deps.getHostName?.(), 'workstation');
|
||||
assert.equal(deps.defaultClientName, 'SubMiner');
|
||||
assert.equal(deps.defaultClientVersion, '1.0.0');
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
|
||||
) {
|
||||
return (): GetJellyfinClientInfoMainDeps => ({
|
||||
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
||||
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(),
|
||||
getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
|
||||
defaultClientName: deps.defaultClientName,
|
||||
defaultClientVersion: deps.defaultClientVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou
|
||||
|
||||
test('jellyfin client info resolves defaults when fields are missing', () => {
|
||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never,
|
||||
getDefaultJellyfinConfig: () =>
|
||||
({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'default-device',
|
||||
}) as never,
|
||||
getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
|
||||
getHostName: () => 'workstation',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
});
|
||||
|
||||
assert.deepEqual(getClientInfo(), {
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'default-device',
|
||||
deviceId: 'workstation',
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin client info keeps explicit config values', () => {
|
||||
test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
|
||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||
getResolvedJellyfinConfig: () =>
|
||||
({
|
||||
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
|
||||
clientVersion: '2.3.4',
|
||||
deviceId: 'custom-device',
|
||||
}) as never,
|
||||
getDefaultJellyfinConfig: () =>
|
||||
({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'default-device',
|
||||
}) as never,
|
||||
getHostName: () => 'Kyle-PC',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
});
|
||||
|
||||
assert.deepEqual(getClientInfo(), {
|
||||
clientName: 'Custom',
|
||||
clientVersion: '2.3.4',
|
||||
deviceId: 'custom-device',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'Kyle-PC',
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin client info ignores legacy configured device id and client version', () => {
|
||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||
getResolvedJellyfinConfig: () =>
|
||||
({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '9.9.9',
|
||||
deviceId: 'custom-device',
|
||||
}) as never,
|
||||
getHostName: () => 'media-box',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
});
|
||||
|
||||
assert.deepEqual(getClientInfo(), {
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'media-box',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
} from './jellyfin-device-identity';
|
||||
|
||||
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
|
||||
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
|
||||
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
|
||||
}
|
||||
|
||||
export function createGetJellyfinClientInfoHandler(deps: {
|
||||
getResolvedJellyfinConfig: () => Partial<
|
||||
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
|
||||
>;
|
||||
getDefaultJellyfinConfig: () => Partial<
|
||||
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
|
||||
>;
|
||||
getResolvedJellyfinConfig: () => unknown;
|
||||
getHostName?: () => string;
|
||||
defaultClientName?: string;
|
||||
defaultClientVersion?: string;
|
||||
}) {
|
||||
return (
|
||||
config = deps.getResolvedJellyfinConfig(),
|
||||
_config = deps.getResolvedJellyfinConfig(),
|
||||
): {
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
} => {
|
||||
const defaults = deps.getDefaultJellyfinConfig();
|
||||
return {
|
||||
clientName: config.clientName || defaults.clientName || '',
|
||||
clientVersion: config.clientVersion || defaults.clientVersion || '',
|
||||
deviceId: config.deviceId || defaults.deviceId || '',
|
||||
clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
|
||||
@@ -49,12 +54,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
url: 'u',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
},
|
||||
);
|
||||
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
|
||||
deps.showVisibleOverlay();
|
||||
deps.sendMpvCommand(['show-text', 'x']);
|
||||
deps.armQuitOnDisconnect();
|
||||
deps.schedule(() => {}, 500);
|
||||
@@ -85,6 +95,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'defaults',
|
||||
'visible-overlay',
|
||||
'cmd:show-text',
|
||||
'arm',
|
||||
'schedule:500',
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => deps.showVisibleOverlay(),
|
||||
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
|
||||
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
|
||||
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
|
||||
@@ -19,5 +20,11 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||
: undefined,
|
||||
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
|
||||
? (title) => deps.updateCurrentMediaTitle!(title)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
const calls: string[] = [];
|
||||
const activeStates: Array<Record<string, unknown>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const statsMetadata: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
@@ -59,11 +61,16 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startTimeTicks: 12_000_000,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (callback, delayMs) => {
|
||||
@@ -75,6 +82,8 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
recordJellyfinPlaybackMetadata: (metadata) =>
|
||||
statsMetadata.push(metadata as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await handler({
|
||||
@@ -87,16 +96,22 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
assert.deepEqual(commands.slice(0, 5), [
|
||||
['set_property', 'sub-auto', 'no'],
|
||||
['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'],
|
||||
['seek', 1.2, 'absolute+exact'],
|
||||
]);
|
||||
assert.equal(scheduled.length, 1);
|
||||
assert.equal(scheduled[0]?.delay, 500);
|
||||
scheduled[0]?.callback();
|
||||
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
|
||||
assert.equal(scheduled.length, 0);
|
||||
assert.equal(
|
||||
commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
|
||||
1,
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('defaults'));
|
||||
assert.ok(calls.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('preload'));
|
||||
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(reportPayloads.length, 1);
|
||||
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 () => {
|
||||
@@ -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',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -147,3 +224,89 @@ test('playback handler applies start override to stream url for remote resume',
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,16 @@ type ActivePlaybackState = {
|
||||
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 {
|
||||
if (typeof startTimeTicksOverride !== 'number') return url;
|
||||
try {
|
||||
@@ -43,6 +53,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
subtitleStreamIndex?: number | null;
|
||||
}) => Promise<JellyfinPlaybackPlan>;
|
||||
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
|
||||
showVisibleOverlay: () => void;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
armQuitOnDisconnect: () => void;
|
||||
schedule: (callback: () => void, delayMs: number) => void;
|
||||
@@ -63,6 +74,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
|
||||
updateCurrentMediaTitle?: (title: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinAuthSession;
|
||||
@@ -94,19 +107,26 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
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']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand([
|
||||
'set_property',
|
||||
'force-media-title',
|
||||
`[Jellyfin/${plan.mode}] ${plan.title}`,
|
||||
]);
|
||||
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.schedule(() => {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
}, 500);
|
||||
|
||||
const startTimeTicks =
|
||||
typeof params.startTimeTicksOverride === 'number'
|
||||
@@ -116,6 +136,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay();
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
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.']);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
|
||||
let playCalls = 0;
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'user',
|
||||
username: 'name',
|
||||
}),
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
|
||||
getJellyfinConfig: () => ({}),
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
playJellyfinItem: async () => {
|
||||
playCalls += 1;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await handlePlay({ ItemIds: ['item-1'] });
|
||||
|
||||
assert.equal(playCalls, 0);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
|
||||
const mpvClient = {};
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
|
||||
@@ -51,6 +51,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
||||
getConfiguredSession: () => JellyfinSession | null;
|
||||
getClientInfo: () => JellyfinClientInfo;
|
||||
getJellyfinConfig: () => unknown;
|
||||
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
playJellyfinItem: (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
@@ -79,6 +80,9 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
||||
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
|
||||
return;
|
||||
}
|
||||
if (deps.getActivePlayback?.()?.itemId === itemId) {
|
||||
return;
|
||||
}
|
||||
await deps.playJellyfinItem({
|
||||
session,
|
||||
clientInfo,
|
||||
|
||||
@@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
getLaunchMode: () => 'fullscreen',
|
||||
platform: 'darwin',
|
||||
execPath: '/tmp/subminer',
|
||||
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
source: null,
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--no-config'],
|
||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||
@@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
assert.equal(deps.getLaunchMode(), 'fullscreen');
|
||||
assert.equal(deps.platform, 'darwin');
|
||||
assert.equal(deps.execPath, '/tmp/subminer');
|
||||
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
||||
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
|
||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||
deps.removeSocketPath('/tmp/mpv.sock');
|
||||
|
||||
@@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
execPath: deps.execPath,
|
||||
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
|
||||
getInstalledPluginDetection: deps.getInstalledPluginDetection,
|
||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
|
||||
@@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
getLaunchMode: () => 'maximized',
|
||||
platform: 'darwin',
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
getRuntimePluginEntrypoint: () =>
|
||||
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
@@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
assert.equal(spawnedArgs.length, 1);
|
||||
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
|
||||
assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
|
||||
assert.ok(
|
||||
spawnedArgs[0]!.includes(
|
||||
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
),
|
||||
);
|
||||
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
|
||||
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
||||
});
|
||||
@@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: true,
|
||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
||||
version: '0.1.0',
|
||||
source: 'default-config',
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
assert.equal(
|
||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
||||
false,
|
||||
);
|
||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type SubminerPluginRuntimeScriptOptConfig,
|
||||
} from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
@@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = {
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
@@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.();
|
||||
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||
? ''
|
||||
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||
if (installedPlugin?.installed && installedPlugin.path) {
|
||||
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
|
||||
}
|
||||
const mpvArgs = [
|
||||
...deps.defaultMpvArgs,
|
||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
|
||||
'--idle=yes',
|
||||
scriptOpts,
|
||||
`--log-file=${deps.defaultMpvLogPath}`,
|
||||
|
||||
@@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
|
||||
getConfiguredSession: () => deps.getConfiguredSession(),
|
||||
getClientInfo: () => deps.getClientInfo(),
|
||||
getJellyfinConfig: () => deps.getJellyfinConfig(),
|
||||
...(deps.getActivePlayback
|
||||
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
|
||||
: {}),
|
||||
playJellyfinItem: (params) => deps.playJellyfinItem(params),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
});
|
||||
|
||||
@@ -61,6 +61,38 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
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 () => {
|
||||
let called = false;
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
|
||||
@@ -31,6 +31,19 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
|
||||
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 = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
@@ -64,7 +77,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
||||
isPaused: paused === true,
|
||||
isPaused: isMpvPauseEnabled(paused),
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
|
||||
@@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
|
||||
serverUrl: 'http://localhost',
|
||||
accessToken: 'token',
|
||||
userId: 'user-id',
|
||||
deviceId: '',
|
||||
clientName: '',
|
||||
clientVersion: '',
|
||||
remoteControlDeviceName: '',
|
||||
autoAnnounce: false,
|
||||
...(overrides || {}),
|
||||
} as never;
|
||||
@@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => {
|
||||
} | null = null;
|
||||
let started = false;
|
||||
const infos: string[] = [];
|
||||
let stateChanges = 0;
|
||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
|
||||
getCurrentSession: () => null,
|
||||
@@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => {
|
||||
storedSession = session as never;
|
||||
},
|
||||
createRemoteSessionService: (options) => {
|
||||
assert.equal(options.deviceName, 'Desk');
|
||||
assert.equal(options.deviceName, 'workstation');
|
||||
return {
|
||||
start: () => {
|
||||
started = true;
|
||||
@@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
logInfo: (message) => infos.push(message),
|
||||
logWarn: () => {},
|
||||
onSessionStateChanged: () => {
|
||||
stateChanges += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await startRemote();
|
||||
|
||||
assert.equal(started, true);
|
||||
assert.ok(storedSession);
|
||||
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).')));
|
||||
assert.equal(stateChanges, 1);
|
||||
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).')));
|
||||
});
|
||||
|
||||
test('start handler uses hostname-derived client info and visible device name', async () => {
|
||||
let createdOptions: {
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceName: string;
|
||||
} | null = null;
|
||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||
getJellyfinConfig: () =>
|
||||
createConfig({
|
||||
clientName: 'SubMiner',
|
||||
}),
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'kyle-pc',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
}),
|
||||
getHostName: () => 'kyle-pc',
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => {},
|
||||
createRemoteSessionService: (options) => {
|
||||
createdOptions = {
|
||||
deviceId: options.deviceId,
|
||||
clientName: options.clientName,
|
||||
clientVersion: options.clientVersion,
|
||||
deviceName: options.deviceName,
|
||||
};
|
||||
return {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
advertiseNow: async () => true,
|
||||
};
|
||||
},
|
||||
defaultDeviceId: 'subminer',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '0.1.0',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await startRemote({ explicit: true });
|
||||
|
||||
assert.deepEqual(createdOptions, {
|
||||
deviceId: 'kyle-pc',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
deviceName: 'kyle-pc',
|
||||
});
|
||||
});
|
||||
|
||||
test('start handler ignores configured visible device name', async () => {
|
||||
let createdDeviceName = '';
|
||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||
getJellyfinConfig: () =>
|
||||
createConfig({
|
||||
remoteControlDeviceName: 'SubMiner Cachy sudacode',
|
||||
}),
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'cachy',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
}),
|
||||
getHostName: () => 'cachy',
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => {},
|
||||
createRemoteSessionService: (options) => {
|
||||
createdDeviceName = options.deviceName;
|
||||
return {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
advertiseNow: async () => true,
|
||||
};
|
||||
},
|
||||
defaultDeviceId: 'subminer',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '0.1.0',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await startRemote({ explicit: true });
|
||||
|
||||
assert.equal(createdDeviceName, 'cachy');
|
||||
});
|
||||
|
||||
test('start handler stops previous session before replacing', async () => {
|
||||
@@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => {
|
||||
test('stop handler stops active session and clears playback', () => {
|
||||
let stopCalls = 0;
|
||||
let clearCalls = 0;
|
||||
let stateChanges = 0;
|
||||
let currentSession: { stop: () => void } | null = {
|
||||
stop: () => {
|
||||
stopCalls += 1;
|
||||
@@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => {
|
||||
clearActivePlayback: () => {
|
||||
clearCalls += 1;
|
||||
},
|
||||
onSessionStateChanged: () => {
|
||||
stateChanges += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stopRemote();
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(clearCalls, 1);
|
||||
assert.equal(currentSession, null);
|
||||
assert.equal(stateChanges, 1);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity';
|
||||
|
||||
type JellyfinRemoteConfig = {
|
||||
enabled: boolean;
|
||||
remoteControlEnabled: boolean;
|
||||
@@ -5,11 +7,13 @@ type JellyfinRemoteConfig = {
|
||||
serverUrl: string;
|
||||
accessToken?: string;
|
||||
userId?: string;
|
||||
autoAnnounce: boolean;
|
||||
};
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
remoteControlDeviceName: string;
|
||||
autoAnnounce: boolean;
|
||||
};
|
||||
|
||||
type JellyfinRemoteService = {
|
||||
@@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
getCurrentSession: () => JellyfinRemoteService | null;
|
||||
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
||||
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
|
||||
getClientInfo: () => JellyfinClientInfo;
|
||||
getHostName: () => string;
|
||||
defaultDeviceId: string;
|
||||
defaultClientName: string;
|
||||
defaultClientVersion: string;
|
||||
@@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
onSessionStateChanged?: () => void;
|
||||
}) {
|
||||
return async (options?: { explicit?: boolean }): Promise<void> => {
|
||||
const jellyfinConfig = deps.getJellyfinConfig();
|
||||
@@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
|
||||
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
|
||||
|
||||
const clientInfo = deps.getClientInfo();
|
||||
const clientName = clientInfo.clientName || deps.defaultClientName;
|
||||
const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion;
|
||||
const deviceName = resolveJellyfinRemoteDeviceName({
|
||||
hostName: deps.getHostName(),
|
||||
});
|
||||
|
||||
const existing = deps.getCurrentSession();
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
@@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
const service = deps.createRemoteSessionService({
|
||||
serverUrl: jellyfinConfig.serverUrl,
|
||||
accessToken: jellyfinConfig.accessToken,
|
||||
deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId,
|
||||
clientName: jellyfinConfig.clientName || deps.defaultClientName,
|
||||
clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion,
|
||||
deviceName:
|
||||
jellyfinConfig.remoteControlDeviceName ||
|
||||
jellyfinConfig.clientName ||
|
||||
deps.defaultClientName,
|
||||
deviceId: clientInfo.deviceId || deps.defaultDeviceId,
|
||||
clientName,
|
||||
clientVersion,
|
||||
deviceName,
|
||||
capabilities: {
|
||||
PlayableMediaTypes: 'Video,Audio',
|
||||
SupportedCommands:
|
||||
@@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
|
||||
service.start();
|
||||
deps.setCurrentSession(service);
|
||||
deps.logInfo(
|
||||
`Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`,
|
||||
);
|
||||
deps.onSessionStateChanged?.();
|
||||
deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
|
||||
getCurrentSession: () => JellyfinRemoteService | null;
|
||||
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
||||
clearActivePlayback: () => void;
|
||||
onSessionStateChanged?: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const session = deps.getCurrentSession();
|
||||
@@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
|
||||
session.stop();
|
||||
deps.setCurrentSession(null);
|
||||
deps.clearActivePlayback();
|
||||
deps.onSessionStateChanged?.();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => calls.push('set-session'),
|
||||
createRemoteSessionService: () => session as never,
|
||||
getClientInfo: () =>
|
||||
({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}) as never,
|
||||
getHostName: () => 'workstation',
|
||||
defaultDeviceId: 'device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
@@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
onSessionStateChanged: () => calls.push('state-changed'),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
|
||||
assert.equal(deps.defaultDeviceId, 'device');
|
||||
assert.equal(deps.defaultClientName, 'SubMiner');
|
||||
assert.equal(deps.defaultClientVersion, '1.0');
|
||||
assert.equal(deps.getHostName(), 'workstation');
|
||||
assert.deepEqual(deps.getClientInfo(), {
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
});
|
||||
assert.equal(deps.createRemoteSessionService({} as never), session);
|
||||
await deps.handlePlay({});
|
||||
await deps.handlePlaystate({});
|
||||
await deps.handleGeneralCommand({});
|
||||
deps.logInfo('connected');
|
||||
deps.logWarn('missing');
|
||||
assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']);
|
||||
deps.onSessionStateChanged?.();
|
||||
assert.deepEqual(calls, [
|
||||
'play',
|
||||
'playstate',
|
||||
'general',
|
||||
'info:connected',
|
||||
'warn:missing',
|
||||
'state-changed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('stop jellyfin remote session main deps builder maps callbacks', () => {
|
||||
@@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => {
|
||||
getCurrentSession: () => session as never,
|
||||
setCurrentSession: () => calls.push('set-null'),
|
||||
clearActivePlayback: () => calls.push('clear'),
|
||||
onSessionStateChanged: () => calls.push('state-changed'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.getCurrentSession(), session);
|
||||
deps.setCurrentSession(null);
|
||||
deps.clearActivePlayback();
|
||||
assert.deepEqual(calls, ['set-null', 'clear']);
|
||||
deps.onSessionStateChanged?.();
|
||||
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
|
||||
getCurrentSession: () => deps.getCurrentSession(),
|
||||
setCurrentSession: (session) => deps.setCurrentSession(session),
|
||||
createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
|
||||
getClientInfo: () => deps.getClientInfo(),
|
||||
getHostName: () => deps.getHostName(),
|
||||
defaultDeviceId: deps.defaultDeviceId,
|
||||
defaultClientName: deps.defaultClientName,
|
||||
defaultClientVersion: deps.defaultClientVersion,
|
||||
@@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
|
||||
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
||||
onSessionStateChanged: deps.onSessionStateChanged,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler(
|
||||
getCurrentSession: () => deps.getCurrentSession(),
|
||||
setCurrentSession: (session) => deps.setCurrentSession(session),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
onSessionStateChanged: deps.onSessionStateChanged,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
||||
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
||||
main { padding: 22px; }
|
||||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
||||
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
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; }
|
||||
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
|
||||
button:disabled { cursor: wait; opacity: .68; }
|
||||
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
|
||||
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--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; }
|
||||
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||
.status.success { color: var(--accent); }
|
||||
.status.error { color: var(--danger); }
|
||||
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
|
||||
.status {
|
||||
min-height: 18px;
|
||||
margin-top: 14px;
|
||||
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>
|
||||
</head>
|
||||
<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 () => {
|
||||
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}`),
|
||||
})();
|
||||
|
||||
@@ -21,6 +26,8 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
|
||||
await deps.wait(1);
|
||||
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||
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(),
|
||||
sendMpvCommand: (command) => deps.sendMpvCommand(command),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,47 +15,424 @@ const clientInfo = {
|
||||
deviceId: 'dev',
|
||||
};
|
||||
|
||||
test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => {
|
||||
function makeDeps(overrides: {
|
||||
listJellyfinSubtitleTracks?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['listJellyfinSubtitleTracks'];
|
||||
getMpvClient?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['getMpvClient'];
|
||||
sendMpvCommand?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['sendMpvCommand'];
|
||||
wait?: Parameters<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 preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
|
||||
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 6,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {},
|
||||
logDebug: () => {},
|
||||
});
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'],
|
||||
['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
|
||||
['set_property', 'sid', 5],
|
||||
['set_property', 'secondary-sid', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles 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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let waited = false;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {
|
||||
waited = true;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {
|
||||
waited = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
@@ -65,15 +442,17 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async (
|
||||
|
||||
test('preload jellyfin subtitles logs debug on failure', async () => {
|
||||
const logs: string[] = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
logDebug: (message) => logs.push(message),
|
||||
});
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
logDebug: (message) => logs.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
|
||||
@@ -18,10 +18,40 @@ type JellyfinSubtitleTrack = {
|
||||
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 = {
|
||||
connected?: boolean;
|
||||
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 {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
@@ -58,17 +88,12 @@ function isLikelyHearingImpaired(title: string): boolean {
|
||||
}
|
||||
|
||||
function pickBestTrackId(
|
||||
tracks: Array<{
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
external: boolean;
|
||||
}>,
|
||||
tracks: MpvSubtitleTrack[],
|
||||
languageMatcher: (value: string) => boolean,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
const ranked = tracks
|
||||
.filter((track) => languageMatcher(track.lang))
|
||||
.filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
|
||||
.filter((track) => track.id !== excludeId)
|
||||
.map((track) => ({
|
||||
track,
|
||||
@@ -81,6 +106,117 @@ function pickBestTrackId(
|
||||
return ranked[0]?.track.id ?? null;
|
||||
}
|
||||
|
||||
function pickBestCachedTrackId(
|
||||
tracks: MpvSubtitleTrack[],
|
||||
cachedTracks: CachedExternalSubtitleTrack[],
|
||||
sourceMatcher: (value: string) => boolean,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track]));
|
||||
const ranked = tracks
|
||||
.map((track) => ({
|
||||
track,
|
||||
cached: cachedByPath.get(track.externalFilename),
|
||||
}))
|
||||
.filter(({ cached }) =>
|
||||
cached
|
||||
? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '')
|
||||
: false,
|
||||
)
|
||||
.filter(({ track }) => track.id !== excludeId)
|
||||
.map(({ track, cached }) => {
|
||||
const title = cached?.source.title || track.title;
|
||||
return {
|
||||
track,
|
||||
score:
|
||||
(track.external ? 100 : 0) +
|
||||
(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: {
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -90,14 +226,31 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
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;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
cleanupActiveCache();
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to cleanup Jellyfin cached subtitles', error);
|
||||
}
|
||||
const tracks = await deps.listJellyfinSubtitleTracks(
|
||||
params.session,
|
||||
params.clientInfo,
|
||||
@@ -110,6 +263,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
|
||||
await deps.wait(300);
|
||||
const seenUrls = new Set<string>();
|
||||
const cachedTracks: CachedExternalSubtitleTrack[] = [];
|
||||
for (const track of externalTracks) {
|
||||
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
|
||||
continue;
|
||||
@@ -117,36 +271,43 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
seenUrls.add(track.deliveryUrl);
|
||||
const labelBase = (track.title || track.language || '').trim();
|
||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||
deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']);
|
||||
const cached = await deps.cacheSubtitleTrack(track);
|
||||
activeCacheDirs.add(cached.cleanupDir);
|
||||
cachedTracks.push({ ...cached, source: track });
|
||||
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
|
||||
}
|
||||
|
||||
await deps.wait(250);
|
||||
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list');
|
||||
const subtitleTracks = Array.isArray(trackListRaw)
|
||||
? trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<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,
|
||||
}))
|
||||
: [];
|
||||
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
|
||||
const shouldWaitForExternalJapanese = externalTracks.some(
|
||||
(track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
|
||||
);
|
||||
const subtitleTracks = await waitForPreferredSubtitleTracks(
|
||||
deps,
|
||||
shouldWaitForExternalJapanese,
|
||||
cachedTracks.map((track) => track.path),
|
||||
);
|
||||
if (
|
||||
shouldWaitForExternalJapanese &&
|
||||
(!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
|
||||
) {
|
||||
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
|
||||
itemId: params.itemId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese);
|
||||
const japanesePrimaryId =
|
||||
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
|
||||
pickBestTrackId(subtitleTracks ?? [], isJapanese);
|
||||
if (japanesePrimaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
} else {
|
||||
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) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
|
||||
}
|
||||
@@ -154,4 +315,20 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
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 () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinT
|
||||
| 'logger'
|
||||
| 'showMpvOsd'
|
||||
>,
|
||||
options: { desiredActive?: boolean } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activeSession = deps.getRemoteSession();
|
||||
if (activeSession) {
|
||||
deps.stopRemoteSession();
|
||||
deps.logger.info('Jellyfin discovery stopped.');
|
||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||
if (options.desiredActive === false) {
|
||||
if (activeSession) {
|
||||
deps.stopRemoteSession();
|
||||
deps.logger.info('Jellyfin discovery stopped.');
|
||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSession) {
|
||||
let visible = false;
|
||||
try {
|
||||
visible = await activeSession.advertiseNow();
|
||||
} catch {
|
||||
deps.logger.warn('Jellyfin discovery visibility check failed; restarting.');
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
if (options.desiredActive === true) {
|
||||
deps.logger.info('Jellyfin discovery already active.');
|
||||
} else {
|
||||
deps.stopRemoteSession();
|
||||
deps.logger.info('Jellyfin discovery stopped.');
|
||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logger.warn('Jellyfin discovery was active but not visible; restarting.');
|
||||
deps.stopRemoteSession();
|
||||
}
|
||||
|
||||
await deps.startRemoteSession({ explicit: true });
|
||||
const remoteSession = deps.getRemoteSession();
|
||||
if (!remoteSession) {
|
||||
|
||||
@@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec
|
||||
['set_property', 'sid', 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => {
|
||||
const commands: Array<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],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const hasAppliedSelectionForCurrentMediaPath = (): boolean =>
|
||||
appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath;
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (
|
||||
!currentMediaPath ||
|
||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||
appliedSecondaryMediaPath === currentMediaPath)
|
||||
) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
@@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||
appliedSecondaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (hasAppliedSelectionForCurrentMediaPath()) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
};
|
||||
@@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user