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:
2026-05-22 01:36:11 -07:00
parent e17c499cfe
commit f19d93e3ab
72 changed files with 1902 additions and 295 deletions
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Kept Jellyfin picker library discovery working when the running app log level is above info.
-4
View File
@@ -644,14 +644,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+1 -6
View File
@@ -1253,7 +1253,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"directPlayPreferred": true, "directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
@@ -1268,21 +1267,17 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) |
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists |
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
+2 -3
View File
@@ -35,7 +35,6 @@ SubMiner includes an optional Jellyfin CLI integration for:
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"pullPictures": false, "pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons",
@@ -56,7 +55,7 @@ subminer jellyfin -l \
--password 'your-password' --password 'your-password'
``` ```
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. `subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username, and refreshes recent servers. Passwords are never stored.
3. List libraries: 3. List libraries:
@@ -76,7 +75,7 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d subminer jellyfin -d
``` ```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart. After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. By default, Jellyfin sees the cast target as the OS hostname (`uname -n` on Linux). If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app: Stop discovery session/app:
-4
View File
@@ -644,14 +644,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+16 -8
View File
@@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection(
fail('Selected Jellyfin item is not playable.'); fail('Selected Jellyfin item is not playable.');
} }
export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] {
const forwarded = [...appArgs];
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwarded.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwarded.push('--password-store', args.passwordStore);
}
if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) {
forwarded.push('--log-level', args.logLevel);
}
return forwarded;
}
async function runAppJellyfinListCommand( async function runAppJellyfinListCommand(
appPath: string, appPath: string,
args: Args, args: Args,
@@ -384,14 +399,7 @@ async function runAppJellyfinCommand(
appArgs: string[], appArgs: string[],
label: string, label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> { ): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = [...appArgs]; const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwardedBase.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwardedBase.push('--password-store', args.passwordStore);
}
const readLogAppendedSince = (offset: number): string => { const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath(); const logPath = getMpvLogPath();
+22
View File
@@ -17,6 +17,7 @@ import {
parseEpisodePathFromDisplay, parseEpisodePathFromDisplay,
buildRootSearchGroups, buildRootSearchGroups,
classifyJellyfinChildSelection, classifyJellyfinChildSelection,
buildForwardedJellyfinAppArgs,
} from './jellyfin.js'; } from './jellyfin.js';
type RunResult = { type RunResult = {
@@ -878,6 +879,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
]); ]);
}); });
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
const forwarded = buildForwardedJellyfinAppArgs(
{
jellyfinServer: 'https://jf.example.test/',
passwordStore: 'gnome-libsecret',
logLevel: 'info',
} as never,
['--jellyfin-libraries'],
);
assert.deepEqual(forwarded, [
'--jellyfin-libraries',
'--jellyfin-server',
'https://jf.example.test',
'--password-store',
'gnome-libsecret',
'--log-level',
'info',
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(` const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning [subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
+10 -1
View File
@@ -144,12 +144,21 @@ function M.create(ctx)
and previous_media_identity ~= nil and previous_media_identity ~= nil
and media_identity == previous_media_identity and media_identity == previous_media_identity
) )
local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.current_media_identity = media_identity state.current_media_identity = media_identity
if new_media_loaded then
state.suppress_ready_overlay_restore = false
end
if same_media_reload then if same_media_reload then
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then if
state.overlay_running
and not state.suppress_ready_overlay_restore
and resolve_auto_start_enabled()
and process.has_matching_mpv_ipc_socket(opts.socket_path)
then
process.run_control_command_async("show-visible-overlay", { process.run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path, socket_path = opts.socket_path,
}) })
+28 -16
View File
@@ -31,6 +31,16 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end end
local function resolve_auto_start_visibility_action()
if resolve_visible_overlay_startup() then
if state.suppress_ready_overlay_restore then
return nil
end
return "show-visible-overlay"
end
return "hide-visible-overlay"
end
local function resolve_pause_until_ready() local function resolve_pause_until_ready()
local raw_pause_until_ready = opts.auto_start_pause_until_ready local raw_pause_until_ready = opts.auto_start_pause_until_ready
if raw_pause_until_ready == nil then if raw_pause_until_ready == nil then
@@ -129,7 +139,7 @@ function M.create(ctx)
local function release_auto_play_ready_gate(reason) local function release_auto_play_ready_gate(reason)
if not state.auto_play_ready_gate_armed then if not state.auto_play_ready_gate_armed then
return return false
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
@@ -140,6 +150,7 @@ function M.create(ctx)
else else
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready")) subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
end end
return true
end end
local function arm_auto_play_ready_gate() local function arm_auto_play_ready_gate()
@@ -179,9 +190,12 @@ function M.create(ctx)
end end
local function notify_auto_play_ready() local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready") local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
local force_ready_overlay_restore = state.force_ready_overlay_restore == true local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false state.force_ready_overlay_restore = false
if not released_ready_gate and not force_ready_overlay_restore then
return
end
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
return return
end end
@@ -224,7 +238,7 @@ function M.create(ctx)
local should_show_visible = overrides.show_visible_overlay local should_show_visible = overrides.show_visible_overlay
if should_show_visible == nil then if should_show_visible == nil then
should_show_visible = resolve_visible_overlay_startup() should_show_visible = resolve_visible_overlay_startup() and not state.suppress_ready_overlay_restore
end end
if should_show_visible then if should_show_visible then
table.insert(args, "--show-visible-overlay") table.insert(args, "--show-visible-overlay")
@@ -399,9 +413,6 @@ function M.create(ctx)
local function start_overlay(overrides) local function start_overlay(overrides)
overrides = overrides or {} overrides = overrides or {}
if overrides.auto_start_trigger == true then
state.suppress_ready_overlay_restore = false
end
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
@@ -424,13 +435,13 @@ function M.create(ctx)
elseif not state.auto_play_ready_gate_armed then elseif not state.auto_play_ready_gate_armed then
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
end end
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_auto_start_visibility_action()
and "show-visible-overlay" if visibility_action ~= nil then
or "hide-visible-overlay" run_control_command_async(visibility_action, {
run_control_command_async(visibility_action, { socket_path = socket_path,
socket_path = socket_path, log_level = overrides.log_level,
log_level = overrides.log_level, })
}) end
return return
end end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
@@ -495,13 +506,13 @@ function M.create(ctx)
end end
if overrides.auto_start_trigger == true then if overrides.auto_start_trigger == true then
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_auto_start_visibility_action()
and "show-visible-overlay" if visibility_action ~= nil then
or "hide-visible-overlay"
run_control_command_async(visibility_action, { run_control_command_async(visibility_action, {
socket_path = socket_path, socket_path = socket_path,
log_level = overrides.log_level, log_level = overrides.log_level,
}) })
end
end end
end) end)
@@ -576,6 +587,7 @@ function M.create(ctx)
return return
end end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("toggle-visible-overlay", nil, function(ok) run_control_command_async("toggle-visible-overlay", nil, function(ok)
if not ok then if not ok then
+101 -1
View File
@@ -1396,7 +1396,7 @@ do
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 3,
"duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load" "duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load"
) )
assert_true( assert_true(
@@ -1471,6 +1471,33 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for duplicate autoplay-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1531,6 +1558,10 @@ do
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off before readiness should suppress ready-time visible overlay restore" "manual toggle-off before readiness should suppress ready-time visible overlay restore"
) )
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off before readiness should not resume playback when readiness arrives"
)
end end
do do
@@ -1564,6 +1595,75 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual hide duplicate auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off should suppress duplicate auto-start visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by duplicate auto-start should keep paused playback paused"
)
end
do
local media_path = "/media/jellyfin-redirect.m3u8"
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = media_path,
media_title = "Jellyfin Redirect",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual hide same-media reload scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "end-file", { reason = "redirect" })
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"manual toggle-off should suppress same-media reload visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by same-media reload should keep paused playback paused"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
+10 -3
View File
@@ -75,7 +75,10 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false); assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
assert.equal('deviceId' in config.jellyfin, false);
assert.equal('clientVersion' in config.jellyfin, false);
assert.equal(config.ai.enabled, false); assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.ai.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false); assert.equal(config.texthooker.openBrowser, false);
@@ -832,7 +835,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns
); );
}); });
test('parses jellyfin remote control fields', () => { test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
@@ -843,6 +846,7 @@ test('parses jellyfin remote control fields', () => {
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": true, "autoAnnounce": true,
"clientName": "Custom Client",
"remoteControlDeviceName": "SubMiner" "remoteControlDeviceName": "SubMiner"
} }
}`, }`,
@@ -857,7 +861,8 @@ test('parses jellyfin remote control fields', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true); assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
}); });
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => { test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
@@ -2469,6 +2474,8 @@ test('template generator includes known keys', () => {
assert.match(output, /"startupWarmups":/); assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/); assert.match(output, /"updates":/);
assert.match(output, /"youtube":/); assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"deviceId":/);
assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/); assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"preserveLineBreaks": false/);
@@ -130,14 +130,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
serverUrl: '', serverUrl: '',
recentServers: [], recentServers: [],
username: '', username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
clientVersion: '0.1.0',
defaultLibraryId: '', defaultLibraryId: '',
remoteControlEnabled: true, remoteControlEnabled: true,
remoteControlAutoConnect: true, remoteControlAutoConnect: true,
autoAnnounce: false, autoAnnounce: false,
remoteControlDeviceName: 'SubMiner',
pullPictures: false, pullPictures: false,
iconCacheDir: '/tmp/subminer-jellyfin-icons', iconCacheDir: '/tmp/subminer-jellyfin-icons',
directPlayPreferred: true, directPlayPreferred: true,
@@ -548,26 +548,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.username, defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.', description: 'Default Jellyfin username used during CLI login.',
}, },
{
path: 'jellyfin.deviceId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.deviceId,
description:
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientName,
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientVersion',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientVersion,
description:
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
},
{ {
path: 'jellyfin.defaultLibraryId', path: 'jellyfin.defaultLibraryId',
kind: 'string', kind: 'string',
@@ -593,12 +573,6 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'When enabled, automatically trigger remote announce/visibility check on websocket connect.', 'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
}, },
{
path: 'jellyfin.remoteControlDeviceName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
description: 'Device name reported for Jellyfin remote control sessions.',
},
{ {
path: 'jellyfin.pullPictures', path: 'jellyfin.pullPictures',
kind: 'boolean', kind: 'boolean',
-3
View File
@@ -371,9 +371,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
const stringKeys = [ const stringKeys = [
'serverUrl', 'serverUrl',
'username', 'username',
'deviceId',
'clientName',
'clientVersion',
'defaultLibraryId', 'defaultLibraryId',
'iconCacheDir', 'iconCacheDir',
'transcodeVideoCodec', 'transcodeVideoCodec',
-4
View File
@@ -59,7 +59,6 @@ test('settings registry hides removed modal-only fields', () => {
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) { ]) {
assert.equal( assert.equal(
fields.some((candidate) => candidate.configPath === path), fields.some((candidate) => candidate.configPath === path),
@@ -246,10 +245,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadLabel', 'controller.preferredGamepadLabel',
'controller.profiles', 'controller.profiles',
'youtubeSubgen.whisperBin', 'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey', 'subtitleSidebar.toggleKey',
'jellyfin.recentServers', 'jellyfin.recentServers',
]) { ]) {
-4
View File
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.accessToken', 'jellyfin.accessToken',
'jellyfin.userId', 'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices', 'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey', 'subtitleSidebar.toggleKey',
@@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
} }
}); });
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaTitle: null,
mediaPath:
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
});
assert.equal(payload.details, 'Unknown media');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
const serialized = JSON.stringify(payload);
assert.equal(serialized.includes('api_key'), false);
assert.equal(serialized.includes('secret-token'), false);
assert.equal(serialized.includes('/Videos/item-1/stream'), false);
});
test('service deduplicates identical updates and sends changed timeline', async () => { test('service deduplicates identical updates and sends changed timeline', async () => {
const sent: DiscordActivityPayload[] = []; const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>(); const timers = new Map<number, () => void>();
+13 -1
View File
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
return parts[parts.length - 1] ?? ''; return parts[parts.length - 1] ?? '';
} }
function fallbackTitleFromMediaPath(mediaPath: string | null): string {
const trimmed = mediaPath?.trim();
if (!trimmed) return '';
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) {
return '';
}
return basename(trimmed).split(/[?#]/)[0] ?? '';
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string { function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle'; if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused'; if (snapshot.paused) return 'Paused';
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
): DiscordActivityPayload { ): DiscordActivityPayload {
const style = resolvePresenceStyle(config.presenceStyle); const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot); const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); const title = sanitizeText(
snapshot.mediaTitle,
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details = const details =
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails; snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
+38 -46
View File
@@ -403,6 +403,11 @@ import {
launchWindowsMpv, launchWindowsMpv,
} from './main/runtime/windows-mpv-launch'; } from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './main/runtime/jellyfin-device-identity';
import { import {
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime, clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
@@ -508,6 +513,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { import {
createElectronAppUpdater, createElectronAppUpdater,
@@ -619,6 +625,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
appDataDir: process.env.APPDATA, appDataDir: process.env.APPDATA,
}); });
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => os.tmpdir(),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir, options) => {
fs.rmSync(dir, options);
},
fetch: (url) => fetch(url),
});
const ANILIST_SETUP_RESPONSE_TYPE = 'token'; const ANILIST_SETUP_RESPONSE_TYPE = 'token';
const ANILIST_DEFAULT_CLIENT_ID = '36084'; const ANILIST_DEFAULT_CLIENT_ID = '36084';
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
@@ -2862,7 +2877,9 @@ const {
}, },
getJellyfinClientInfoMainDeps: { getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, getHostName: () => os.hostname(),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
}, },
waitForMpvConnectedMainDeps: { waitForMpvConnectedMainDeps: {
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
@@ -2918,41 +2935,8 @@ const {
sendMpvCommandRuntime(appState.mpvClient, command); sendMpvCommandRuntime(appState.mpvClient, command);
}, },
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
cacheSubtitleTrack: async (track) => { cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
if (!track.deliveryUrl) { cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
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 });
}
},
logDebug: (message, error) => { logDebug: (message, error) => {
logger.debug(message, error); logger.debug(message, error);
}, },
@@ -2995,6 +2979,9 @@ const {
showMpvOsd: (text) => { showMpvOsd: (text) => {
showMpvOsd(text); showMpvOsd(text);
}, },
updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title);
},
recordJellyfinPlaybackMetadata: (metadata) => { recordJellyfinPlaybackMetadata: (metadata) => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata); appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
@@ -3059,11 +3046,13 @@ const {
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
}, },
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, getHostName: () => os.hostname(),
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message, details) => logger.warn(message, details), logWarn: (message, details) => logger.warn(message, details),
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
stopJellyfinRemoteSessionMainDeps: { stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => appState.jellyfinRemoteSession, getCurrentSession: () => appState.jellyfinRemoteSession,
@@ -3073,6 +3062,7 @@ const {
clearActivePlayback: () => { clearActivePlayback: () => {
activeJellyfinRemotePlayback = null; activeJellyfinRemotePlayback = null;
}, },
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
runJellyfinCommandMainDeps: { runJellyfinCommandMainDeps: {
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
@@ -3093,7 +3083,6 @@ const {
clearStoredSession: () => clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()), clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => { patchJellyfinConfig: (session) => {
const clientInfo = getJellyfinClientInfo();
const recentServers = mergeJellyfinRecentServers( const recentServers = mergeJellyfinRecentServers(
session.serverUrl, session.serverUrl,
getResolvedConfig().jellyfin.recentServers || [], getResolvedConfig().jellyfin.recentServers || [],
@@ -3103,9 +3092,6 @@ const {
enabled: true, enabled: true,
serverUrl: session.serverUrl, serverUrl: session.serverUrl,
username: session.username, username: session.username,
deviceId: clientInfo.deviceId,
clientName: clientInfo.clientName,
clientVersion: clientInfo.clientVersion,
recentServers, recentServers,
}, },
}); });
@@ -4434,8 +4420,8 @@ const {
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
} }
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
currentMediaTokenizationGate.updateCurrentMediaPath(path); currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
startupOsdSequencer.reset(); startupOsdSequencer.reset();
@@ -6162,6 +6148,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildTrayMenuTemplateDeps: { buildTrayMenuTemplateDeps: {
buildTrayMenuTemplateRuntime, buildTrayMenuTemplateRuntime,
platform: process.platform,
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
openSessionHelpModal: () => openSessionHelpOverlay(), openSessionHelpModal: () => openSessionHelpOverlay(),
@@ -6177,8 +6164,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
isJellyfinConfigured: () => isJellyfinConfigured: () =>
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
toggleJellyfinDiscovery: () => toggleJellyfinDiscovery: (checked: boolean) =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()), toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
desiredActive: checked,
}),
openAnilistSetupWindow: () => openAnilistSetupWindow(), openAnilistSetupWindow: () => openAnilistSetupWindow(),
checkForUpdates: () => { checkForUpdates: () => {
void getUpdateService().checkForUpdates({ source: 'manual' }); void getUpdateService().checkForUpdates({ source: 'manual' });
@@ -6410,6 +6399,7 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
@@ -6421,6 +6411,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
autoplayReadyGate.markCurrentMediaAutoplayReady();
if (overlayManager.getVisibleOverlayVisible()) { if (overlayManager.getVisibleOverlayVisible()) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else { } else {
@@ -6431,6 +6422,7 @@ function toggleVisibleOverlay(): void {
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
+27
View File
@@ -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', () => { test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource(); const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
@@ -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')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
}); });
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
});
test('should restore windows on activate requires initialized runtime and no windows', () => { test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false; let initialized = false;
let windowCount = 1; let windowCount = 1;
+5 -2
View File
@@ -61,9 +61,12 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow(); deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow(); deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow(); deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession(); try {
deps.stopJellyfinRemoteSession();
} finally {
deps.cleanupJellyfinSubtitleCache();
}
deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupJellyfinSubtitleCache();
deps.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
}; };
} }
+80 -1
View File
@@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
), ),
); );
assert.equal(scheduled.length > 0, true);
}); });
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
@@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
); );
}); });
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let playbackPaused = true;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
const retry = scheduled.shift();
retry?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
const commands: Array<Array<string | boolean>> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.markCurrentMediaAutoplayReady();
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => { test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = []; const commands: Array<Array<string | boolean>> = [];
let targetReady = false; let targetReady = false;
+16
View File
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const getSignalMediaPath = (): string => const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__'; deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const markCurrentMediaAutoplayReady = (): void => {
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = getSignalMediaPath();
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = ( const maybeSignalPluginAutoplayReady = (
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
forceWhilePaused: options?.forceWhilePaused === true, forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs, retryDelayMs: releaseRetryDelayMs,
}); });
let releaseUnpauseSent = false;
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => { const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
try { try {
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
deps.logDebug(
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
);
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient); const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!shouldUnpause) { if (!shouldUnpause) {
return; return;
} }
mpvClient.send({ command: ['set_property', 'pause', false] }); mpvClient.send({ command: ['set_property', 'pause', false] });
releaseUnpauseSent = true;
if (attempt < maxReleaseAttempts) { if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
} }
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
flushPendingAutoplayReadySignal, flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath, getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks, invalidatePendingAutoplayReadyFallbacks,
markCurrentMediaAutoplayReady,
maybeSignalPluginAutoplayReady, maybeSignalPluginAutoplayReady,
}; };
} }
@@ -101,6 +101,7 @@ export function composeJellyfinRemoteHandlers(
getConfiguredSession: options.getConfiguredSession, getConfiguredSession: options.getConfiguredSession,
getClientInfo: options.getClientInfo, getClientInfo: options.getClientInfo,
getJellyfinConfig: options.getJellyfinConfig, getJellyfinConfig: options.getJellyfinConfig,
getActivePlayback: options.getActivePlayback,
playJellyfinItem: options.playJellyfinItem, playJellyfinItem: options.playJellyfinItem,
logWarn: options.logWarn, logWarn: options.logWarn,
}); });
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
}, },
getJellyfinClientInfoMainDeps: { getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never, getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({ getHostName: () => 'workstation',
clientName: 'SubMiner', defaultClientName: 'SubMiner',
clientVersion: 'test', defaultClientVersion: 'test',
deviceId: 'dev',
}),
}, },
waitForMpvConnectedMainDeps: { waitForMpvConnectedMainDeps: {
getMpvClient: () => null, getMpvClient: () => null,
@@ -140,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
defaultDeviceId: 'dev', defaultDeviceId: 'dev',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: 'test', defaultClientVersion: 'test',
getHostName: () => 'workstation',
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
}, },
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
>; >;
startJellyfinRemoteSessionMainDeps: Omit< startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps, StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand' | 'getJellyfinConfig'
| 'getClientInfo'
| 'handlePlay'
| 'handlePlaystate'
| 'handleGeneralCommand'
>; >;
stopJellyfinRemoteSessionMainDeps: Parameters< stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
@@ -236,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
createBuildStartJellyfinRemoteSessionMainDepsHandler({ createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps, ...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(), getJellyfinConfig: () => getResolvedJellyfinConfig(),
getClientInfo: () => getJellyfinClientInfo(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload), handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
+1
View File
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
export * from '../jellyfin-client-info-main-deps'; export * from '../jellyfin-client-info-main-deps';
export * from '../jellyfin-command-dispatch'; export * from '../jellyfin-command-dispatch';
export * from '../jellyfin-command-dispatch-main-deps'; export * from '../jellyfin-command-dispatch-main-deps';
export * from '../jellyfin-device-identity';
export * from '../jellyfin-playback-launch'; export * from '../jellyfin-playback-launch';
export * from '../jellyfin-playback-launch-main-deps'; export * from '../jellyfin-playback-launch-main-deps';
export * from '../jellyfin-remote-commands'; export * from '../jellyfin-remote-commands';
+33 -7
View File
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
enabled: true, enabled: true,
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
username: 'user', username: 'user',
deviceId: 'd1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: ['http://localhost'], recentServers: ['http://localhost'],
}, },
}); });
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
}); });
test('persistJellyfinAuthSession stores client metadata and recent servers', () => { test('persistJellyfinAuthSession stores session config and recent servers', () => {
let patchPayload: unknown = null; let patchPayload: unknown = null;
let storedSession: unknown = null; let storedSession: unknown = null;
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
enabled: true, enabled: true,
serverUrl: 'http://localhost:8096', serverUrl: 'http://localhost:8096',
username: 'alice', username: 'alice',
deviceId: 'device-1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: [ recentServers: [
'http://localhost:8096', 'http://localhost:8096',
'http://old.example:8096', 'http://old.example:8096',
@@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
}); });
}); });
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
let patchPayload: unknown = null;
persistJellyfinAuthSession({
session: {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
clientInfo: {
deviceId: 'subminer-local-pc',
clientName: 'SubMiner',
clientVersion: '1.0',
},
existingRecentServers: [],
saveStoredSession: () => {},
patchRawConfig: (patch) => {
patchPayload = patch;
},
});
assert.deepEqual(patchPayload, {
jellyfin: {
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: ['http://localhost:8096'],
},
});
});
test('jellyfin auth handler no-ops when no auth command', async () => { test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({ const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {}, patchRawConfig: () => {},
-9
View File
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
recentServers: string[]; recentServers: string[];
}>; }>;
}) => void; }) => void;
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: true, enabled: true,
serverUrl: deps.session.serverUrl, serverUrl: deps.session.serverUrl,
username: deps.session.username, username: deps.session.username,
deviceId: deps.clientInfo.deviceId,
clientName: deps.clientInfo.clientName,
clientVersion: deps.clientInfo.clientVersion,
recentServers: mergeJellyfinRecentServers( recentServers: mergeJellyfinRecentServers(
deps.session.serverUrl, deps.session.serverUrl,
deps.existingRecentServers || [], deps.existingRecentServers || [],
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
}>; }>;
}) => void; }) => void;
authenticateWithPassword: ( authenticateWithPassword: (
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
test('get jellyfin client info main deps builder maps callbacks', () => { test('get jellyfin client info main deps builder maps callbacks', () => {
const configured = { clientName: 'Configured' }; const configured = { clientName: 'Configured' };
const defaults = { clientName: 'Default' };
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({ const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
getResolvedJellyfinConfig: () => configured as never, getResolvedJellyfinConfig: () => configured as never,
getDefaultJellyfinConfig: () => defaults as never, getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
})(); })();
assert.equal(deps.getResolvedJellyfinConfig(), configured); assert.equal(deps.getResolvedJellyfinConfig(), configured);
assert.equal(deps.getDefaultJellyfinConfig(), defaults); assert.equal(deps.getHostName?.(), 'workstation');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0.0');
}); });
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
) { ) {
return (): GetJellyfinClientInfoMainDeps => ({ return (): GetJellyfinClientInfoMainDeps => ({
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(), getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion,
}); });
} }
+32 -18
View File
@@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou
test('jellyfin client info resolves defaults when fields are missing', () => { test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({ const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never, getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
getDefaultJellyfinConfig: () => getHostName: () => 'workstation',
({ defaultClientName: 'SubMiner',
clientName: 'SubMiner', defaultClientVersion: '1.0.0',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
}); });
assert.deepEqual(getClientInfo(), { assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner', clientName: 'SubMiner',
clientVersion: '1.0.0', clientVersion: '1.0.0',
deviceId: 'default-device', deviceId: 'workstation',
}); });
}); });
test('jellyfin client info keeps explicit config values', () => { test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({ const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig: () =>
({ ({
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
clientVersion: '2.3.4', clientVersion: '2.3.4',
deviceId: 'custom-device', deviceId: 'custom-device',
}) as never, }) as never,
getDefaultJellyfinConfig: () => getHostName: () => 'Kyle-PC',
({ defaultClientName: 'SubMiner',
clientName: 'SubMiner', defaultClientVersion: '1.0.0',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
}); });
assert.deepEqual(getClientInfo(), { assert.deepEqual(getClientInfo(), {
clientName: 'Custom', clientName: 'SubMiner',
clientVersion: '2.3.4', clientVersion: '1.0.0',
deviceId: 'custom-device', deviceId: 'Kyle-PC',
});
});
test('jellyfin client info ignores legacy configured device id and client version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '9.9.9',
deviceId: 'custom-device',
}) as never,
getHostName: () => 'media-box',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'media-box',
}); });
}); });
+13 -11
View File
@@ -1,5 +1,10 @@
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store'; import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
import type { ResolvedConfig } from '../../types'; import type { ResolvedConfig } from '../../types';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './jellyfin-device-identity';
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin']; type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & { type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
} }
export function createGetJellyfinClientInfoHandler(deps: { export function createGetJellyfinClientInfoHandler(deps: {
getResolvedJellyfinConfig: () => Partial< getResolvedJellyfinConfig: () => unknown;
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'> getHostName?: () => string;
>; defaultClientName?: string;
getDefaultJellyfinConfig: () => Partial< defaultClientVersion?: string;
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
}) { }) {
return ( return (
config = deps.getResolvedJellyfinConfig(), _config = deps.getResolvedJellyfinConfig(),
): { ): {
clientName: string; clientName: string;
clientVersion: string; clientVersion: string;
deviceId: string; deviceId: string;
} => { } => {
const defaults = deps.getDefaultJellyfinConfig();
return { return {
clientName: config.clientName || defaults.clientName || '', clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
clientVersion: config.clientVersion || defaults.clientVersion || '', clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
deviceId: config.deviceId || defaults.deviceId || '', deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
}; };
}; };
} }
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHostDerivedJellyfinDeviceId,
resolveJellyfinRemoteDeviceName,
} from './jellyfin-device-identity';
test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => {
assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local');
assert.equal(createHostDerivedJellyfinDeviceId(''), 'device');
});
test('resolveJellyfinRemoteDeviceName uses hostname by default', () => {
assert.equal(
resolveJellyfinRemoteDeviceName({
hostName: 'kyle-pc',
}),
'kyle-pc',
);
});
test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => {
assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device');
});
@@ -0,0 +1,18 @@
export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0';
export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner';
export function normalizeJellyfinHostName(value: string): string {
return value.trim();
}
export function createHostDerivedJellyfinDeviceId(hostName: string): string {
return normalizeJellyfinHostName(hostName) || 'device';
}
export function resolveJellyfinDeviceId(params: { hostName: string }): string {
return createHostDerivedJellyfinDeviceId(params.hostName);
}
export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string {
return normalizeJellyfinHostName(params.hostName) || 'device';
}
@@ -23,5 +23,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata) ? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
: undefined, : 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'], ['set_property', 'sid', 'no'],
['seek', 1.2, 'absolute+exact'], ['seek', 1.2, 'absolute+exact'],
]); ]);
assert.equal(scheduled.length, 1); assert.equal(scheduled.length, 0);
assert.equal(scheduled[0]?.delay, 500); assert.equal(
scheduled[0]?.callback(); commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); 1,
);
assert.ok(calls.includes('defaults')); assert.ok(calls.includes('defaults'));
assert.ok(calls.includes('visible-overlay')); assert.ok(calls.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 () => { test('playback handler applies start override to stream url for remote resume', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({ 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.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']); assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
}); });
test('playback handler does not let stats metadata failures block playback startup', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 3',
itemTitle: 'Episode 3',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
recordJellyfinPlaybackMetadata: () => {
throw new Error('stats db unavailable');
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-3',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
});
+15 -12
View File
@@ -75,6 +75,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
}) => void; }) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void; recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
updateCurrentMediaTitle?: (title: string) => void;
}) { }) {
return async (params: { return async (params: {
session: JellyfinAuthSession; session: JellyfinAuthSession;
@@ -106,24 +107,26 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient); deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
deps.recordJellyfinPlaybackMetadata?.({ deps.updateCurrentMediaTitle?.(plan.title);
mediaPath: playbackUrl, try {
displayTitle: plan.title, deps.recordJellyfinPlaybackMetadata?.({
itemTitle: plan.itemTitle, mediaPath: playbackUrl,
seriesTitle: plan.seriesTitle, displayTitle: plan.title,
seasonNumber: plan.seasonNumber, itemTitle: plan.itemTitle,
episodeNumber: plan.episodeNumber, seriesTitle: plan.seriesTitle,
itemId: params.itemId, seasonNumber: plan.seasonNumber,
}); episodeNumber: plan.episodeNumber,
itemId: params.itemId,
});
} catch {
// Best-effort stats metadata must not block playback startup.
}
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) { if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect(); deps.armQuitOnDisconnect();
} }
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]); deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.schedule(() => {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}, 500);
const startTimeTicks = const startTimeTicks =
typeof params.startTimeTicksOverride === 'number' typeof params.startTimeTicksOverride === 'number'
@@ -101,6 +101,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']); assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
}); });
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
let playCalls = 0;
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
playJellyfinItem: async () => {
playCalls += 1;
},
logWarn: () => {},
});
await handlePlay({ ItemIds: ['item-1'] });
assert.equal(playCalls, 0);
});
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => { test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
const mpvClient = {}; const mpvClient = {};
const commands: Array<(string | number)[]> = []; const commands: Array<(string | number)[]> = [];
@@ -51,6 +51,7 @@ export type JellyfinRemotePlayHandlerDeps = {
getConfiguredSession: () => JellyfinSession | null; getConfiguredSession: () => JellyfinSession | null;
getClientInfo: () => JellyfinClientInfo; getClientInfo: () => JellyfinClientInfo;
getJellyfinConfig: () => unknown; getJellyfinConfig: () => unknown;
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
playJellyfinItem: (params: { playJellyfinItem: (params: {
session: JellyfinSession; session: JellyfinSession;
clientInfo: JellyfinClientInfo; clientInfo: JellyfinClientInfo;
@@ -79,6 +80,9 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.'); deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
return; return;
} }
if (deps.getActivePlayback?.()?.itemId === itemId) {
return;
}
await deps.playJellyfinItem({ await deps.playJellyfinItem({
session, session,
clientInfo, clientInfo,
@@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
getConfiguredSession: () => deps.getConfiguredSession(), getConfiguredSession: () => deps.getConfiguredSession(),
getClientInfo: () => deps.getClientInfo(), getClientInfo: () => deps.getClientInfo(),
getJellyfinConfig: () => deps.getJellyfinConfig(), getJellyfinConfig: () => deps.getJellyfinConfig(),
...(deps.getActivePlayback
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
: {}),
playJellyfinItem: (params) => deps.playJellyfinItem(params), playJellyfinItem: (params) => deps.playJellyfinItem(params),
logWarn: (message: string) => deps.logWarn(message), logWarn: (message: string) => deps.logWarn(message),
}); });
@@ -61,6 +61,38 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
assert.equal(lastProgressAtMs, 5000); assert.equal(lastProgressAtMs, 5000);
}); });
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
const reportPayloads: Array<{ isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
}),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
assert.deepEqual(reportPayloads, [{ isPaused: true }]);
});
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => { test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
let called = false; let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler({ const reportProgress = createReportJellyfinRemoteProgressHandler({
+14 -1
View File
@@ -31,6 +31,19 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
return Math.max(0, Math.floor(seconds * ticksPerSecond)); return Math.max(0, Math.floor(seconds * ticksPerSecond));
} }
function isMpvPauseEnabled(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
return true;
}
return false;
}
export type JellyfinRemoteProgressReporterDeps = { export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void; clearActivePlayback: () => void;
@@ -64,7 +77,7 @@ export function createReportJellyfinRemoteProgressHandler(
itemId: playback.itemId, itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId, mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond), positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
isPaused: paused === true, isPaused: isMpvPauseEnabled(paused),
playMethod: playback.playMethod, playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex, audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex,
@@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
accessToken: 'token', accessToken: 'token',
userId: 'user-id', userId: 'user-id',
deviceId: '',
clientName: '',
clientVersion: '',
remoteControlDeviceName: '',
autoAnnounce: false, autoAnnounce: false,
...(overrides || {}), ...(overrides || {}),
} as never; } as never;
@@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => {
} | null = null; } | null = null;
let started = false; let started = false;
const infos: string[] = []; const infos: string[] = [];
let stateChanges = 0;
const startRemote = createStartJellyfinRemoteSessionHandler({ const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }), getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
getCurrentSession: () => null, getCurrentSession: () => null,
@@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => {
storedSession = session as never; storedSession = session as never;
}, },
createRemoteSessionService: (options) => { createRemoteSessionService: (options) => {
assert.equal(options.deviceName, 'Desk'); assert.equal(options.deviceName, 'workstation');
return { return {
start: () => { start: () => {
started = true; started = true;
@@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
logInfo: (message) => infos.push(message), logInfo: (message) => infos.push(message),
logWarn: () => {}, logWarn: () => {},
onSessionStateChanged: () => {
stateChanges += 1;
},
}); });
await startRemote(); await startRemote();
assert.equal(started, true); assert.equal(started, true);
assert.ok(storedSession); assert.ok(storedSession);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).'))); assert.equal(stateChanges, 1);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).')));
});
test('start handler uses hostname-derived client info and visible device name', async () => {
let createdOptions: {
deviceId: string;
clientName: string;
clientVersion: string;
deviceName: string;
} | null = null;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
clientName: 'SubMiner',
}),
getClientInfo: () => ({
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'kyle-pc',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdOptions = {
deviceId: options.deviceId,
clientName: options.clientName,
clientVersion: options.clientVersion,
deviceName: options.deviceName,
};
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.deepEqual(createdOptions, {
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
deviceName: 'kyle-pc',
});
});
test('start handler ignores configured visible device name', async () => {
let createdDeviceName = '';
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
remoteControlDeviceName: 'SubMiner Cachy sudacode',
}),
getClientInfo: () => ({
deviceId: 'cachy',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'cachy',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdDeviceName = options.deviceName;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.equal(createdDeviceName, 'cachy');
}); });
test('start handler stops previous session before replacing', async () => { test('start handler stops previous session before replacing', async () => {
@@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => {
defaultDeviceId: 'default-device', defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {}, handlePlay: async () => {},
handlePlaystate: async () => {}, handlePlaystate: async () => {},
handleGeneralCommand: async () => {}, handleGeneralCommand: async () => {},
@@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => {
test('stop handler stops active session and clears playback', () => { test('stop handler stops active session and clears playback', () => {
let stopCalls = 0; let stopCalls = 0;
let clearCalls = 0; let clearCalls = 0;
let stateChanges = 0;
let currentSession: { stop: () => void } | null = { let currentSession: { stop: () => void } | null = {
stop: () => { stop: () => {
stopCalls += 1; stopCalls += 1;
@@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => {
clearActivePlayback: () => { clearActivePlayback: () => {
clearCalls += 1; clearCalls += 1;
}, },
onSessionStateChanged: () => {
stateChanges += 1;
},
}); });
stopRemote(); stopRemote();
assert.equal(stopCalls, 1); assert.equal(stopCalls, 1);
assert.equal(clearCalls, 1); assert.equal(clearCalls, 1);
assert.equal(currentSession, null); assert.equal(currentSession, null);
assert.equal(stateChanges, 1);
}); });
@@ -1,3 +1,5 @@
import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity';
type JellyfinRemoteConfig = { type JellyfinRemoteConfig = {
enabled: boolean; enabled: boolean;
remoteControlEnabled: boolean; remoteControlEnabled: boolean;
@@ -5,11 +7,13 @@ type JellyfinRemoteConfig = {
serverUrl: string; serverUrl: string;
accessToken?: string; accessToken?: string;
userId?: string; userId?: string;
autoAnnounce: boolean;
};
type JellyfinClientInfo = {
deviceId: string; deviceId: string;
clientName: string; clientName: string;
clientVersion: string; clientVersion: string;
remoteControlDeviceName: string;
autoAnnounce: boolean;
}; };
type JellyfinRemoteService = { type JellyfinRemoteService = {
@@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
getCurrentSession: () => JellyfinRemoteService | null; getCurrentSession: () => JellyfinRemoteService | null;
setCurrentSession: (session: JellyfinRemoteService | null) => void; setCurrentSession: (session: JellyfinRemoteService | null) => void;
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService; createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
getClientInfo: () => JellyfinClientInfo;
getHostName: () => string;
defaultDeviceId: string; defaultDeviceId: string;
defaultClientName: string; defaultClientName: string;
defaultClientVersion: string; defaultClientVersion: string;
@@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>; handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void; logWarn: (message: string, details?: unknown) => void;
onSessionStateChanged?: () => void;
}) { }) {
return async (options?: { explicit?: boolean }): Promise<void> => { return async (options?: { explicit?: boolean }): Promise<void> => {
const jellyfinConfig = deps.getJellyfinConfig(); const jellyfinConfig = deps.getJellyfinConfig();
@@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return; if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
const clientInfo = deps.getClientInfo();
const clientName = clientInfo.clientName || deps.defaultClientName;
const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion;
const deviceName = resolveJellyfinRemoteDeviceName({
hostName: deps.getHostName(),
});
const existing = deps.getCurrentSession(); const existing = deps.getCurrentSession();
if (existing) { if (existing) {
existing.stop(); existing.stop();
@@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
const service = deps.createRemoteSessionService({ const service = deps.createRemoteSessionService({
serverUrl: jellyfinConfig.serverUrl, serverUrl: jellyfinConfig.serverUrl,
accessToken: jellyfinConfig.accessToken, accessToken: jellyfinConfig.accessToken,
deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId, deviceId: clientInfo.deviceId || deps.defaultDeviceId,
clientName: jellyfinConfig.clientName || deps.defaultClientName, clientName,
clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion, clientVersion,
deviceName: deviceName,
jellyfinConfig.remoteControlDeviceName ||
jellyfinConfig.clientName ||
deps.defaultClientName,
capabilities: { capabilities: {
PlayableMediaTypes: 'Video,Audio', PlayableMediaTypes: 'Video,Audio',
SupportedCommands: SupportedCommands:
@@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
service.start(); service.start();
deps.setCurrentSession(service); deps.setCurrentSession(service);
deps.logInfo( deps.onSessionStateChanged?.();
`Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`);
);
}; };
} }
@@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
getCurrentSession: () => JellyfinRemoteService | null; getCurrentSession: () => JellyfinRemoteService | null;
setCurrentSession: (session: JellyfinRemoteService | null) => void; setCurrentSession: (session: JellyfinRemoteService | null) => void;
clearActivePlayback: () => void; clearActivePlayback: () => void;
onSessionStateChanged?: () => void;
}) { }) {
return (): void => { return (): void => {
const session = deps.getCurrentSession(); const session = deps.getCurrentSession();
@@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
session.stop(); session.stop();
deps.setCurrentSession(null); deps.setCurrentSession(null);
deps.clearActivePlayback(); deps.clearActivePlayback();
deps.onSessionStateChanged?.();
}; };
} }
@@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
getCurrentSession: () => null, getCurrentSession: () => null,
setCurrentSession: () => calls.push('set-session'), setCurrentSession: () => calls.push('set-session'),
createRemoteSessionService: () => session as never, createRemoteSessionService: () => session as never,
getClientInfo: () =>
({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}) as never,
getHostName: () => 'workstation',
defaultDeviceId: 'device', defaultDeviceId: 'device',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: '1.0', defaultClientVersion: '1.0',
@@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
}, },
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
onSessionStateChanged: () => calls.push('state-changed'),
})(); })();
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' }); assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
assert.equal(deps.defaultDeviceId, 'device'); assert.equal(deps.defaultDeviceId, 'device');
assert.equal(deps.defaultClientName, 'SubMiner'); assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0'); assert.equal(deps.defaultClientVersion, '1.0');
assert.equal(deps.getHostName(), 'workstation');
assert.deepEqual(deps.getClientInfo(), {
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
});
assert.equal(deps.createRemoteSessionService({} as never), session); assert.equal(deps.createRemoteSessionService({} as never), session);
await deps.handlePlay({}); await deps.handlePlay({});
await deps.handlePlaystate({}); await deps.handlePlaystate({});
await deps.handleGeneralCommand({}); await deps.handleGeneralCommand({});
deps.logInfo('connected'); deps.logInfo('connected');
deps.logWarn('missing'); deps.logWarn('missing');
assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']); deps.onSessionStateChanged?.();
assert.deepEqual(calls, [
'play',
'playstate',
'general',
'info:connected',
'warn:missing',
'state-changed',
]);
}); });
test('stop jellyfin remote session main deps builder maps callbacks', () => { test('stop jellyfin remote session main deps builder maps callbacks', () => {
@@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => {
getCurrentSession: () => session as never, getCurrentSession: () => session as never,
setCurrentSession: () => calls.push('set-null'), setCurrentSession: () => calls.push('set-null'),
clearActivePlayback: () => calls.push('clear'), clearActivePlayback: () => calls.push('clear'),
onSessionStateChanged: () => calls.push('state-changed'),
})(); })();
assert.equal(deps.getCurrentSession(), session); assert.equal(deps.getCurrentSession(), session);
deps.setCurrentSession(null); deps.setCurrentSession(null);
deps.clearActivePlayback(); deps.clearActivePlayback();
assert.deepEqual(calls, ['set-null', 'clear']); deps.onSessionStateChanged?.();
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
}); });
@@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
getCurrentSession: () => deps.getCurrentSession(), getCurrentSession: () => deps.getCurrentSession(),
setCurrentSession: (session) => deps.setCurrentSession(session), setCurrentSession: (session) => deps.setCurrentSession(session),
createRemoteSessionService: (options) => deps.createRemoteSessionService(options), createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
getClientInfo: () => deps.getClientInfo(),
getHostName: () => deps.getHostName(),
defaultDeviceId: deps.defaultDeviceId, defaultDeviceId: deps.defaultDeviceId,
defaultClientName: deps.defaultClientName, defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion, defaultClientVersion: deps.defaultClientVersion,
@@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload), handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
onSessionStateChanged: deps.onSessionStateChanged,
}); });
} }
@@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler(
getCurrentSession: () => deps.getCurrentSession(), getCurrentSession: () => deps.getCurrentSession(),
setCurrentSession: (session) => deps.setCurrentSession(session), setCurrentSession: (session) => deps.setCurrentSession(session),
clearActivePlayback: () => deps.clearActivePlayback(), clearActivePlayback: () => deps.clearActivePlayback(),
onSessionStateChanged: deps.onSessionStateChanged,
}); });
} }
@@ -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: () => ({ getMpvClient: () => ({
requestProperty: async () => [ 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), 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' }); await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(commands, [ assert.deepEqual(commands, [
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'], ['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'], ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
['set_property', 'sid', 5], ['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6], ['set_property', 'secondary-sid', 6],
]); ]);
}); });
test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount < 3) {
return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }];
}
return [
{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false },
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
],
);
});
test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount < 3) {
return [{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' }];
}
return [
{ type: 'sub', id: 2, lang: 'jpn', title: 'Embedded Japanese' },
{
type: 'sub',
id: 42,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 43,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 42],
['set_property', 'secondary-sid', 43],
],
);
});
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 10, language: 'deu', title: 'German', deliveryUrl: 'https://sub/deu.ass' },
{ index: 12, language: 'rus', title: 'Russian', deliveryUrl: 'https://sub/rus.ass' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
if (requestCount === 1) {
return [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
];
}
return [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
{
type: 'sub',
id: 18,
lang: 'deu',
title: 'German',
external: true,
selected: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/10.srt',
},
{
type: 'sub',
id: 20,
lang: 'rus',
title: 'Russian',
external: true,
selected: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/12.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 2);
assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add'),
[
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/10.srt', 'auto', 'German', 'deu'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'],
],
);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[['set_property', 'sid', 11]],
);
});
test('preload jellyfin subtitles leaves current track alone when reported japanese track never appears', async () => {
const commands: Array<Array<string | number>> = [];
const logs: string[] = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
return [{ type: 'sub', id: 1, lang: 'eng', title: 'CR', external: false }];
},
}),
sendMpvCommand: (command) => commands.push(command),
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 10);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
),
false,
);
assert.deepEqual(logs, ['Timed out waiting for Jellyfin Japanese subtitle track']);
});
test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => { test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => {
const cleanupCalls: string[][] = []; const cleanupCalls: string[][] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler( 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']]); 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 () => { test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => {
const cleanupCalls: string[][] = []; const cleanupCalls: string[][] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler( const preload = createPreloadJellyfinExternalSubtitlesHandler(
+173 -29
View File
@@ -23,10 +23,27 @@ type CachedSubtitleTrack = {
cleanupDir: string; cleanupDir: string;
}; };
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
title: string;
external: boolean;
externalFilename: string;
};
type MpvClientLike = { type MpvClientLike = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>; requestProperty: (name: string) => Promise<unknown>;
}; };
const TRACK_SELECTION_INITIAL_WAIT_MS = 250;
const TRACK_SELECTION_RETRY_MS = 150;
const TRACK_SELECTION_MAX_ATTEMPTS = 10;
export type PreloadJellyfinExternalSubtitlesHandler = ((params: { export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
session: JellyfinSession; session: JellyfinSession;
clientInfo: JellyfinClientInfo; clientInfo: JellyfinClientInfo;
@@ -71,17 +88,12 @@ function isLikelyHearingImpaired(title: string): boolean {
} }
function pickBestTrackId( function pickBestTrackId(
tracks: Array<{ tracks: MpvSubtitleTrack[],
id: number;
lang: string;
title: string;
external: boolean;
}>,
languageMatcher: (value: string) => boolean, languageMatcher: (value: string) => boolean,
excludeId: number | null = null, excludeId: number | null = null,
): number | null { ): number | null {
const ranked = tracks const ranked = tracks
.filter((track) => languageMatcher(track.lang)) .filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
.filter((track) => track.id !== excludeId) .filter((track) => track.id !== excludeId)
.map((track) => ({ .map((track) => ({
track, track,
@@ -94,6 +106,119 @@ function pickBestTrackId(
return ranked[0]?.track.id ?? null; return ranked[0]?.track.id ?? null;
} }
function pickBestCachedTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
sourceMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track]));
const ranked = tracks
.map((track) => ({
track,
cached: cachedByPath.get(track.externalFilename),
}))
.filter(({ cached }) =>
cached
? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '')
: false,
)
.filter(({ track }) => track.id !== excludeId)
.map(({ track, cached }) => {
const title = cached?.source.title || track.title;
return {
track,
score:
(track.external ? 100 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0),
};
})
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
function isJapaneseTrack(track: MpvSubtitleTrack): boolean {
return isJapanese(track.lang) || isJapanese(track.title);
}
function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean {
return tracks.some((track) => track.external && isJapaneseTrack(track));
}
function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
return Array.isArray(trackListRaw)
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
Boolean(track) &&
typeof track === 'object' &&
track.type === 'sub' &&
typeof track.id === 'number',
)
.map((track) => ({
id: track.id as number,
lang: String(track.lang || ''),
title: String(track.title || ''),
external: track.external === true,
externalFilename: String(track['external-filename'] || ''),
}))
: [];
}
function hasExpectedExternalSubtitleTracks(
tracks: MpvSubtitleTrack[],
expectedExternalFilenames: string[],
): boolean {
if (expectedExternalFilenames.length === 0) {
return true;
}
const loadedExternalFilenames = new Set(
tracks
.filter((track) => track.externalFilename)
.map((track) => track.externalFilename),
);
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
}
async function readMpvSubtitleTracks(deps: {
getMpvClient: () => MpvClientLike | null;
}): Promise<MpvSubtitleTrack[] | null> {
const client = deps.getMpvClient();
if (!client || client.connected === false) {
return null;
}
const trackListRaw = await client.requestProperty('track-list');
return parseMpvSubtitleTracks(trackListRaw);
}
async function waitForPreferredSubtitleTracks(
deps: {
getMpvClient: () => MpvClientLike | null;
wait: (ms: number) => Promise<void>;
},
shouldWaitForExternalJapanese: boolean,
expectedExternalFilenames: string[],
): Promise<MpvSubtitleTrack[] | null> {
let subtitleTracks: MpvSubtitleTrack[] = [];
for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) {
const nextTracks = await readMpvSubtitleTracks(deps);
if (nextTracks !== null) {
subtitleTracks = nextTracks;
if (
(!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) &&
hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames)
) {
return subtitleTracks;
}
}
if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) {
await deps.wait(TRACK_SELECTION_RETRY_MS);
}
}
return subtitleTracks;
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: { export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: ( listJellyfinSubtitleTracks: (
session: JellyfinSession, session: JellyfinSession,
@@ -108,6 +233,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler { }): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>(); const activeCacheDirs = new Set<string>();
let preloadQueue: Promise<void> = Promise.resolve();
function cleanupActiveCache(): void { function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs]; const dirs = [...activeCacheDirs];
@@ -116,7 +242,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.cleanupCachedSubtitles(dirs); deps.cleanupCachedSubtitles(dirs);
} }
const preload = async (params: { const runPreload = async (params: {
session: JellyfinSession; session: JellyfinSession;
clientInfo: JellyfinClientInfo; clientInfo: JellyfinClientInfo;
itemId: string; itemId: string;
@@ -136,6 +262,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
await deps.wait(300); await deps.wait(300);
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
const cachedTracks: CachedExternalSubtitleTrack[] = [];
for (const track of externalTracks) { for (const track of externalTracks) {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) { if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
continue; continue;
@@ -145,36 +272,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
const label = labelBase || `Jellyfin Subtitle ${track.index}`; const label = labelBase || `Jellyfin Subtitle ${track.index}`;
const cached = await deps.cacheSubtitleTrack(track); const cached = await deps.cacheSubtitleTrack(track);
activeCacheDirs.add(cached.cleanupDir); 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); await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list'); const shouldWaitForExternalJapanese = externalTracks.some(
const subtitleTracks = Array.isArray(trackListRaw) (track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
? trackListRaw );
.filter( const subtitleTracks = await waitForPreferredSubtitleTracks(
(track): track is Record<string, unknown> => deps,
Boolean(track) && shouldWaitForExternalJapanese,
typeof track === 'object' && cachedTracks.map((track) => track.path),
track.type === 'sub' && );
typeof track.id === 'number', if (
) shouldWaitForExternalJapanese &&
.map((track) => ({ (!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
id: track.id as number, ) {
lang: String(track.lang || ''), deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
title: String(track.title || ''), itemId: params.itemId,
external: track.external === true, });
})) return;
: []; }
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); const japanesePrimaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
pickBestTrackId(subtitleTracks ?? [], isJapanese);
if (japanesePrimaryId !== null) { if (japanesePrimaryId !== null) {
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
} else { } else {
deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.sendMpvCommand(['set_property', 'sid', 'no']);
} }
const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); const englishSecondaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) { if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
} }
@@ -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, { return Object.assign(preload, {
cleanupCachedSubtitles: cleanupActiveCache, cleanupCachedSubtitles: cleanupActiveCache,
}); });
@@ -194,6 +194,124 @@ test('stops active discovery from tray', async () => {
]); ]);
}); });
test('uses checked tray state to start discovery instead of blind toggling', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray(
{
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
},
{ desiredActive: true },
);
assert.deepEqual(calls, [
'start',
'advertise',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('uses unchecked tray state to stop discovery without visibility probing', async () => {
const calls: string[] = [];
await toggleJellyfinDiscoveryFromTray(
{
getRemoteSession: () => ({
advertiseNow: async () => {
calls.push('advertise');
return true;
},
}),
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async () => {
calls.push('start');
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
},
{ desiredActive: false },
);
assert.deepEqual(calls, [
'stop',
'info:Jellyfin discovery stopped.',
'osd:Jellyfin discovery stopped',
'refresh',
]);
});
test('restarts active discovery when current session is not visible', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = {
advertiseNow: async () => {
calls.push('advertise-stale');
return false;
},
};
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => {
calls.push('stop');
session = null;
},
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise-fresh');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'advertise-stale',
'warn:Jellyfin discovery was active but not visible; restarting.',
'stop',
'start',
'advertise-fresh',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('warns and refreshes tray when explicit discovery cannot create a session', async () => { test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
const calls: string[] = []; const calls: string[] = [];
+30 -4
View File
@@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinT
| 'logger' | 'logger'
| 'showMpvOsd' | 'showMpvOsd'
>, >,
options: { desiredActive?: boolean } = {},
): Promise<void> { ): Promise<void> {
try { try {
const activeSession = deps.getRemoteSession(); const activeSession = deps.getRemoteSession();
if (activeSession) { if (options.desiredActive === false) {
deps.stopRemoteSession(); if (activeSession) {
deps.logger.info('Jellyfin discovery stopped.'); deps.stopRemoteSession();
deps.showMpvOsd('Jellyfin discovery stopped'); deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
}
return; return;
} }
if (activeSession) {
let visible = false;
try {
visible = await activeSession.advertiseNow();
} catch {
deps.logger.warn('Jellyfin discovery visibility check failed; restarting.');
}
if (visible) {
if (options.desiredActive === true) {
deps.logger.info('Jellyfin discovery already active.');
} else {
deps.stopRemoteSession();
deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
}
return;
}
deps.logger.warn('Jellyfin discovery was active but not visible; restarting.');
deps.stopRemoteSession();
}
await deps.startRemoteSession({ explicit: true }); await deps.startRemoteSession({ explicit: true });
const remoteSession = deps.getRemoteSession(); const remoteSession = deps.getRemoteSession();
if (!remoteSession) { if (!remoteSession) {
@@ -100,3 +100,33 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track
assert.deepEqual(calls, []); 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; 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( function getActiveSubtitleTrack(
currentTrackRaw: unknown, currentTrackRaw: unknown,
trackListRaw: unknown, trackListRaw: unknown,
@@ -104,6 +113,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: externalFilename, sourceKey: externalFilename }; return { path: externalFilename, sourceKey: externalFilename };
} }
if (isRemoteMediaPath(input.videoPath)) {
return null;
}
const extracted = await deps.extractInternalSubtitleTrack( const extracted = await deps.extractInternalSubtitleTrack(
deps.getFfmpegPath(), deps.getFfmpegPath(),
input.videoPath, input.videoPath,
+57
View File
@@ -43,6 +43,63 @@ test('ensure tray updates menu when tray already exists', () => {
assert.deepEqual(calls, ['set-menu']); 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', () => { test('ensure tray creates new tray and binds click handler', () => {
const calls: string[] = []; const calls: string[] = [];
let trayRef: unknown = null; let trayRef: unknown = null;
+7 -4
View File
@@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => {
let initialized = false; let initialized = false;
const buildTemplate = createBuildTrayMenuTemplateHandler({ const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => { buildTrayMenuTemplateRuntime: (handlers) => {
calls.push(`platform:${handlers.platform}`);
handlers.openSessionHelp(); handlers.openSessionHelp();
handlers.openTexthookerInBrowser(); handlers.openTexthookerInBrowser();
calls.push(`show-texthooker:${handlers.showTexthookerPage}`); calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
@@ -50,7 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openYomitanSettings(); handlers.openYomitanSettings();
handlers.openConfigSettings(); handlers.openConfigSettings();
handlers.openJellyfinSetup(); handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(); handlers.toggleJellyfinDiscovery(true);
handlers.openAnilistSetup(); handlers.openAnilistSetup();
handlers.checkForUpdates(); handlers.checkForUpdates();
handlers.quitApp(); handlers.quitApp();
@@ -72,9 +73,10 @@ test('build tray template handler wires actions and init guards', () => {
openJellyfinSetupWindow: () => calls.push('jellyfin'), openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true, isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false, isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: async () => { toggleJellyfinDiscovery: async (checked) => {
calls.push('jellyfin-discovery'); calls.push(`jellyfin-discovery:${checked}`);
}, },
platform: 'linux',
openAnilistSetupWindow: () => calls.push('anilist'), openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'), checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
@@ -83,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
const template = buildTemplate(); const template = buildTemplate();
assert.deepEqual(template, [{ label: 'ok' }]); assert.deepEqual(template, [{ label: 'ok' }]);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'platform:linux',
'init', 'init',
'help', 'help',
'texthooker', 'texthooker',
@@ -92,7 +95,7 @@ test('build tray template handler wires actions and init guards', () => {
'yomitan', 'yomitan',
'configuration', 'configuration',
'jellyfin', 'jellyfin',
'jellyfin-discovery', 'jellyfin-discovery:true',
'anilist', 'anilist',
'updates', 'updates',
'quit', 'quit',
+7 -4
View File
@@ -37,6 +37,7 @@ export function shouldShowTexthookerTrayEntry(config: {
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: { export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: { buildTrayMenuTemplateRuntime: (handlers: {
platform?: string;
openSessionHelp: () => void; openSessionHelp: () => void;
openTexthookerInBrowser: () => void; openTexthookerInBrowser: () => void;
showTexthookerPage: boolean; showTexthookerPage: boolean;
@@ -49,7 +50,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean; showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean; jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void; toggleJellyfinDiscovery: (checked: boolean) => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
checkForUpdates: () => void; checkForUpdates: () => void;
quitApp: () => void; quitApp: () => void;
@@ -67,13 +68,15 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openJellyfinSetupWindow: () => void; openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean; isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean; isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>; toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
platform?: string;
openAnilistSetupWindow: () => void; openAnilistSetupWindow: () => void;
checkForUpdates: () => void; checkForUpdates: () => void;
quitApp: () => void; quitApp: () => void;
}) { }) {
return (): TMenuItem[] => { return (): TMenuItem[] => {
return deps.buildTrayMenuTemplateRuntime({ return deps.buildTrayMenuTemplateRuntime({
platform: deps.platform,
openSessionHelp: () => { openSessionHelp: () => {
if (!deps.isOverlayRuntimeInitialized()) { if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
@@ -103,8 +106,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}, },
showJellyfinDiscovery: deps.isJellyfinConfigured(), showJellyfinDiscovery: deps.isJellyfinConfigured(),
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(), jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
toggleJellyfinDiscovery: () => { toggleJellyfinDiscovery: (checked) => {
void deps.toggleJellyfinDiscovery(); void deps.toggleJellyfinDiscovery(checked);
}, },
openAnilistSetup: () => { openAnilistSetup: () => {
deps.openAnilistSetupWindow(); deps.openAnilistSetupWindow();
+6 -3
View File
@@ -35,15 +35,18 @@ test('tray main deps builders return mapped handlers', () => {
openJellyfinSetupWindow: () => calls.push('jellyfin'), openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true, isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false, isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => { toggleJellyfinDiscovery: (checked) => {
calls.push('jellyfin-discovery'); calls.push(`jellyfin-discovery:${checked}`);
}, },
platform: 'linux',
openAnilistSetupWindow: () => calls.push('anilist'), openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'), checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
})(); })();
assert.equal(menuDeps.platform, 'linux');
const template = menuDeps.buildTrayMenuTemplateRuntime({ const template = menuDeps.buildTrayMenuTemplateRuntime({
platform: menuDeps.platform,
openSessionHelp: () => calls.push('open-help'), openSessionHelp: () => calls.push('open-help'),
openTexthookerInBrowser: () => calls.push('open-texthooker'), openTexthookerInBrowser: () => calls.push('open-texthooker'),
showTexthookerPage: true, showTexthookerPage: true,
@@ -56,7 +59,7 @@ test('tray main deps builders return mapped handlers', () => {
openJellyfinSetup: () => calls.push('open-jellyfin'), openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true, showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false, jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'), toggleJellyfinDiscovery: (checked) => calls.push(`open-jellyfin-discovery:${checked}`),
openAnilistSetup: () => calls.push('open-anilist'), openAnilistSetup: () => calls.push('open-anilist'),
checkForUpdates: () => calls.push('open-updates'), checkForUpdates: () => calls.push('open-updates'),
quitApp: () => calls.push('quit-app'), quitApp: () => calls.push('quit-app'),
+5 -2
View File
@@ -27,6 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: { export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: { buildTrayMenuTemplateRuntime: (handlers: {
platform?: string;
openSessionHelp: () => void; openSessionHelp: () => void;
openTexthookerInBrowser: () => void; openTexthookerInBrowser: () => void;
showTexthookerPage: boolean; showTexthookerPage: boolean;
@@ -39,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean; showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean; jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void; toggleJellyfinDiscovery: (checked: boolean) => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
checkForUpdates: () => void; checkForUpdates: () => void;
quitApp: () => void; quitApp: () => void;
@@ -57,13 +58,15 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openJellyfinSetupWindow: () => void; openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean; isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean; isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>; toggleJellyfinDiscovery: (checked: boolean) => void | Promise<void>;
platform?: string;
openAnilistSetupWindow: () => void; openAnilistSetupWindow: () => void;
checkForUpdates: () => void; checkForUpdates: () => void;
quitApp: () => void; quitApp: () => void;
}) { }) {
return () => ({ return () => ({
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime, buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
platform: deps.platform,
initializeOverlayRuntime: deps.initializeOverlayRuntime, initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
openSessionHelpModal: deps.openSessionHelpModal, openSessionHelpModal: deps.openSessionHelpModal,
+29 -3
View File
@@ -41,7 +41,7 @@ test('tray menu template contains expected entries and handlers', () => {
openJellyfinSetup: () => calls.push('jellyfin'), openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true, showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false, jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'), toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
openAnilistSetup: () => calls.push('anilist'), openAnilistSetup: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'), checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'), 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'); const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, false); assert.equal(discovery?.checked, false);
discovery?.click?.(); discovery?.click?.({ checked: true });
template[0]!.click?.(); template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker'); assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.(); 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[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.(); template[11]!.click?.();
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'jellyfin-discovery', 'jellyfin-discovery:true',
'help', 'help',
'texthooker', 'texthooker',
'updates', 'updates',
@@ -155,3 +155,29 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, true); 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);
});
+20 -4
View File
@@ -30,6 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
} }
export type TrayMenuActionHandlers = { export type TrayMenuActionHandlers = {
platform?: string;
openSessionHelp: () => void; openSessionHelp: () => void;
openTexthookerInBrowser: () => void; openTexthookerInBrowser: () => void;
showTexthookerPage: boolean; showTexthookerPage: boolean;
@@ -42,19 +43,28 @@ export type TrayMenuActionHandlers = {
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean; showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean; jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void; toggleJellyfinDiscovery: (checked: boolean) => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
checkForUpdates: () => void; checkForUpdates: () => void;
quitApp: () => void; quitApp: () => void;
}; };
type TrayMenuClickItem = {
checked?: boolean;
};
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
label?: string; label?: string;
type?: 'separator' | 'checkbox'; type?: 'separator' | 'checkbox';
checked?: boolean; checked?: boolean;
enabled?: boolean; enabled?: boolean;
click?: () => void; click?: (menuItem?: TrayMenuClickItem) => void;
}> { }> {
const jellyfinDiscoveryLabel =
handlers.platform === 'linux' && handlers.jellyfinDiscoveryActive
? '✓ Jellyfin Discovery'
: 'Jellyfin Discovery';
return [ return [
{ {
label: 'Open Help', label: 'Open Help',
@@ -99,11 +109,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
...(handlers.showJellyfinDiscovery ...(handlers.showJellyfinDiscovery
? [ ? [
{ {
label: 'Jellyfin Discovery', label: jellyfinDiscoveryLabel,
type: 'checkbox' as const, type: 'checkbox' as const,
checked: handlers.jellyfinDiscoveryActive, checked: handlers.jellyfinDiscoveryActive,
enabled: true, enabled: true,
click: handlers.toggleJellyfinDiscovery, click: (menuItem?: TrayMenuClickItem) => {
const checked =
typeof menuItem?.checked === 'boolean'
? menuItem.checked
: !handlers.jellyfinDiscoveryActive;
handlers.toggleJellyfinDiscovery(checked);
},
}, },
] ]
: []), : []),
+26
View File
@@ -244,3 +244,29 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
harness.restoreGlobals(); 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();
}
});
+9 -1
View File
@@ -105,8 +105,16 @@ export function createSubsyncModal(
async function runSubsyncManualFromModal(): Promise<void> { async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return; 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 = const sourceTrackId =
engine === 'alass' && ctx.dom.subsyncSourceSelect.value engine === 'alass' && ctx.dom.subsyncSourceSelect.value
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10) ? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
-4
View File
@@ -302,14 +302,10 @@ export interface ResolvedConfig {
serverUrl: string; serverUrl: string;
recentServers: string[]; recentServers: string[];
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
defaultLibraryId: string; defaultLibraryId: string;
remoteControlEnabled: boolean; remoteControlEnabled: boolean;
remoteControlAutoConnect: boolean; remoteControlAutoConnect: boolean;
autoAnnounce: boolean; autoAnnounce: boolean;
remoteControlDeviceName: string;
pullPictures: boolean; pullPictures: boolean;
iconCacheDir: string; iconCacheDir: string;
directPlayPreferred: boolean; directPlayPreferred: boolean;
-4
View File
@@ -87,14 +87,10 @@ export interface JellyfinConfig {
serverUrl?: string; serverUrl?: string;
recentServers?: string[]; recentServers?: string[];
username?: string; username?: string;
deviceId?: string;
clientName?: string;
clientVersion?: string;
defaultLibraryId?: string; defaultLibraryId?: string;
remoteControlEnabled?: boolean; remoteControlEnabled?: boolean;
remoteControlAutoConnect?: boolean; remoteControlAutoConnect?: boolean;
autoAnnounce?: boolean; autoAnnounce?: boolean;
remoteControlDeviceName?: string;
pullPictures?: boolean; pullPictures?: boolean;
iconCacheDir?: string; iconCacheDir?: string;
directPlayPreferred?: boolean; directPlayPreferred?: boolean;