mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
fix(jellyfin): fix discovery loop, device identity, tray state, and Disc
- Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting
This commit is contained in:
@@ -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: 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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Kept Jellyfin picker library discovery working when the running app log level is above info.
|
||||
@@ -644,14 +644,10 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||
|
||||
@@ -1253,7 +1253,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": false,
|
||||
"remoteControlDeviceName": "SubMiner",
|
||||
"defaultLibraryId": "",
|
||||
"directPlayPreferred": true,
|
||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
||||
@@ -1268,21 +1267,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.
|
||||
|
||||
|
||||
@@ -35,7 +35,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",
|
||||
@@ -56,7 +55,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:
|
||||
|
||||
@@ -76,7 +75,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:
|
||||
|
||||
|
||||
@@ -644,14 +644,10 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -75,7 +75,10 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal('clientName' in config.jellyfin, false);
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
assert.equal('deviceId' in config.jellyfin, false);
|
||||
assert.equal('clientVersion' in config.jellyfin, false);
|
||||
assert.equal(config.ai.enabled, false);
|
||||
assert.equal(config.ai.apiKeyCommand, '');
|
||||
assert.equal(config.texthooker.openBrowser, false);
|
||||
@@ -832,7 +835,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns
|
||||
);
|
||||
});
|
||||
|
||||
test('parses jellyfin remote control fields', () => {
|
||||
test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -843,6 +846,7 @@ test('parses jellyfin remote control fields', () => {
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": true,
|
||||
"clientName": "Custom Client",
|
||||
"remoteControlDeviceName": "SubMiner"
|
||||
}
|
||||
}`,
|
||||
@@ -857,7 +861,8 @@ test('parses jellyfin remote control fields', () => {
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, true);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal('clientName' in config.jellyfin, false);
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
});
|
||||
|
||||
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
|
||||
@@ -2469,6 +2474,8 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"updates":/);
|
||||
assert.match(output, /"youtube":/);
|
||||
assert.doesNotMatch(output, /"deviceId":/);
|
||||
assert.doesNotMatch(output, /"clientVersion":/);
|
||||
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
|
||||
@@ -130,14 +130,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
serverUrl: '',
|
||||
recentServers: [],
|
||||
username: '',
|
||||
deviceId: 'subminer',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
defaultLibraryId: '',
|
||||
remoteControlEnabled: true,
|
||||
remoteControlAutoConnect: true,
|
||||
autoAnnounce: false,
|
||||
remoteControlDeviceName: 'SubMiner',
|
||||
pullPictures: false,
|
||||
iconCacheDir: '/tmp/subminer-jellyfin-icons',
|
||||
directPlayPreferred: true,
|
||||
|
||||
@@ -548,26 +548,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.username,
|
||||
description: 'Default Jellyfin username used during CLI login.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.deviceId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.deviceId,
|
||||
description:
|
||||
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientName,
|
||||
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientVersion',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientVersion,
|
||||
description:
|
||||
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.defaultLibraryId',
|
||||
kind: 'string',
|
||||
@@ -593,12 +573,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',
|
||||
|
||||
@@ -371,9 +371,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
const stringKeys = [
|
||||
'serverUrl',
|
||||
'username',
|
||||
'deviceId',
|
||||
'clientName',
|
||||
'clientVersion',
|
||||
'defaultLibraryId',
|
||||
'iconCacheDir',
|
||||
'transcodeVideoCodec',
|
||||
|
||||
@@ -59,7 +59,6 @@ test('settings registry hides removed modal-only fields', () => {
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
]) {
|
||||
assert.equal(
|
||||
fields.some((candidate) => candidate.configPath === path),
|
||||
@@ -246,10 +245,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'youtubeSubgen.whisperBin',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.clientName',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
+38
-46
@@ -403,6 +403,11 @@ import {
|
||||
launchWindowsMpv,
|
||||
} from './main/runtime/windows-mpv-launch';
|
||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||
import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
} from './main/runtime/jellyfin-device-identity';
|
||||
import {
|
||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
|
||||
@@ -508,6 +513,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
|
||||
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import {
|
||||
createElectronAppUpdater,
|
||||
@@ -619,6 +625,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/';
|
||||
@@ -2862,7 +2877,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,
|
||||
@@ -2918,41 +2935,8 @@ const {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
cacheSubtitleTrack: async (track) => {
|
||||
if (!track.deliveryUrl) {
|
||||
throw new Error('Jellyfin subtitle track has no delivery URL');
|
||||
}
|
||||
|
||||
const cacheDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'subminer-jellyfin-subtitles-'),
|
||||
);
|
||||
const urlPath = (() => {
|
||||
try {
|
||||
return new URL(track.deliveryUrl).pathname;
|
||||
} catch {
|
||||
return track.deliveryUrl;
|
||||
}
|
||||
})();
|
||||
const ext = path.extname(urlPath).slice(0, 16) || '.srt';
|
||||
const subtitlePath = path.join(cacheDir, `track-${track.index}${ext}`);
|
||||
try {
|
||||
const response = await 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 fs.promises.writeFile(subtitlePath, bytes);
|
||||
} catch (error) {
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return { path: subtitlePath, cleanupDir: cacheDir };
|
||||
},
|
||||
cleanupCachedSubtitles: (dirs) => {
|
||||
for (const dir of dirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
|
||||
logDebug: (message, error) => {
|
||||
logger.debug(message, error);
|
||||
},
|
||||
@@ -2995,6 +2979,9 @@ const {
|
||||
showMpvOsd: (text) => {
|
||||
showMpvOsd(text);
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
mediaRuntime.updateCurrentMediaTitle(title);
|
||||
},
|
||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
||||
@@ -3059,11 +3046,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,
|
||||
@@ -3073,6 +3062,7 @@ const {
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
},
|
||||
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||
},
|
||||
runJellyfinCommandMainDeps: {
|
||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||
@@ -3093,7 +3083,6 @@ const {
|
||||
clearStoredSession: () =>
|
||||
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
patchJellyfinConfig: (session) => {
|
||||
const clientInfo = getJellyfinClientInfo();
|
||||
const recentServers = mergeJellyfinRecentServers(
|
||||
session.serverUrl,
|
||||
getResolvedConfig().jellyfin.recentServers || [],
|
||||
@@ -3103,9 +3092,6 @@ const {
|
||||
enabled: true,
|
||||
serverUrl: session.serverUrl,
|
||||
username: session.username,
|
||||
deviceId: clientInfo.deviceId,
|
||||
clientName: clientInfo.clientName,
|
||||
clientVersion: clientInfo.clientVersion,
|
||||
recentServers,
|
||||
},
|
||||
});
|
||||
@@ -4434,8 +4420,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();
|
||||
@@ -6162,6 +6148,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime,
|
||||
platform: process.platform,
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
@@ -6177,8 +6164,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' });
|
||||
@@ -6410,6 +6399,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
@@ -6421,6 +6411,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
@@ -6431,6 +6422,7 @@ function toggleVisibleOverlay(): void {
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
|
||||
@@ -46,6 +46,33 @@ test('media path changes clear rendered subtitle state without clearing same-you
|
||||
);
|
||||
});
|
||||
|
||||
test('same media path updates do not reset autoplay ready fallback state', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles 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\(\)/);
|
||||
|
||||
@@ -56,6 +56,50 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
destroyMainOverlayWindow: () => {},
|
||||
destroyModalOverlayWindow: () => {},
|
||||
destroyYomitanParserWindow: () => {},
|
||||
clearYomitanParserState: () => {},
|
||||
stopWindowTracker: () => {},
|
||||
flushMpvLog: () => {},
|
||||
destroyMpvSocket: () => {},
|
||||
clearReconnectTimer: () => {},
|
||||
destroySubtitleTimingTracker: () => {},
|
||||
destroyImmersionTracker: () => {},
|
||||
destroyAnkiIntegration: () => {},
|
||||
destroyAnilistSetupWindow: () => {},
|
||||
clearAnilistSetupWindow: () => {},
|
||||
destroyJellyfinSetupWindow: () => {},
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
destroyFirstRunSetupWindow: () => {},
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
destroyYomitanSettingsWindow: () => {},
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {
|
||||
calls.push('stop-jellyfin-remote');
|
||||
throw new Error('stop failed');
|
||||
},
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
assert.throws(() => cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
|
||||
@@ -61,9 +61,12 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.clearFirstRunSetupWindow();
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
try {
|
||||
deps.stopJellyfinRemoteSession();
|
||||
} finally {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
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,
|
||||
@@ -140,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
defaultDeviceId: 'dev',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
getHostName: () => 'workstation',
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
|
||||
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
||||
>;
|
||||
startJellyfinRemoteSessionMainDeps: Omit<
|
||||
StartRemoteSessionMainDeps,
|
||||
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
|
||||
| 'getJellyfinConfig'
|
||||
| 'getClientInfo'
|
||||
| 'handlePlay'
|
||||
| 'handlePlaystate'
|
||||
| 'handleGeneralCommand'
|
||||
>;
|
||||
stopJellyfinRemoteSessionMainDeps: Parameters<
|
||||
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
||||
@@ -236,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),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -23,5 +23,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||
: undefined,
|
||||
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
|
||||
? (title) => deps.updateCurrentMediaTitle!(title)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,10 +100,11 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
['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'));
|
||||
@@ -133,6 +134,52 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
@@ -177,3 +224,46 @@ 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']);
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
}) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
|
||||
updateCurrentMediaTitle?: (title: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinAuthSession;
|
||||
@@ -106,24 +107,26 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||
deps.recordJellyfinPlaybackMetadata?.({
|
||||
mediaPath: playbackUrl,
|
||||
displayTitle: plan.title,
|
||||
itemTitle: plan.itemTitle,
|
||||
seriesTitle: plan.seriesTitle,
|
||||
seasonNumber: plan.seasonNumber,
|
||||
episodeNumber: plan.episodeNumber,
|
||||
itemId: params.itemId,
|
||||
});
|
||||
deps.updateCurrentMediaTitle?.(plan.title);
|
||||
try {
|
||||
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 stats metadata must not block playback startup.
|
||||
}
|
||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -61,8 +61,22 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
|
||||
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
|
||||
{
|
||||
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),
|
||||
@@ -76,13 +90,225 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.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(
|
||||
@@ -105,6 +331,34 @@ test('preload jellyfin subtitles cleans previous cached subtitles before a new p
|
||||
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]);
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -23,10 +23,27 @@ type CachedSubtitleTrack = {
|
||||
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;
|
||||
@@ -71,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,
|
||||
@@ -94,6 +106,119 @@ 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,
|
||||
@@ -108,6 +233,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
const activeCacheDirs = new Set<string>();
|
||||
let preloadQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
@@ -116,7 +242,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
deps.cleanupCachedSubtitles(dirs);
|
||||
}
|
||||
|
||||
const preload = async (params: {
|
||||
const runPreload = async (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
@@ -136,6 +262,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;
|
||||
@@ -145,36 +272,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||
const cached = await deps.cacheSubtitleTrack(track);
|
||||
activeCacheDirs.add(cached.cleanupDir);
|
||||
deps.sendMpvCommand(['sub-add', cached.path, 'cached', label, track.language || '']);
|
||||
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]);
|
||||
}
|
||||
@@ -183,6 +315,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
|
||||
@@ -100,3 +100,33 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => {
|
||||
let extracted = false;
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg-custom',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
extracted = true;
|
||||
return {
|
||||
path: '/tmp/subminer-sidebar-123/track_7.ass',
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: {
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
codec: 'ass',
|
||||
},
|
||||
trackListRaw: [],
|
||||
sidRaw: 3,
|
||||
videoPath: 'http://jellyfin.local/Videos/movie/stream?static=true',
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
assert.equal(extracted, false);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,15 @@ function parseTrackId(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRemoteMediaPath(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveSubtitleTrack(
|
||||
currentTrackRaw: unknown,
|
||||
trackListRaw: unknown,
|
||||
@@ -104,6 +113,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
return { path: externalFilename, sourceKey: externalFilename };
|
||||
}
|
||||
|
||||
if (isRemoteMediaPath(input.videoPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extracted = await deps.extractInternalSubtitleTrack(
|
||||
deps.getFfmpegPath(),
|
||||
input.videoPath,
|
||||
|
||||
@@ -43,6 +43,63 @@ test('ensure tray updates menu when tray already exists', () => {
|
||||
assert.deepEqual(calls, ['set-menu']);
|
||||
});
|
||||
|
||||
test('ensure tray refreshes existing tray menu on linux with setContextMenu', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = {
|
||||
setContextMenu: () => calls.push('old-set-menu'),
|
||||
setToolTip: () => calls.push('old-set-tooltip'),
|
||||
on: () => calls.push('old-bind-click'),
|
||||
destroy: () => calls.push('old-destroy'),
|
||||
};
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => trayRef as never,
|
||||
setTray: (tray) => {
|
||||
trayRef = tray;
|
||||
calls.push(tray ? 'set-new-tray' : 'clear-tray');
|
||||
},
|
||||
buildTrayMenu: () => ({ id: 'menu' }),
|
||||
resolveTrayIconPath: () => '/tmp/icon.png',
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: (options: { width: number; height: number }) => {
|
||||
calls.push(`resize:${options.width}x${options.height}`);
|
||||
return {
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
};
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () =>
|
||||
({
|
||||
setContextMenu: () => calls.push('new-set-menu'),
|
||||
setToolTip: () => calls.push('new-set-tooltip'),
|
||||
on: () => calls.push('new-bind-click'),
|
||||
destroy: () => calls.push('new-destroy'),
|
||||
}) as never,
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'linux',
|
||||
logWarn: () => calls.push('warn'),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
assert.deepEqual(calls, ['old-set-menu']);
|
||||
});
|
||||
|
||||
test('ensure tray creates new tray and binds click handler', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = null;
|
||||
|
||||
@@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
let initialized = false;
|
||||
const buildTemplate = createBuildTrayMenuTemplateHandler({
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
calls.push(`platform:${handlers.platform}`);
|
||||
handlers.openSessionHelp();
|
||||
handlers.openTexthookerInBrowser();
|
||||
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
|
||||
@@ -50,7 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openConfigSettings();
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery();
|
||||
handlers.toggleJellyfinDiscovery(true);
|
||||
handlers.openAnilistSetup();
|
||||
handlers.checkForUpdates();
|
||||
handlers.quitApp();
|
||||
@@ -72,9 +73,10 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: async () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
toggleJellyfinDiscovery: async (checked) => {
|
||||
calls.push(`jellyfin-discovery:${checked}`);
|
||||
},
|
||||
platform: 'linux',
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
@@ -83,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
const template = buildTemplate();
|
||||
assert.deepEqual(template, [{ label: 'ok' }]);
|
||||
assert.deepEqual(calls, [
|
||||
'platform:linux',
|
||||
'init',
|
||||
'help',
|
||||
'texthooker',
|
||||
@@ -92,7 +95,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'yomitan',
|
||||
'configuration',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery',
|
||||
'jellyfin-discovery:true',
|
||||
'anilist',
|
||||
'updates',
|
||||
'quit',
|
||||
|
||||
@@ -37,6 +37,7 @@ export function shouldShowTexthookerTrayEntry(config: {
|
||||
|
||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
@@ -49,7 +50,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
toggleJellyfinDiscovery: (checked: boolean) => void;
|
||||
openAnilistSetup: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
@@ -67,13 +68,15 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||
toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
|
||||
platform?: string;
|
||||
openAnilistSetupWindow: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return (): TMenuItem[] => {
|
||||
return deps.buildTrayMenuTemplateRuntime({
|
||||
platform: deps.platform,
|
||||
openSessionHelp: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
@@ -103,8 +106,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
},
|
||||
showJellyfinDiscovery: deps.isJellyfinConfigured(),
|
||||
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
|
||||
toggleJellyfinDiscovery: () => {
|
||||
void deps.toggleJellyfinDiscovery();
|
||||
toggleJellyfinDiscovery: (checked) => {
|
||||
void deps.toggleJellyfinDiscovery(checked);
|
||||
},
|
||||
openAnilistSetup: () => {
|
||||
deps.openAnilistSetupWindow();
|
||||
|
||||
@@ -35,15 +35,18 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
toggleJellyfinDiscovery: (checked) => {
|
||||
calls.push(`jellyfin-discovery:${checked}`);
|
||||
},
|
||||
platform: 'linux',
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
})();
|
||||
|
||||
assert.equal(menuDeps.platform, 'linux');
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
platform: menuDeps.platform,
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||
showTexthookerPage: true,
|
||||
@@ -56,7 +59,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
|
||||
toggleJellyfinDiscovery: (checked) => calls.push(`open-jellyfin-discovery:${checked}`),
|
||||
openAnilistSetup: () => calls.push('open-anilist'),
|
||||
checkForUpdates: () => calls.push('open-updates'),
|
||||
quitApp: () => calls.push('quit-app'),
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
|
||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
@@ -39,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
toggleJellyfinDiscovery: (checked: boolean) => void;
|
||||
openAnilistSetup: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
@@ -57,13 +58,15 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||
toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
|
||||
platform?: string;
|
||||
openAnilistSetupWindow: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
|
||||
platform: deps.platform,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
|
||||
@@ -41,7 +41,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
|
||||
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
@@ -60,7 +60,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
|
||||
assert.equal(discovery?.type, 'checkbox');
|
||||
assert.equal(discovery?.checked, false);
|
||||
discovery?.click?.();
|
||||
discovery?.click?.({ checked: true });
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
@@ -70,7 +70,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[11]!.click?.();
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery',
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'texthooker',
|
||||
'updates',
|
||||
@@ -155,3 +155,29 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
assert.equal(discovery?.type, 'checkbox');
|
||||
assert.equal(discovery?.checked, true);
|
||||
});
|
||||
|
||||
test('tray menu template renders a visible linux discovery check mark when active', () => {
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
platform: 'linux',
|
||||
openSessionHelp: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: true,
|
||||
toggleJellyfinDiscovery: () => undefined,
|
||||
openAnilistSetup: () => undefined,
|
||||
checkForUpdates: () => undefined,
|
||||
quitApp: () => undefined,
|
||||
});
|
||||
|
||||
const discovery = template.find((entry) => entry.label === '✓ Jellyfin Discovery');
|
||||
assert.equal(discovery?.type, 'checkbox');
|
||||
assert.equal(discovery?.checked, true);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
}
|
||||
|
||||
export type TrayMenuActionHandlers = {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
@@ -42,19 +43,28 @@ export type TrayMenuActionHandlers = {
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
toggleJellyfinDiscovery: (checked: boolean) => void;
|
||||
openAnilistSetup: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
};
|
||||
|
||||
type TrayMenuClickItem = {
|
||||
checked?: boolean;
|
||||
};
|
||||
|
||||
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
||||
label?: string;
|
||||
type?: 'separator' | 'checkbox';
|
||||
checked?: boolean;
|
||||
enabled?: boolean;
|
||||
click?: () => void;
|
||||
click?: (menuItem?: TrayMenuClickItem) => void;
|
||||
}> {
|
||||
const jellyfinDiscoveryLabel =
|
||||
handlers.platform === 'linux' && handlers.jellyfinDiscoveryActive
|
||||
? '✓ Jellyfin Discovery'
|
||||
: 'Jellyfin Discovery';
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Open Help',
|
||||
@@ -99,11 +109,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
...(handlers.showJellyfinDiscovery
|
||||
? [
|
||||
{
|
||||
label: 'Jellyfin Discovery',
|
||||
label: jellyfinDiscoveryLabel,
|
||||
type: 'checkbox' as const,
|
||||
checked: handlers.jellyfinDiscoveryActive,
|
||||
enabled: true,
|
||||
click: handlers.toggleJellyfinDiscovery,
|
||||
click: (menuItem?: TrayMenuClickItem) => {
|
||||
const checked =
|
||||
typeof menuItem?.checked === 'boolean'
|
||||
? menuItem.checked
|
||||
: !handlers.jellyfinDiscoveryActive;
|
||||
handlers.toggleJellyfinDiscovery(checked);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -244,3 +244,29 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('subsync modal ignores enter submission when no sync engine is available', async () => {
|
||||
let runCalls = 0;
|
||||
const harness = createTestHarness(async () => {
|
||||
runCalls += 1;
|
||||
return { ok: true, message: 'ok' };
|
||||
});
|
||||
|
||||
try {
|
||||
harness.modal.openSubsyncModal({
|
||||
sourceTracks: [],
|
||||
ffsubsyncAvailable: false,
|
||||
});
|
||||
|
||||
harness.modal.handleSubsyncKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(runCalls, 0);
|
||||
assert.equal(harness.ctx.state.subsyncModalOpen, true);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,8 +105,16 @@ export function createSubsyncModal(
|
||||
|
||||
async function runSubsyncManualFromModal(): Promise<void> {
|
||||
if (ctx.state.subsyncSubmitting) return;
|
||||
if (ctx.dom.subsyncRunButton.disabled) return;
|
||||
|
||||
const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync';
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
|
||||
if (!useAlass && !useFfsubsync) {
|
||||
setSubsyncStatus('No sync engine available for current media.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const engine = useAlass ? 'alass' : 'ffsubsync';
|
||||
const sourceTrackId =
|
||||
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
|
||||
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
||||
|
||||
@@ -302,14 +302,10 @@ export interface ResolvedConfig {
|
||||
serverUrl: string;
|
||||
recentServers: string[];
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
defaultLibraryId: string;
|
||||
remoteControlEnabled: boolean;
|
||||
remoteControlAutoConnect: boolean;
|
||||
autoAnnounce: boolean;
|
||||
remoteControlDeviceName: string;
|
||||
pullPictures: boolean;
|
||||
iconCacheDir: string;
|
||||
directPlayPreferred: boolean;
|
||||
|
||||
@@ -87,14 +87,10 @@ export interface JellyfinConfig {
|
||||
serverUrl?: string;
|
||||
recentServers?: string[];
|
||||
username?: string;
|
||||
deviceId?: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
defaultLibraryId?: string;
|
||||
remoteControlEnabled?: boolean;
|
||||
remoteControlAutoConnect?: boolean;
|
||||
autoAnnounce?: boolean;
|
||||
remoteControlDeviceName?: string;
|
||||
pullPictures?: boolean;
|
||||
iconCacheDir?: string;
|
||||
directPlayPreferred?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user