diff --git a/README.md b/README.md
index af0a3f48..55db317d 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
Jellyfin
- Browse and launch media from your Jellyfin server
+ Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray
Jimaku
@@ -252,6 +252,8 @@ subminer app --setup # launch the first-run setup wizard
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup.
+Jellyfin setup is available from the tray or `subminer jellyfin`; once Jellyfin is enabled with a server URL, the tray can toggle Jellyfin Discovery for the current app session.
+
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
diff --git a/backlog/completed/task-314 - Improve-Jellyfin-setup-popup-and-tray-discovery-toggle.md b/backlog/completed/task-314 - Improve-Jellyfin-setup-popup-and-tray-discovery-toggle.md
new file mode 100644
index 00000000..30f503da
--- /dev/null
+++ b/backlog/completed/task-314 - Improve-Jellyfin-setup-popup-and-tray-discovery-toggle.md
@@ -0,0 +1,44 @@
+---
+id: TASK-314
+title: Improve Jellyfin setup popup and tray discovery toggle
+status: Done
+assignee: []
+created_date: '2026-05-02 22:45'
+updated_date: '2026-05-02 23:11'
+labels:
+ - jellyfin
+dependencies: []
+references:
+ - src/main/runtime/jellyfin-setup-window.ts
+ - src/main/runtime/jellyfin-cli-auth.ts
+ - src/main/runtime/tray-runtime.ts
+ - src/main/runtime/jellyfin-remote-session-lifecycle.ts
+documentation:
+ - docs-site/jellyfin-integration.md
+ - docs-site/configuration.md
+priority: medium
+---
+
+## Description
+
+
+Improve the Jellyfin integration setup experience and remove the need to use command-line discovery mode for normal tray-driven use. The existing `--jellyfin` setup popup should become a frontend for the same auth persistence path used by CLI login, with manual/recent server selection and inline feedback. The tray should expose a runtime-only Jellyfin Discovery checkbox when Jellyfin is configured so users can start or stop cast/discovery mode without changing config.
+
+
+## Acceptance Criteria
+
+- [x] #1 The Jellyfin setup popup supports config/recent/default server choices, manual URL entry, username/password login, logout when a session exists, done/close, and inline success/error status without persisting passwords.
+- [x] #2 CLI login and setup popup login share the same auth persistence behavior, including encrypted token storage, enabled/server/username/client metadata config patching, and recent server updates.
+- [x] #3 `jellyfin.recentServers` is parsed, normalized, deduplicated, capped, documented, and included in generated config examples if exposed.
+- [x] #4 The tray keeps Configure Jellyfin visible and shows a Jellyfin Discovery checkbox only when Jellyfin is configured with enabled integration, server URL, access token, and user ID.
+- [x] #5 The tray Jellyfin Discovery checkbox starts/stops the current remote session at runtime only, announces after start, reports OSD/log status, and does not patch config.
+- [x] #6 Startup auto-connect behavior remains governed by existing config, including `remoteControlAutoConnect`; explicit tray start can start discovery without requiring `remoteControlAutoConnect`.
+- [x] #7 Focused tests cover setup popup actions/rendering, shared auth persistence, config parsing, tray toggle visibility/state/click behavior, and remote lifecycle auto-connect versus explicit-start behavior.
+- [x] #8 Jellyfin docs and changelog fragment are updated.
+
+
+## Final Summary
+
+
+Implemented Jellyfin setup popup improvements, shared auth persistence for CLI/setup config shape, recent server config support, runtime-only tray Jellyfin Discovery toggle, docs/config examples, and changelog fragment. Verified focused Jellyfin/tray tests, config tests, launcher tests, typecheck, and docs tests.
+
diff --git a/changes/314-jellyfin-setup-discovery.md b/changes/314-jellyfin-setup-discovery.md
new file mode 100644
index 00000000..94b2b5a8
--- /dev/null
+++ b/changes/314-jellyfin-setup-discovery.md
@@ -0,0 +1,5 @@
+type: fixed
+area: jellyfin
+
+- Improved Jellyfin setup with recent server selection and inline authentication feedback.
+- Added a tray Jellyfin Discovery toggle for runtime-only cast discovery.
diff --git a/config.example.jsonc b/config.example.jsonc
index 7632da1e..1b3470ed 100644
--- a/config.example.jsonc
+++ b/config.example.jsonc
@@ -483,6 +483,7 @@
"jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
+ "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting.
diff --git a/docs-site/configuration.md b/docs-site/configuration.md
index 6fe134ef..eb8e2fbf 100644
--- a/docs-site/configuration.md
+++ b/docs-site/configuration.md
@@ -1157,6 +1157,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
+ "recentServers": ["http://127.0.0.1:8096"],
"username": "",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
@@ -1174,6 +1175,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL |
+| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
@@ -1206,6 +1208,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
+When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
+
### Discord Rich Presence
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md
index 15cbddb8..4ee649b0 100644
--- a/docs-site/jellyfin-integration.md
+++ b/docs-site/jellyfin-integration.md
@@ -6,7 +6,8 @@ SubMiner includes an optional Jellyfin CLI integration for:
- listing libraries and media items
- launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app
-- opening an in-app setup window for server/user/password input
+- opening an in-app setup window for server selection and authentication
+- toggling Jellyfin cast discovery from the tray once configured
## Requirements
@@ -23,6 +24,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
+ "recentServers": ["http://127.0.0.1:8096"],
"username": "your-user",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
@@ -48,6 +50,8 @@ subminer jellyfin -l \
--password 'your-password'
```
+`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. 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.
+
3. List libraries:
```bash
@@ -66,6 +70,8 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d
```
+After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
+
Stop discovery session/app:
```bash
@@ -129,12 +135,13 @@ remote playback target in Jellyfin's cast-to-device menu.
- `jellyfin.enabled=true`
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
- `jellyfin.remoteControlEnabled=true` (default)
-- `jellyfin.remoteControlAutoConnect=true` (default)
+- `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
### Behavior
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
+- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled.
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
- `Playstate` events map to mpv pause/resume/seek/stop controls.
@@ -147,7 +154,8 @@ remote playback target in Jellyfin's cast-to-device menu.
- Device not visible in Jellyfin cast menu:
- ensure SubMiner is running
- ensure session token is valid (`--jellyfin-login` again if needed)
- - ensure `remoteControlEnabled` and `remoteControlAutoConnect` are true
+ - ensure `remoteControlEnabled` is true
+ - use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery
- Cast command received but playback does not start:
- verify mpv IPC can connect (`--start` flow)
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc
index 7632da1e..1b3470ed 100644
--- a/docs-site/public/config.example.jsonc
+++ b/docs-site/public/config.example.jsonc
@@ -483,6 +483,7 @@
"jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
+ "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting.
diff --git a/docs-site/usage.md b/docs-site/usage.md
index 0b1bb68e..8d69c74a 100644
--- a/docs-site/usage.md
+++ b/docs-site/usage.md
@@ -135,6 +135,8 @@ SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector
SubMiner.AppImage --help # Show all options
```
+Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
+
### Logging and App Mode
- `--log-level` controls logger verbosity.
diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts
index 9972c92e..33da67a5 100644
--- a/src/config/definitions/defaults-integrations.ts
+++ b/src/config/definitions/defaults-integrations.ts
@@ -117,6 +117,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
jellyfin: {
enabled: false,
serverUrl: '',
+ recentServers: [],
username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts
index 012d01e1..97975611 100644
--- a/src/config/definitions/options-integrations.ts
+++ b/src/config/definitions/options-integrations.ts
@@ -265,6 +265,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.serverUrl,
description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
},
+ {
+ path: 'jellyfin.recentServers',
+ kind: 'array',
+ defaultValue: defaultConfig.jellyfin.recentServers,
+ description: 'Recently authenticated Jellyfin server URLs shown in setup.',
+ },
{
path: 'jellyfin.username',
kind: 'string',
diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts
index bf029cc2..aed83436 100644
--- a/src/config/resolve/integrations.ts
+++ b/src/config/resolve/integrations.ts
@@ -318,6 +318,26 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string array.',
);
}
+
+ if (Array.isArray(src.jellyfin.recentServers)) {
+ const seenRecentServers = new Set();
+ resolved.jellyfin.recentServers = src.jellyfin.recentServers
+ .filter((item): item is string => typeof item === 'string')
+ .map((item) => item.trim().replace(/\/+$/, ''))
+ .filter((item) => {
+ if (!item || seenRecentServers.has(item)) return false;
+ seenRecentServers.add(item);
+ return true;
+ })
+ .slice(0, 5);
+ } else if (src.jellyfin.recentServers !== undefined) {
+ warn(
+ 'jellyfin.recentServers',
+ src.jellyfin.recentServers,
+ resolved.jellyfin.recentServers,
+ 'Expected string array.',
+ );
+ }
}
if (isObject(src.discordPresence)) {
diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts
index a1737267..13f34d52 100644
--- a/src/config/resolve/jellyfin.test.ts
+++ b/src/config/resolve/jellyfin.test.ts
@@ -17,6 +17,34 @@ test('jellyfin directPlayContainers are normalized', () => {
assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']);
});
+test('jellyfin recentServers are normalized, deduped, and capped', () => {
+ const { context } = createResolveContext({
+ jellyfin: {
+ recentServers: [
+ ' http://one.local:8096/ ',
+ '',
+ 'http://two.local:8096',
+ 'http://one.local:8096',
+ 42 as unknown as string,
+ 'http://three.local:8096',
+ 'http://four.local:8096',
+ 'http://five.local:8096',
+ 'http://six.local:8096',
+ ],
+ },
+ });
+
+ applyIntegrationConfig(context);
+
+ assert.deepEqual(context.resolved.jellyfin.recentServers, [
+ 'http://one.local:8096',
+ 'http://two.local:8096',
+ 'http://three.local:8096',
+ 'http://four.local:8096',
+ 'http://five.local:8096',
+ ]);
+});
+
test('jellyfin legacy auth keys are ignored by resolver', () => {
const { context } = createResolveContext({
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
diff --git a/src/main.ts b/src/main.ts
index a18a46b3..8c586a79 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -181,6 +181,8 @@ import {
buildJellyfinSetupFormHtml,
parseJellyfinSetupSubmissionUrl,
getConfiguredJellyfinSession,
+ mergeJellyfinRecentServers,
+ persistJellyfinAuthSession,
type ActiveJellyfinRemotePlaybackState,
} from './main/runtime/domains/jellyfin';
import {
@@ -389,6 +391,11 @@ import {
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
+import {
+ clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
+ isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
+ toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime,
+} from './main/runtime/jellyfin-tray-discovery';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
@@ -2369,6 +2376,7 @@ const {
stopJellyfinRemoteSession,
runJellyfinCommand,
openJellyfinSetupWindow,
+ getJellyfinClientInfo,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => getResolvedConfig(),
@@ -2488,11 +2496,13 @@ const {
handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: (patch) => {
configService.patchRawConfig(patch);
+ refreshTrayMenuIfPresent();
},
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo),
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
- clearStoredSession: () => jellyfinTokenStore.clearSession(),
+ clearStoredSession: () =>
+ clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
logInfo: (message) => logger.info(message),
},
handleJellyfinListCommandsMainDeps: {
@@ -2547,21 +2557,43 @@ const {
createSetupWindow: createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
}),
- buildSetupFormHtml: (defaultServer, defaultUser) =>
- buildJellyfinSetupFormHtml(defaultServer, defaultUser),
+ buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state),
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: (server, username, password, clientInfo) =>
authenticateWithPasswordRuntime(server, username, password, clientInfo),
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
+ clearStoredSession: () =>
+ clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => {
+ const clientInfo = getJellyfinClientInfo();
+ const recentServers = mergeJellyfinRecentServers(
+ session.serverUrl,
+ getResolvedConfig().jellyfin.recentServers || [],
+ );
configService.patchRawConfig({
jellyfin: {
enabled: true,
serverUrl: session.serverUrl,
username: session.username,
+ deviceId: clientInfo.deviceId,
+ clientName: clientInfo.clientName,
+ clientVersion: clientInfo.clientVersion,
+ recentServers,
},
});
+ refreshTrayMenuIfPresent();
},
+ persistAuthenticatedSession: (session, clientInfo) =>
+ persistJellyfinAuthSession({
+ session,
+ clientInfo,
+ existingRecentServers: getResolvedConfig().jellyfin.recentServers || [],
+ saveStoredSession: (storedSession) => jellyfinTokenStore.saveSession(storedSession),
+ patchRawConfig: (patch) => {
+ configService.patchRawConfig(patch);
+ refreshTrayMenuIfPresent();
+ },
+ }),
logInfo: (message) => logger.info(message),
logError: (message, error) => logger.error(message, error),
showMpvOsd: (message) => showMpvOsd(message),
@@ -2572,6 +2604,8 @@ const {
appState.jellyfinSetupWindow = window as BrowserWindow;
},
encodeURIComponent: (value) => encodeURIComponent(value),
+ defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl || 'http://127.0.0.1:8096',
+ hasStoredSession: () => Boolean(jellyfinTokenStore.loadSession()),
},
});
@@ -5134,6 +5168,26 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setMainWindow: (window) => overlayManager.setMainWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window),
});
+
+function refreshTrayMenuIfPresent(): void {
+ if (appTray) {
+ ensureTrayHandler();
+ }
+}
+
+function getJellyfinTrayDiscoveryDeps() {
+ return {
+ getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
+ getRemoteSession: () => appState.jellyfinRemoteSession,
+ clearStoredSession: () => jellyfinTokenStore.clearSession(),
+ stopRemoteSession: () => stopJellyfinRemoteSession(),
+ startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options),
+ refreshTrayMenu: () => refreshTrayMenuIfPresent(),
+ logger,
+ showMpvOsd: (message: string) => showMpvOsd(message),
+ };
+}
+
const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
createTrayRuntimeHandlers({
resolveTrayIconPathDeps: {
@@ -5158,6 +5212,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
+ isJellyfinConfigured: () =>
+ isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
+ isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
+ toggleJellyfinDiscovery: () =>
+ toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
openAnilistSetupWindow: () => openAnilistSetupWindow(),
quitApp: () => requestAppQuit(),
},
diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts
index a1fe9810..273d8691 100644
--- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts
+++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts
@@ -159,8 +159,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
isDestroyed: () => false,
close: () => {},
}) as never,
- buildSetupFormHtml: (defaultServer, defaultUser) =>
- `${defaultServer}${defaultUser}`,
+ buildSetupFormHtml: (state) => `${state.selectedServerUrl}${state.username}`,
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
@@ -169,6 +168,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
userId: 'id',
}),
saveStoredSession: () => {},
+ clearStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
@@ -176,6 +176,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
clearSetupWindow: () => {},
setSetupWindow: () => {},
encodeURIComponent,
+ defaultServerUrl: 'https://example.test',
+ hasStoredSession: () => false,
},
});
diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts
index 1ba31119..ee8d5b2b 100644
--- a/src/main/runtime/composers/jellyfin-runtime-composer.ts
+++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts
@@ -227,7 +227,7 @@ export function composeJellyfinRuntimeHandlers(
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
- startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
+ startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
})(),
);
diff --git a/src/main/runtime/jellyfin-cli-auth.test.ts b/src/main/runtime/jellyfin-cli-auth.test.ts
index 6a59c7c7..94faee83 100644
--- a/src/main/runtime/jellyfin-cli-auth.test.ts
+++ b/src/main/runtime/jellyfin-cli-auth.test.ts
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { createHandleJellyfinAuthCommands } from './jellyfin-cli-auth';
+import { createHandleJellyfinAuthCommands, persistJellyfinAuthSession } from './jellyfin-cli-auth';
test('jellyfin auth handler processes logout', async () => {
const calls: string[] = [];
@@ -70,6 +70,7 @@ test('jellyfin auth handler processes login', async () => {
jellyfinConfig: {
serverUrl: '',
username: '',
+ recentServers: ['http://localhost'],
},
serverUrl: 'http://localhost',
clientInfo: {
@@ -91,11 +92,60 @@ test('jellyfin auth handler processes login', async () => {
deviceId: 'd1',
clientName: 'SubMiner',
clientVersion: '1.0',
+ recentServers: ['http://localhost'],
},
});
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
});
+test('persistJellyfinAuthSession stores client metadata and recent servers', () => {
+ let patchPayload: unknown = null;
+ let storedSession: unknown = null;
+
+ persistJellyfinAuthSession({
+ session: {
+ serverUrl: 'http://localhost:8096',
+ username: 'alice',
+ accessToken: 'token',
+ userId: 'uid',
+ },
+ clientInfo: {
+ deviceId: 'device-1',
+ clientName: 'SubMiner',
+ clientVersion: '1.0',
+ },
+ existingRecentServers: [
+ ' http://old.example:8096/ ',
+ 'http://localhost:8096',
+ '',
+ 'http://another.example:8096',
+ ],
+ saveStoredSession: (session) => {
+ storedSession = session;
+ },
+ patchRawConfig: (patch) => {
+ patchPayload = patch;
+ },
+ });
+
+ assert.deepEqual(storedSession, { accessToken: 'token', userId: 'uid' });
+ assert.deepEqual(patchPayload, {
+ jellyfin: {
+ enabled: true,
+ serverUrl: 'http://localhost:8096',
+ username: 'alice',
+ deviceId: 'device-1',
+ clientName: 'SubMiner',
+ clientVersion: '1.0',
+ recentServers: [
+ 'http://localhost:8096',
+ 'http://old.example:8096',
+ 'http://another.example:8096',
+ ],
+ },
+ });
+});
+
test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {},
diff --git a/src/main/runtime/jellyfin-cli-auth.ts b/src/main/runtime/jellyfin-cli-auth.ts
index 5f5f8aad..9368b6fc 100644
--- a/src/main/runtime/jellyfin-cli-auth.ts
+++ b/src/main/runtime/jellyfin-cli-auth.ts
@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
type JellyfinConfig = {
serverUrl: string;
username: string;
+ recentServers?: string[];
};
type JellyfinClientInfo = {
@@ -18,6 +19,67 @@ type JellyfinSession = {
userId: string;
};
+const MAX_RECENT_JELLYFIN_SERVERS = 5;
+
+export function normalizeJellyfinServerUrl(value: string): string {
+ return value.trim().replace(/\/+$/, '');
+}
+
+export function normalizeJellyfinRecentServers(values: unknown[]): string[] {
+ const seen = new Set();
+ const servers: string[] = [];
+ for (const value of values) {
+ if (typeof value !== 'string') continue;
+ const normalized = normalizeJellyfinServerUrl(value);
+ if (!normalized || seen.has(normalized)) continue;
+ seen.add(normalized);
+ servers.push(normalized);
+ if (servers.length >= MAX_RECENT_JELLYFIN_SERVERS) break;
+ }
+ return servers;
+}
+
+export function mergeJellyfinRecentServers(serverUrl: string, existing: unknown[]): string[] {
+ return normalizeJellyfinRecentServers([serverUrl, ...existing]);
+}
+
+export function persistJellyfinAuthSession(deps: {
+ session: JellyfinSession;
+ clientInfo: JellyfinClientInfo;
+ existingRecentServers?: unknown[];
+ saveStoredSession: (session: { accessToken: string; userId: string }) => void;
+ patchRawConfig: (patch: {
+ jellyfin: Partial<{
+ enabled: boolean;
+ serverUrl: string;
+ username: string;
+ deviceId: string;
+ clientName: string;
+ clientVersion: string;
+ recentServers: string[];
+ }>;
+ }) => void;
+}): void {
+ deps.saveStoredSession({
+ accessToken: deps.session.accessToken,
+ userId: deps.session.userId,
+ });
+ deps.patchRawConfig({
+ jellyfin: {
+ enabled: true,
+ serverUrl: deps.session.serverUrl,
+ username: deps.session.username,
+ deviceId: deps.clientInfo.deviceId,
+ clientName: deps.clientInfo.clientName,
+ clientVersion: deps.clientInfo.clientVersion,
+ recentServers: mergeJellyfinRecentServers(
+ deps.session.serverUrl,
+ deps.existingRecentServers || [],
+ ),
+ },
+ });
+}
+
export function createHandleJellyfinAuthCommands(deps: {
patchRawConfig: (patch: {
jellyfin: Partial<{
@@ -66,19 +128,12 @@ export function createHandleJellyfinAuthCommands(deps: {
password,
params.clientInfo,
);
- deps.saveStoredSession({
- accessToken: session.accessToken,
- userId: session.userId,
- });
- deps.patchRawConfig({
- jellyfin: {
- enabled: true,
- serverUrl: session.serverUrl,
- username: session.username,
- deviceId: params.clientInfo.deviceId,
- clientName: params.clientInfo.clientName,
- clientVersion: params.clientInfo.clientVersion,
- },
+ persistJellyfinAuthSession({
+ session,
+ clientInfo: params.clientInfo,
+ existingRecentServers: params.jellyfinConfig.recentServers || [],
+ saveStoredSession: (storedSession) => deps.saveStoredSession(storedSession),
+ patchRawConfig: (patch) => deps.patchRawConfig(patch),
});
deps.logInfo(`Jellyfin login succeeded for ${session.username}.`);
return true;
diff --git a/src/main/runtime/jellyfin-cli-main-deps.test.ts b/src/main/runtime/jellyfin-cli-main-deps.test.ts
index 780c5a0c..ad6514d4 100644
--- a/src/main/runtime/jellyfin-cli-main-deps.test.ts
+++ b/src/main/runtime/jellyfin-cli-main-deps.test.ts
@@ -94,17 +94,17 @@ test('jellyfin remote announce main deps builder maps callbacks', async () => {
const calls: string[] = [];
const session = { advertiseNow: async () => true };
const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
- startJellyfinRemoteSession: async () => {
- calls.push('start');
+ startJellyfinRemoteSession: async (options) => {
+ calls.push(`start:${options?.explicit ? 'explicit' : 'default'}`);
},
getRemoteSession: () => session,
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
})();
- await deps.startJellyfinRemoteSession();
+ await deps.startJellyfinRemoteSession({ explicit: true });
assert.equal(deps.getRemoteSession(), session);
deps.logInfo('visible');
deps.logWarn('not-visible');
- assert.deepEqual(calls, ['start', 'info:visible', 'warn:not-visible']);
+ assert.deepEqual(calls, ['start:explicit', 'info:visible', 'warn:not-visible']);
});
diff --git a/src/main/runtime/jellyfin-cli-main-deps.ts b/src/main/runtime/jellyfin-cli-main-deps.ts
index d0e163f1..b94a9dc3 100644
--- a/src/main/runtime/jellyfin-cli-main-deps.ts
+++ b/src/main/runtime/jellyfin-cli-main-deps.ts
@@ -51,7 +51,7 @@ export function createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler(
deps: HandleJellyfinRemoteAnnounceCommandMainDeps,
) {
return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({
- startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(),
+ startJellyfinRemoteSession: (options) => deps.startJellyfinRemoteSession(options),
getRemoteSession: () => deps.getRemoteSession(),
logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string) => deps.logWarn(message),
diff --git a/src/main/runtime/jellyfin-cli-remote-announce.test.ts b/src/main/runtime/jellyfin-cli-remote-announce.test.ts
index d9bebef9..f1fa6e16 100644
--- a/src/main/runtime/jellyfin-cli-remote-announce.test.ts
+++ b/src/main/runtime/jellyfin-cli-remote-announce.test.ts
@@ -23,10 +23,10 @@ test('remote announce handler no-ops when flag is disabled', async () => {
test('remote announce handler warns when session is unavailable', async () => {
const warnings: string[] = [];
- let started = false;
+ let startOptions: { explicit?: boolean } | undefined;
const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({
- startJellyfinRemoteSession: async () => {
- started = true;
+ startJellyfinRemoteSession: async (options) => {
+ startOptions = options;
},
getRemoteSession: () => null,
logInfo: () => {},
@@ -38,7 +38,7 @@ test('remote announce handler warns when session is unavailable', async () => {
} as never);
assert.equal(handled, true);
- assert.equal(started, true);
+ assert.deepEqual(startOptions, { explicit: true });
assert.deepEqual(warnings, ['Jellyfin remote session is not available.']);
});
diff --git a/src/main/runtime/jellyfin-cli-remote-announce.ts b/src/main/runtime/jellyfin-cli-remote-announce.ts
index 5417a8e8..5a58ff2b 100644
--- a/src/main/runtime/jellyfin-cli-remote-announce.ts
+++ b/src/main/runtime/jellyfin-cli-remote-announce.ts
@@ -5,7 +5,7 @@ type JellyfinRemoteSession = {
};
export function createHandleJellyfinRemoteAnnounceCommand(deps: {
- startJellyfinRemoteSession: () => Promise;
+ startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise;
getRemoteSession: () => JellyfinRemoteSession | null;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
@@ -15,7 +15,7 @@ export function createHandleJellyfinRemoteAnnounceCommand(deps: {
return false;
}
- await deps.startJellyfinRemoteSession();
+ await deps.startJellyfinRemoteSession({ explicit: true });
const remoteSession = deps.getRemoteSession();
if (!remoteSession) {
deps.logWarn('Jellyfin remote session is not available.');
diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts
index ba82d46d..80722056 100644
--- a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts
+++ b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts
@@ -78,6 +78,37 @@ test('start handler no-ops when remote control is disabled', async () => {
assert.equal(created, false);
});
+test('start handler respects auto-connect unless explicit start is requested', async () => {
+ let created = 0;
+ const startRemote = createStartJellyfinRemoteSessionHandler({
+ getJellyfinConfig: () => createConfig({ remoteControlAutoConnect: false }),
+ getCurrentSession: () => null,
+ setCurrentSession: () => {},
+ createRemoteSessionService: () => {
+ created += 1;
+ return {
+ start: () => {},
+ stop: () => {},
+ advertiseNow: async () => true,
+ };
+ },
+ defaultDeviceId: 'default-device',
+ defaultClientName: 'SubMiner',
+ defaultClientVersion: '1.0',
+ handlePlay: async () => {},
+ handlePlaystate: async () => {},
+ handleGeneralCommand: async () => {},
+ logInfo: () => {},
+ logWarn: () => {},
+ });
+
+ await startRemote();
+ assert.equal(created, 0);
+
+ await startRemote({ explicit: true });
+ assert.equal(created, 1);
+});
+
test('start handler creates, starts, and stores session', async () => {
let storedSession: {
start: () => void;
diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts
index adc07100..df95c9a0 100644
--- a/src/main/runtime/jellyfin-remote-session-lifecycle.ts
+++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts
@@ -53,11 +53,11 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
}) {
- return async (): Promise => {
+ return async (options?: { explicit?: boolean }): Promise => {
const jellyfinConfig = deps.getJellyfinConfig();
if (jellyfinConfig.enabled === false) return;
if (jellyfinConfig.remoteControlEnabled === false) return;
- if (jellyfinConfig.remoteControlAutoConnect === false) return;
+ if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
const existing = deps.getCurrentSession();
diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts
index 0c09b396..64c009f0 100644
--- a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts
+++ b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts
@@ -4,12 +4,28 @@ import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-se
test('open jellyfin setup window main deps builder maps callbacks', async () => {
const calls: string[] = [];
+ const expectedState = {
+ servers: [],
+ selectedServerUrl: 'a',
+ username: 'b',
+ hasStoredSession: false,
+ statusMessage: '',
+ statusKind: 'idle' as const,
+ };
+ let capturedBuildState: unknown = null;
+ let capturedParseUrl = '';
const deps = createBuildOpenJellyfinSetupWindowMainDepsHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => ({}) as never,
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }),
- buildSetupFormHtml: () => '',
- parseSubmissionUrl: () => ({ server: 's', username: 'u', password: 'p' }),
+ buildSetupFormHtml: (state) => {
+ capturedBuildState = state;
+ return '';
+ },
+ parseSubmissionUrl: (rawUrl) => {
+ capturedParseUrl = rawUrl;
+ return { action: 'login', server: 's', username: 'u', password: 'p' };
+ },
authenticateWithPassword: async () => ({
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
@@ -22,13 +38,17 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
deviceId: 'dev',
}),
saveStoredSession: () => calls.push('save'),
+ clearStoredSession: () => calls.push('clear-session'),
patchJellyfinConfig: () => calls.push('patch'),
+ persistAuthenticatedSession: () => calls.push('persist'),
logInfo: (message) => calls.push(`info:${message}`),
logError: (message) => calls.push(`error:${message}`),
showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear'),
setSetupWindow: () => calls.push('set-window'),
encodeURIComponent: (value) => encodeURIComponent(value),
+ defaultServerUrl: 'http://127.0.0.1:8096',
+ hasStoredSession: () => true,
})();
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
@@ -36,12 +56,16 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
});
- assert.equal(deps.buildSetupFormHtml('a', 'b'), '');
- assert.deepEqual(deps.parseSubmissionUrl('subminer://jellyfin-setup?x=1'), {
+ assert.equal(deps.buildSetupFormHtml(expectedState), '');
+ assert.deepEqual(capturedBuildState, expectedState);
+ const setupUrl = 'subminer://jellyfin-setup?x=1';
+ assert.deepEqual(deps.parseSubmissionUrl(setupUrl), {
+ action: 'login',
server: 's',
username: 'u',
password: 'p',
});
+ assert.equal(capturedParseUrl, setupUrl);
assert.deepEqual(
await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()),
{
@@ -52,21 +76,35 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
},
);
deps.saveStoredSession({ accessToken: 'token', userId: 'uid' });
+ deps.clearStoredSession();
deps.patchJellyfinConfig({
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
});
+ deps.persistAuthenticatedSession?.(
+ {
+ serverUrl: 'http://127.0.0.1:8096',
+ username: 'alice',
+ accessToken: 'token',
+ userId: 'uid',
+ },
+ deps.getJellyfinClientInfo(),
+ );
deps.logInfo('ok');
deps.logError('bad', null);
deps.showMpvOsd('toast');
deps.clearSetupWindow();
deps.setSetupWindow({} as never);
assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
+ assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096');
+ assert.equal(deps.hasStoredSession(), true);
assert.deepEqual(calls, [
'save',
+ 'clear-session',
'patch',
+ 'persist',
'info:ok',
'error:bad',
'osd:toast',
diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.ts b/src/main/runtime/jellyfin-setup-window-main-deps.ts
index 08a2ff1a..624490c8 100644
--- a/src/main/runtime/jellyfin-setup-window-main-deps.ts
+++ b/src/main/runtime/jellyfin-setup-window-main-deps.ts
@@ -9,19 +9,24 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
createSetupWindow: () => deps.createSetupWindow(),
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
- buildSetupFormHtml: (defaultServer: string, defaultUser: string) =>
- deps.buildSetupFormHtml(defaultServer, defaultUser),
+ buildSetupFormHtml: (state) => deps.buildSetupFormHtml(state),
parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl),
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredSession: (session) => deps.saveStoredSession(session),
+ clearStoredSession: () => deps.clearStoredSession(),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
+ persistAuthenticatedSession: deps.persistAuthenticatedSession
+ ? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
+ : undefined,
logInfo: (message: string) => deps.logInfo(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
clearSetupWindow: () => deps.clearSetupWindow(),
setSetupWindow: (window) => deps.setSetupWindow(window),
encodeURIComponent: (value: string) => deps.encodeURIComponent(value),
+ defaultServerUrl: deps.defaultServerUrl,
+ hasStoredSession: () => deps.hasStoredSession(),
});
}
diff --git a/src/main/runtime/jellyfin-setup-window.test.ts b/src/main/runtime/jellyfin-setup-window.test.ts
index 77331dc5..a95adec5 100644
--- a/src/main/runtime/jellyfin-setup-window.test.ts
+++ b/src/main/runtime/jellyfin-setup-window.test.ts
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildJellyfinSetupFormHtml,
+ buildJellyfinSetupViewState,
createHandleJellyfinSetupWindowClosedHandler,
createHandleJellyfinSetupNavigationHandler,
createHandleJellyfinSetupSubmissionHandler,
@@ -12,10 +13,50 @@ import {
} from './jellyfin-setup-window';
test('buildJellyfinSetupFormHtml escapes default values', () => {
- const html = buildJellyfinSetupFormHtml('http://host/"x"', 'user"name');
+ const html = buildJellyfinSetupFormHtml({
+ servers: [
+ {
+ serverUrl: 'http://host/"x"',
+ label: 'Configured "Server"',
+ source: 'config',
+ },
+ ],
+ selectedServerUrl: 'http://host/"x"',
+ username: 'user"name',
+ hasStoredSession: true,
+ statusMessage: 'Ready "now"',
+ statusKind: 'success',
+ });
assert.ok(html.includes('http://host/"x"'));
assert.ok(html.includes('user"name'));
+ assert.ok(html.includes('Ready "now"'));
+ assert.ok(html.includes('Logout'));
assert.ok(html.includes('subminer://jellyfin-setup?'));
+ assert.equal(html.includes('params.set("password"'), false);
+});
+
+test('buildJellyfinSetupViewState composes config, recent, and default servers', () => {
+ const state = buildJellyfinSetupViewState({
+ config: {
+ serverUrl: ' http://configured:8096/ ',
+ username: 'alice',
+ recentServers: ['http://recent:8096', 'http://configured:8096', ''],
+ },
+ defaultServerUrl: 'http://127.0.0.1:8096',
+ hasStoredSession: false,
+ });
+
+ assert.deepEqual(
+ state.servers.map((server) => [server.serverUrl, server.source]),
+ [
+ ['http://configured:8096', 'config'],
+ ['http://recent:8096', 'recent'],
+ ['http://127.0.0.1:8096', 'default'],
+ ],
+ );
+ assert.equal(state.selectedServerUrl, 'http://configured:8096');
+ assert.equal(state.username, 'alice');
+ assert.equal(state.statusKind, 'idle');
});
test('maybe focus jellyfin setup window no-ops without window', () => {
@@ -28,13 +69,26 @@ test('maybe focus jellyfin setup window no-ops without window', () => {
test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => {
const parsed = parseJellyfinSetupSubmissionUrl(
- 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b',
+ 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a&password=b',
);
assert.deepEqual(parsed, {
+ action: 'login',
server: 'http://localhost',
username: 'a',
password: 'b',
});
+ assert.deepEqual(parseJellyfinSetupSubmissionUrl('subminer://jellyfin-setup?action=logout'), {
+ action: 'logout',
+ server: '',
+ username: '',
+ password: '',
+ });
+ assert.deepEqual(parseJellyfinSetupSubmissionUrl('subminer://jellyfin-setup?action=done'), {
+ action: 'done',
+ server: '',
+ username: '',
+ password: '',
+ });
assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null);
});
@@ -42,14 +96,18 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
const calls: string[] = [];
let patchPayload: unknown = null;
let savedSession: unknown = null;
+ let authPassword = '';
const handler = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
- authenticateWithPassword: async () => ({
- serverUrl: 'http://localhost',
- username: 'user',
- accessToken: 'token',
- userId: 'uid',
- }),
+ authenticateWithPassword: async (_server, _username, password) => {
+ authPassword = password;
+ return {
+ serverUrl: 'http://localhost',
+ username: 'user',
+ accessToken: 'token',
+ userId: 'uid',
+ };
+ },
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
@@ -59,6 +117,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
savedSession = session;
calls.push('save');
},
+ clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: (session) => {
patchPayload = session;
calls.push('patch');
@@ -67,13 +126,16 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'),
+ reloadSetupWindow: () => calls.push('reload'),
});
const handled = await handler(
- 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b',
+ 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a',
+ 'b',
);
assert.equal(handled, true);
- assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'close']);
+ assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'reload']);
+ assert.equal(authPassword, 'b');
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
assert.deepEqual(patchPayload, {
serverUrl: 'http://localhost',
@@ -96,18 +158,155 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
+ clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'),
+ reloadSetupWindow: (_state) => calls.push('reload'),
});
const handled = await handler(
- 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b',
+ 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a&password=b',
);
assert.equal(handled, true);
- assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials']);
+ assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials', 'reload']);
+});
+
+test('createHandleJellyfinSetupSubmissionHandler reports logout failure inline', async () => {
+ const calls: string[] = [];
+ let reloadState: unknown = null;
+ const handler = createHandleJellyfinSetupSubmissionHandler({
+ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
+ authenticateWithPassword: async () => {
+ throw new Error('should not authenticate');
+ },
+ getJellyfinClientInfo: () => ({
+ clientName: 'SubMiner',
+ clientVersion: '1.0',
+ deviceId: 'did',
+ }),
+ saveStoredSession: () => calls.push('save'),
+ clearStoredSession: () => {
+ throw new Error('logout failed');
+ },
+ patchJellyfinConfig: () => calls.push('patch'),
+ logInfo: () => calls.push('info'),
+ logError: (message) => calls.push(`error:${message}`),
+ showMpvOsd: (message) => calls.push(`osd:${message}`),
+ closeSetupWindow: () => calls.push('close'),
+ reloadSetupWindow: (state) => {
+ reloadState = state;
+ calls.push('reload');
+ },
+ });
+
+ assert.equal(await handler('subminer://jellyfin-setup?action=logout'), true);
+ assert.deepEqual(calls, [
+ 'error:Jellyfin logout failed',
+ 'osd:Jellyfin logout failed: logout failed',
+ 'reload',
+ ]);
+ assert.deepEqual(reloadState, {
+ statusMessage: 'logout failed',
+ statusKind: 'error',
+ });
+});
+
+test('createHandleJellyfinSetupSubmissionHandler ignores concurrent login submissions', async () => {
+ const calls: string[] = [];
+ type TestSession = {
+ serverUrl: string;
+ username: string;
+ accessToken: string;
+ userId: string;
+ };
+ let finishAuth: ((session: TestSession) => void) | undefined;
+ const handler = createHandleJellyfinSetupSubmissionHandler({
+ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
+ authenticateWithPassword: async () =>
+ new Promise((resolve) => {
+ finishAuth = resolve;
+ }),
+ getJellyfinClientInfo: () => ({
+ clientName: 'SubMiner',
+ clientVersion: '1.0',
+ deviceId: 'did',
+ }),
+ saveStoredSession: () => calls.push('save'),
+ clearStoredSession: () => calls.push('clear'),
+ patchJellyfinConfig: () => calls.push('patch'),
+ logInfo: () => calls.push('info'),
+ logError: () => calls.push('error'),
+ showMpvOsd: (message) => calls.push(`osd:${message}`),
+ closeSetupWindow: () => calls.push('close'),
+ reloadSetupWindow: (state) => calls.push(`reload:${state?.statusKind || 'none'}`),
+ });
+
+ const first = handler(
+ 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a',
+ 'first',
+ );
+ const second = await handler(
+ 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a',
+ 'second',
+ );
+
+ assert.equal(second, true);
+ const resolveAuth = finishAuth;
+ if (!resolveAuth) {
+ throw new Error('missing auth resolver');
+ }
+ resolveAuth({
+ serverUrl: 'http://localhost',
+ username: 'a',
+ accessToken: 'token',
+ userId: 'uid',
+ });
+ assert.equal(await first, true);
+ assert.deepEqual(calls, [
+ 'osd:Jellyfin login already in progress',
+ 'reload:loading',
+ 'save',
+ 'patch',
+ 'info',
+ 'osd:Jellyfin login success',
+ 'reload:success',
+ ]);
+});
+
+test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async () => {
+ const calls: string[] = [];
+ const handler = createHandleJellyfinSetupSubmissionHandler({
+ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
+ authenticateWithPassword: async () => {
+ throw new Error('should not authenticate');
+ },
+ getJellyfinClientInfo: () => ({
+ clientName: 'SubMiner',
+ clientVersion: '1.0',
+ deviceId: 'did',
+ }),
+ saveStoredSession: () => calls.push('save'),
+ clearStoredSession: () => calls.push('clear'),
+ patchJellyfinConfig: () => calls.push('patch'),
+ logInfo: (message) => calls.push(message),
+ logError: () => calls.push('error'),
+ showMpvOsd: (message) => calls.push(`osd:${message}`),
+ closeSetupWindow: () => calls.push('close'),
+ reloadSetupWindow: () => calls.push('reload'),
+ });
+
+ assert.equal(await handler('subminer://jellyfin-setup?action=logout'), true);
+ assert.equal(await handler('subminer://jellyfin-setup?action=done'), true);
+ assert.deepEqual(calls, [
+ 'clear',
+ 'Cleared stored Jellyfin auth session.',
+ 'osd:Jellyfin logged out',
+ 'reload',
+ 'close',
+ ]);
});
test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => {
@@ -200,7 +399,10 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
+ clearStoredSession: () => {},
encodeURIComponent: (value) => value,
+ defaultServerUrl: 'http://127.0.0.1:8096',
+ hasStoredSession: () => false,
});
handler();
@@ -224,6 +426,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
willNavigateHandler = handler;
}
},
+ executeJavaScript: async () => 'pass',
},
loadURL: (url: string) => {
calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`);
@@ -240,21 +443,29 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
- getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }),
- buildSetupFormHtml: (server, username) => `${server}|${username}`,
- parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
- authenticateWithPassword: async () => ({
+ getResolvedJellyfinConfig: () => ({
serverUrl: 'http://localhost:8096',
username: 'alice',
- accessToken: 'token',
- userId: 'uid',
+ recentServers: [],
}),
+ buildSetupFormHtml: (state) => `${state.selectedServerUrl}|${state.username}`,
+ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
+ authenticateWithPassword: async (_server, _username, password) => {
+ calls.push(`password:${password}`);
+ return {
+ serverUrl: 'http://localhost:8096',
+ username: 'alice',
+ accessToken: 'token',
+ userId: 'uid',
+ };
+ },
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
+ clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
@@ -262,6 +473,8 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
clearSetupWindow: () => calls.push('clear-window'),
setSetupWindow: () => calls.push('set-window'),
encodeURIComponent: (value) => encodeURIComponent(value),
+ defaultServerUrl: 'http://127.0.0.1:8096',
+ hasStoredSession: () => true,
});
handler();
@@ -281,15 +494,16 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
prevented = true;
},
},
- 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=alice&password=pass',
+ 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=alice',
);
- await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(prevented, true);
+ assert.ok(calls.includes('password:pass'));
assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch'));
assert.ok(calls.includes('osd:Jellyfin login success'));
- assert.ok(calls.includes('close'));
+ assert.ok(calls.includes('load:data-url'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) {
diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts
index 604bc0f7..4441491e 100644
--- a/src/main/runtime/jellyfin-setup-window.ts
+++ b/src/main/runtime/jellyfin-setup-window.ts
@@ -1,3 +1,5 @@
+import { normalizeJellyfinRecentServers } from './jellyfin-cli-auth';
+
type JellyfinSession = {
serverUrl: string;
username: string;
@@ -17,6 +19,7 @@ type FocusableWindowLike = {
type JellyfinSetupWebContentsLike = {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
+ executeJavaScript?: (code: string, userGesture?: boolean) => Promise;
};
type JellyfinSetupWindowLike = FocusableWindowLike & {
@@ -27,10 +30,43 @@ type JellyfinSetupWindowLike = FocusableWindowLike & {
close: () => void;
};
+export type JellyfinSetupAction = 'login' | 'logout' | 'done';
+
+export type JellyfinSetupServerOption = {
+ serverUrl: string;
+ label: string;
+ source: 'config' | 'recent' | 'default';
+ username?: string;
+};
+
+export type JellyfinSetupViewState = {
+ servers: JellyfinSetupServerOption[];
+ selectedServerUrl: string;
+ username: string;
+ hasStoredSession: boolean;
+ statusMessage: string;
+ statusKind: 'idle' | 'success' | 'error' | 'loading';
+};
+
+type JellyfinSetupViewOverrides = {
+ selectedServerUrl?: string;
+ username?: string;
+ statusMessage?: string;
+ statusKind?: JellyfinSetupViewState['statusKind'];
+};
+
function escapeHtmlAttr(value: string): string {
return value.replace(/"/g, '"');
}
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null;
}) {
@@ -44,55 +80,151 @@ export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
};
}
-export function buildJellyfinSetupFormHtml(defaultServer: string, defaultUser: string): string {
+export function buildJellyfinSetupViewState(input: {
+ config: {
+ serverUrl?: string | null;
+ username?: string | null;
+ recentServers?: unknown[];
+ };
+ defaultServerUrl: string;
+ hasStoredSession: boolean;
+ statusMessage?: string;
+ statusKind?: JellyfinSetupViewState['statusKind'];
+ selectedServerUrl?: string;
+ username?: string;
+}): JellyfinSetupViewState {
+ const configServer = normalizeJellyfinRecentServers([input.config.serverUrl || ''])[0] || '';
+ const recentServers = normalizeJellyfinRecentServers(input.config.recentServers || []);
+ const defaultServer = normalizeJellyfinRecentServers([input.defaultServerUrl])[0] || '';
+ const seen = new Set();
+ const servers: JellyfinSetupServerOption[] = [];
+
+ const addServer = (serverUrl: string, source: JellyfinSetupServerOption['source']) => {
+ if (!serverUrl || seen.has(serverUrl)) return;
+ seen.add(serverUrl);
+ servers.push({
+ serverUrl,
+ label:
+ source === 'config'
+ ? `${serverUrl} (configured)`
+ : source === 'default'
+ ? `${serverUrl} (default)`
+ : serverUrl,
+ source,
+ });
+ };
+
+ addServer(configServer, 'config');
+ for (const recent of recentServers) addServer(recent, 'recent');
+ addServer(defaultServer, 'default');
+
+ const selectedServerUrl =
+ normalizeJellyfinRecentServers([input.selectedServerUrl || ''])[0] ||
+ configServer ||
+ recentServers[0] ||
+ defaultServer;
+
+ return {
+ servers,
+ selectedServerUrl,
+ username: input.username ?? input.config.username ?? '',
+ hasStoredSession: input.hasStoredSession,
+ statusMessage: input.statusMessage || '',
+ statusKind: input.statusKind || 'idle',
+ };
+}
+
+export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): string {
+ const options = state.servers
+ .map(
+ (server) =>
+ `${escapeHtml(server.label)} `,
+ )
+ .join('');
+ const statusClass = `status ${state.statusKind}`;
return `
Jellyfin Setup
Jellyfin Setup
- Login info is used to fetch a token and save Jellyfin config values.
+ Choose a server, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.
`;
}
export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
+ action: JellyfinSetupAction;
server: string;
username: string;
password: string;
@@ -101,7 +233,11 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
return null;
}
const parsed = new URL(rawUrl);
+ const rawAction = parsed.searchParams.get('action') || 'login';
+ const action: JellyfinSetupAction =
+ rawAction === 'logout' || rawAction === 'done' ? rawAction : 'login';
return {
+ action,
server: parsed.searchParams.get('server') || '',
username: parsed.searchParams.get('username') || '',
password: parsed.searchParams.get('password') || '',
@@ -111,7 +247,7 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
export function createHandleJellyfinSetupSubmissionHandler(deps: {
parseSubmissionUrl: (
rawUrl: string,
- ) => { server: string; username: string; password: string } | null;
+ ) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
authenticateWithPassword: (
server: string,
username: string,
@@ -120,37 +256,95 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
) => Promise;
getJellyfinClientInfo: () => JellyfinClientInfo;
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
+ clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void;
+ persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
closeSetupWindow: () => void;
+ reloadSetupWindow: (state?: JellyfinSetupViewOverrides) => void;
}) {
- return async (rawUrl: string): Promise => {
+ let loginInFlight = false;
+
+ return async (rawUrl: string, passwordOverride?: string): Promise => {
const submission = deps.parseSubmissionUrl(rawUrl);
if (!submission) {
return false;
}
+ if (submission.action === 'done') {
+ deps.closeSetupWindow();
+ return true;
+ }
+
+ if (submission.action === 'logout') {
+ try {
+ deps.clearStoredSession();
+ deps.logInfo('Cleared stored Jellyfin auth session.');
+ deps.showMpvOsd('Jellyfin logged out');
+ deps.reloadSetupWindow({
+ statusMessage: 'Jellyfin session cleared.',
+ statusKind: 'success',
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ deps.logError('Jellyfin logout failed', error);
+ deps.showMpvOsd(`Jellyfin logout failed: ${message}`);
+ deps.reloadSetupWindow({
+ statusMessage: message,
+ statusKind: 'error',
+ });
+ }
+ return true;
+ }
+
+ if (loginInFlight) {
+ deps.showMpvOsd('Jellyfin login already in progress');
+ deps.reloadSetupWindow({
+ selectedServerUrl: submission.server,
+ username: submission.username,
+ statusMessage: 'Jellyfin login already in progress.',
+ statusKind: 'loading',
+ });
+ return true;
+ }
+
+ loginInFlight = true;
try {
+ const clientInfo = deps.getJellyfinClientInfo();
const session = await deps.authenticateWithPassword(
submission.server,
submission.username,
- submission.password,
- deps.getJellyfinClientInfo(),
+ passwordOverride ?? submission.password,
+ clientInfo,
);
- deps.saveStoredSession({
- accessToken: session.accessToken,
- userId: session.userId,
- });
- deps.patchJellyfinConfig(session);
+ if (deps.persistAuthenticatedSession) {
+ deps.persistAuthenticatedSession(session, clientInfo);
+ } else {
+ deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
+ deps.patchJellyfinConfig(session);
+ }
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
deps.showMpvOsd('Jellyfin login success');
- deps.closeSetupWindow();
+ deps.reloadSetupWindow({
+ selectedServerUrl: session.serverUrl,
+ username: session.username,
+ statusMessage: `Authenticated as ${session.username}.`,
+ statusKind: 'success',
+ });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logError('Jellyfin setup failed', error);
deps.showMpvOsd(`Jellyfin login failed: ${message}`);
+ deps.reloadSetupWindow({
+ selectedServerUrl: submission.server,
+ username: submission.username,
+ statusMessage: message,
+ statusKind: 'error',
+ });
+ } finally {
+ loginInFlight = false;
}
return true;
};
@@ -173,6 +367,27 @@ export function createHandleJellyfinSetupNavigationHandler(deps: {
};
}
+async function readJellyfinSetupPasswordFromWindow(
+ setupWindow: JellyfinSetupWindowLike,
+): Promise {
+ const executeJavaScript = setupWindow.webContents.executeJavaScript;
+ if (!executeJavaScript) {
+ return undefined;
+ }
+
+ const value = await executeJavaScript(
+ `(() => {
+ const input = document.getElementById("password");
+ const password = String(window.__subminerJellyfinPassword || input?.value || "");
+ window.__subminerJellyfinPassword = "";
+ if (input) input.value = "";
+ return password;
+ })()`,
+ true,
+ );
+ return typeof value === 'string' ? value : '';
+}
+
export function createHandleJellyfinSetupWindowClosedHandler(deps: {
clearSetupWindow: () => void;
}) {
@@ -192,11 +407,15 @@ export function createOpenJellyfinSetupWindowHandler<
>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
- getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null };
- buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string;
+ getResolvedJellyfinConfig: () => {
+ serverUrl?: string | null;
+ username?: string | null;
+ recentServers?: unknown[];
+ };
+ buildSetupFormHtml: (state: JellyfinSetupViewState) => string;
parseSubmissionUrl: (
rawUrl: string,
- ) => { server: string; username: string; password: string } | null;
+ ) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
authenticateWithPassword: (
server: string,
username: string,
@@ -205,13 +424,17 @@ export function createOpenJellyfinSetupWindowHandler<
) => Promise;
getJellyfinClientInfo: () => JellyfinClientInfo;
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
+ clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void;
+ persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
+ defaultServerUrl: string;
+ hasStoredSession: () => boolean;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
@@ -219,17 +442,30 @@ export function createOpenJellyfinSetupWindowHandler<
}
const setupWindow = deps.createSetupWindow();
- const defaults = deps.getResolvedJellyfinConfig();
- const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096';
- const defaultUser = defaults.username || '';
- const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser);
+ const loadSetupForm = (overrides: JellyfinSetupViewOverrides = {}) => {
+ const state = buildJellyfinSetupViewState({
+ config: deps.getResolvedJellyfinConfig(),
+ defaultServerUrl: deps.defaultServerUrl,
+ hasStoredSession: deps.hasStoredSession(),
+ selectedServerUrl: overrides.selectedServerUrl,
+ username: overrides.username,
+ statusMessage: overrides.statusMessage,
+ statusKind: overrides.statusKind,
+ });
+ const formHtml = deps.buildSetupFormHtml(state);
+ void setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`);
+ };
const handleSubmission = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
authenticateWithPassword: (server, username, password, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredSession: (session) => deps.saveStoredSession(session),
+ clearStoredSession: () => deps.clearStoredSession(),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
+ persistAuthenticatedSession: deps.persistAuthenticatedSession
+ ? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
+ : undefined,
logInfo: (message) => deps.logInfo(message),
logError: (message, error) => deps.logError(message, error),
showMpvOsd: (message) => deps.showMpvOsd(message),
@@ -238,10 +474,22 @@ export function createOpenJellyfinSetupWindowHandler<
setupWindow.close();
}
},
+ reloadSetupWindow: (state) => {
+ if (!setupWindow.isDestroyed()) {
+ loadSetupForm(state);
+ }
+ },
});
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
setupSchemePrefix: 'subminer://jellyfin-setup',
- handleSubmission: (rawUrl) => handleSubmission(rawUrl),
+ handleSubmission: async (rawUrl) => {
+ const submission = deps.parseSubmissionUrl(rawUrl);
+ const password =
+ submission?.action === 'login' && !submission.password
+ ? await readJellyfinSetupPasswordFromWindow(setupWindow)
+ : undefined;
+ return handleSubmission(rawUrl, password);
+ },
logError: (message, error) => deps.logError(message, error),
});
const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({
@@ -262,7 +510,7 @@ export function createOpenJellyfinSetupWindowHandler<
},
});
});
- void setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`);
+ loadSetupForm();
setupWindow.on('closed', () => {
handleWindowClosed();
});
diff --git a/src/main/runtime/jellyfin-tray-discovery.test.ts b/src/main/runtime/jellyfin-tray-discovery.test.ts
new file mode 100644
index 00000000..cd23a841
--- /dev/null
+++ b/src/main/runtime/jellyfin-tray-discovery.test.ts
@@ -0,0 +1,250 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ clearJellyfinAuthSessionAndRefreshTray,
+ isJellyfinConfiguredForTray,
+ toggleJellyfinDiscoveryFromTray,
+} from './jellyfin-tray-discovery';
+
+test('detects Jellyfin tray configuration when Jellyfin has a server URL', () => {
+ assert.equal(
+ isJellyfinConfiguredForTray({
+ getResolvedJellyfinConfig: () => ({
+ enabled: true,
+ serverUrl: 'http://server:8096',
+ accessToken: 'token',
+ userId: 'user',
+ }),
+ }),
+ true,
+ );
+
+ assert.equal(
+ isJellyfinConfiguredForTray({
+ getResolvedJellyfinConfig: () => ({
+ enabled: true,
+ serverUrl: 'http://server:8096',
+ }),
+ }),
+ true,
+ );
+
+ assert.equal(
+ isJellyfinConfiguredForTray({
+ getResolvedJellyfinConfig: () => ({
+ enabled: false,
+ serverUrl: 'http://server:8096',
+ accessToken: 'token',
+ userId: 'user',
+ }),
+ }),
+ false,
+ );
+
+ assert.equal(
+ isJellyfinConfiguredForTray({
+ getResolvedJellyfinConfig: () => ({
+ enabled: true,
+ serverUrl: '',
+ accessToken: 'token',
+ userId: 'user',
+ }),
+ }),
+ false,
+ );
+});
+
+test('clears stored auth, stops active discovery, and refreshes tray', () => {
+ const calls: string[] = [];
+
+ clearJellyfinAuthSessionAndRefreshTray({
+ clearStoredSession: () => calls.push('clear'),
+ getRemoteSession: () => ({ advertiseNow: async () => true }),
+ stopRemoteSession: () => calls.push('stop'),
+ refreshTrayMenu: () => calls.push('refresh'),
+ logger: {
+ info: (message) => calls.push(`info:${message}`),
+ warn: (message) => calls.push(`warn:${message}`),
+ error: (message) => calls.push(`error:${message}`),
+ },
+ });
+
+ assert.deepEqual(calls, ['clear', 'stop', 'refresh']);
+});
+
+test('clear auth still refreshes tray when clear or stop throws', () => {
+ const calls: string[] = [];
+
+ clearJellyfinAuthSessionAndRefreshTray({
+ clearStoredSession: () => {
+ throw new Error('clear failed');
+ },
+ getRemoteSession: () => ({ advertiseNow: async () => true }),
+ stopRemoteSession: () => {
+ throw new Error('stop failed');
+ },
+ refreshTrayMenu: () => calls.push('refresh'),
+ logger: {
+ info: (message) => calls.push(`info:${message}`),
+ warn: (message) => calls.push(`warn:${message}`),
+ error: (message) => calls.push(`error:${message}`),
+ },
+ });
+
+ assert.deepEqual(calls, [
+ 'error:Failed to clear Jellyfin auth session.',
+ 'error:Failed to stop Jellyfin discovery while clearing auth session.',
+ 'refresh',
+ ]);
+});
+
+test('starts explicit discovery and advertises cast target from tray', async () => {
+ const calls: string[] = [];
+ let session: { advertiseNow: () => Promise } | 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}`),
+ });
+
+ assert.deepEqual(calls, [
+ 'start',
+ 'advertise',
+ 'info:Jellyfin discovery started; cast target is visible in server sessions.',
+ 'osd:Jellyfin discovery started',
+ 'refresh',
+ ]);
+});
+
+test('starts explicit discovery and reports pending visibility from tray', async () => {
+ const calls: string[] = [];
+ let session: { advertiseNow: () => Promise } | 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 false;
+ },
+ };
+ },
+ 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, [
+ 'start',
+ 'advertise',
+ 'warn:Jellyfin discovery started, but cast target is not visible yet.',
+ 'osd:Jellyfin discovery started; waiting for visibility',
+ 'refresh',
+ ]);
+});
+
+test('stops active discovery from tray', async () => {
+ const calls: string[] = [];
+
+ await toggleJellyfinDiscoveryFromTray({
+ getRemoteSession: () => ({ advertiseNow: async () => 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}`),
+ });
+
+ assert.deepEqual(calls, [
+ 'stop',
+ 'info:Jellyfin discovery stopped.',
+ 'osd:Jellyfin discovery stopped',
+ 'refresh',
+ ]);
+});
+
+test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
+ const calls: string[] = [];
+
+ await toggleJellyfinDiscoveryFromTray({
+ getRemoteSession: () => null,
+ 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}`),
+ });
+
+ assert.deepEqual(calls, [
+ 'start',
+ 'warn:Jellyfin discovery could not start. Configure Jellyfin first.',
+ 'osd:Jellyfin discovery unavailable',
+ 'refresh',
+ ]);
+});
+
+test('reports discovery toggle failures and still refreshes tray', async () => {
+ const calls: string[] = [];
+ const error = new Error('boom');
+
+ await toggleJellyfinDiscoveryFromTray({
+ getRemoteSession: () => null,
+ stopRemoteSession: () => calls.push('stop'),
+ startRemoteSession: async () => {
+ throw error;
+ },
+ refreshTrayMenu: () => calls.push('refresh'),
+ logger: {
+ info: (message) => calls.push(`info:${message}`),
+ warn: (message) => calls.push(`warn:${message}`),
+ error: (message, actualError) => {
+ calls.push(`error:${message}`);
+ assert.equal(actualError, error);
+ },
+ },
+ showMpvOsd: (message) => calls.push(`osd:${message}`),
+ });
+
+ assert.deepEqual(calls, [
+ 'error:Failed to toggle Jellyfin discovery.',
+ 'osd:Jellyfin discovery failed',
+ 'refresh',
+ ]);
+});
diff --git a/src/main/runtime/jellyfin-tray-discovery.ts b/src/main/runtime/jellyfin-tray-discovery.ts
new file mode 100644
index 00000000..1611db30
--- /dev/null
+++ b/src/main/runtime/jellyfin-tray-discovery.ts
@@ -0,0 +1,101 @@
+type JellyfinTrayConfig = {
+ enabled?: boolean;
+ serverUrl?: string | null;
+ accessToken?: string | null;
+ userId?: string | null;
+};
+
+type JellyfinTrayRemoteSession = {
+ advertiseNow: () => Promise;
+};
+
+type JellyfinTrayLogger = {
+ info: (message: string) => void;
+ warn: (message: string) => void;
+ error: (message: string, error?: unknown) => void;
+};
+
+type JellyfinTrayDiscoveryDeps = {
+ getResolvedJellyfinConfig: () => JellyfinTrayConfig;
+ getRemoteSession: () => TSession | null;
+ clearStoredSession: () => void;
+ stopRemoteSession: () => void;
+ startRemoteSession: (options: { explicit: true }) => Promise;
+ refreshTrayMenu: () => void;
+ logger: JellyfinTrayLogger;
+ showMpvOsd: (message: string) => void;
+};
+
+export function isJellyfinConfiguredForTray(
+ deps: Pick, 'getResolvedJellyfinConfig'>,
+): boolean {
+ const jellyfin = deps.getResolvedJellyfinConfig();
+ return Boolean(jellyfin.enabled !== false && jellyfin.serverUrl);
+}
+
+export function clearJellyfinAuthSessionAndRefreshTray(
+ deps: Pick<
+ JellyfinTrayDiscoveryDeps,
+ 'clearStoredSession' | 'getRemoteSession' | 'stopRemoteSession' | 'refreshTrayMenu' | 'logger'
+ >,
+): void {
+ try {
+ deps.clearStoredSession();
+ } catch (error) {
+ deps.logger.error('Failed to clear Jellyfin auth session.', error);
+ }
+
+ try {
+ if (deps.getRemoteSession()) {
+ deps.stopRemoteSession();
+ }
+ } catch (error) {
+ deps.logger.error('Failed to stop Jellyfin discovery while clearing auth session.', error);
+ } finally {
+ deps.refreshTrayMenu();
+ }
+}
+
+export async function toggleJellyfinDiscoveryFromTray(
+ deps: Pick<
+ JellyfinTrayDiscoveryDeps,
+ | 'getRemoteSession'
+ | 'stopRemoteSession'
+ | 'startRemoteSession'
+ | 'refreshTrayMenu'
+ | 'logger'
+ | 'showMpvOsd'
+ >,
+): Promise {
+ try {
+ const activeSession = deps.getRemoteSession();
+ if (activeSession) {
+ deps.stopRemoteSession();
+ deps.logger.info('Jellyfin discovery stopped.');
+ deps.showMpvOsd('Jellyfin discovery stopped');
+ return;
+ }
+
+ await deps.startRemoteSession({ explicit: true });
+ const remoteSession = deps.getRemoteSession();
+ if (!remoteSession) {
+ deps.logger.warn('Jellyfin discovery could not start. Configure Jellyfin first.');
+ deps.showMpvOsd('Jellyfin discovery unavailable');
+ return;
+ }
+
+ const visible = await remoteSession.advertiseNow();
+ if (visible) {
+ deps.logger.info('Jellyfin discovery started; cast target is visible in server sessions.');
+ deps.showMpvOsd('Jellyfin discovery started');
+ } else {
+ deps.logger.warn('Jellyfin discovery started, but cast target is not visible yet.');
+ deps.showMpvOsd('Jellyfin discovery started; waiting for visibility');
+ }
+ } catch (error) {
+ deps.logger.error('Failed to toggle Jellyfin discovery.', error);
+ deps.showMpvOsd('Jellyfin discovery failed');
+ } finally {
+ deps.refreshTrayMenu();
+ }
+}
diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts
index 809a54e4..b8039656 100644
--- a/src/main/runtime/tray-main-actions.test.ts
+++ b/src/main/runtime/tray-main-actions.test.ts
@@ -48,6 +48,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openJellyfinSetup();
+ handlers.toggleJellyfinDiscovery();
handlers.openAnilistSetup();
handlers.quitApp();
return [{ label: 'ok' }] as never;
@@ -65,6 +66,11 @@ test('build tray template handler wires actions and init guards', () => {
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
+ isJellyfinConfigured: () => true,
+ isJellyfinDiscoveryActive: () => false,
+ toggleJellyfinDiscovery: async () => {
+ calls.push('jellyfin-discovery');
+ },
openAnilistSetupWindow: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
});
@@ -80,6 +86,7 @@ test('build tray template handler wires actions and init guards', () => {
'yomitan',
'runtime-options',
'jellyfin',
+ 'jellyfin-discovery',
'anilist',
'quit',
]);
diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts
index 43c587d3..abc65028 100644
--- a/src/main/runtime/tray-main-actions.ts
+++ b/src/main/runtime/tray-main-actions.ts
@@ -37,6 +37,9 @@ export function createBuildTrayMenuTemplateHandler(deps: {
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
+ showJellyfinDiscovery: boolean;
+ jellyfinDiscoveryActive: boolean;
+ toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
}) => TMenuItem[];
@@ -50,6 +53,9 @@ export function createBuildTrayMenuTemplateHandler(deps: {
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
+ isJellyfinConfigured: () => boolean;
+ isJellyfinDiscoveryActive: () => boolean;
+ toggleJellyfinDiscovery: () => void | Promise;
openAnilistSetupWindow: () => void;
quitApp: () => void;
}) {
@@ -84,6 +90,11 @@ export function createBuildTrayMenuTemplateHandler(deps: {
openJellyfinSetup: () => {
deps.openJellyfinSetupWindow();
},
+ showJellyfinDiscovery: deps.isJellyfinConfigured(),
+ jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
+ toggleJellyfinDiscovery: () => {
+ void deps.toggleJellyfinDiscovery();
+ },
openAnilistSetup: () => {
deps.openAnilistSetupWindow();
},
diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts
index d0a1b0e0..a78c7e43 100644
--- a/src/main/runtime/tray-main-deps.test.ts
+++ b/src/main/runtime/tray-main-deps.test.ts
@@ -32,6 +32,11 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
+ isJellyfinConfigured: () => true,
+ isJellyfinDiscoveryActive: () => false,
+ toggleJellyfinDiscovery: () => {
+ calls.push('jellyfin-discovery');
+ },
openAnilistSetupWindow: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
})();
@@ -46,6 +51,9 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
+ showJellyfinDiscovery: true,
+ jellyfinDiscoveryActive: false,
+ toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
openAnilistSetup: () => calls.push('open-anilist'),
quitApp: () => calls.push('quit-app'),
});
diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts
index 2ec92b5f..6d540034 100644
--- a/src/main/runtime/tray-main-deps.ts
+++ b/src/main/runtime/tray-main-deps.ts
@@ -36,6 +36,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: {
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
+ showJellyfinDiscovery: boolean;
+ jellyfinDiscoveryActive: boolean;
+ toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
}) => TMenuItem[];
@@ -49,6 +52,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: {
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
+ isJellyfinConfigured: () => boolean;
+ isJellyfinDiscoveryActive: () => boolean;
+ toggleJellyfinDiscovery: () => void | Promise;
openAnilistSetupWindow: () => void;
quitApp: () => void;
}) {
@@ -64,6 +70,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: {
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
+ isJellyfinConfigured: deps.isJellyfinConfigured,
+ isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
+ toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
openAnilistSetupWindow: deps.openAnilistSetupWindow,
quitApp: deps.quitApp,
});
diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts
index 92e342e3..592b6edd 100644
--- a/src/main/runtime/tray-runtime-handlers.test.ts
+++ b/src/main/runtime/tray-runtime-handlers.test.ts
@@ -32,6 +32,9 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},
+ isJellyfinConfigured: () => false,
+ isJellyfinDiscoveryActive: () => false,
+ toggleJellyfinDiscovery: () => {},
openAnilistSetupWindow: () => {},
quitApp: () => {},
},
diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts
index 16c46dae..8f5cae20 100644
--- a/src/main/runtime/tray-runtime.test.ts
+++ b/src/main/runtime/tray-runtime.test.ts
@@ -38,22 +38,29 @@ test('tray menu template contains expected entries and handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
+ showJellyfinDiscovery: true,
+ jellyfinDiscoveryActive: false,
+ toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
openAnilistSetup: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
});
- assert.equal(template.length, 10);
+ assert.equal(template.length, 11);
assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
);
assert.equal(template[0]!.label, 'Open Help');
+ const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
+ assert.equal(discovery?.type, 'checkbox');
+ assert.equal(discovery?.checked, false);
+ discovery?.click?.();
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
- template[8]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
- template[9]!.click?.();
- assert.deepEqual(calls, ['help', 'texthooker', 'separator', 'quit']);
+ template[9]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
+ template[10]!.click?.();
+ assert.deepEqual(calls, ['jellyfin-discovery', 'help', 'texthooker', 'separator', 'quit']);
});
test('tray menu template omits first-run setup entry when setup is complete', () => {
@@ -67,6 +74,9 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
+ showJellyfinDiscovery: false,
+ jellyfinDiscoveryActive: false,
+ toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
quitApp: () => undefined,
})
@@ -75,4 +85,28 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
assert.equal(labels.includes('Complete Setup'), false);
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
+ assert.equal(labels.includes('Jellyfin Discovery'), false);
+});
+
+test('tray menu template renders active jellyfin discovery checkbox', () => {
+ const template = buildTrayMenuTemplateRuntime({
+ openSessionHelp: () => undefined,
+ openTexthookerInBrowser: () => undefined,
+ openFirstRunSetup: () => undefined,
+ showFirstRunSetup: false,
+ openWindowsMpvLauncherSetup: () => undefined,
+ showWindowsMpvLauncherSetup: false,
+ openYomitanSettings: () => undefined,
+ openRuntimeOptions: () => undefined,
+ openJellyfinSetup: () => undefined,
+ showJellyfinDiscovery: true,
+ jellyfinDiscoveryActive: true,
+ toggleJellyfinDiscovery: () => undefined,
+ openAnilistSetup: () => undefined,
+ quitApp: () => undefined,
+ });
+
+ const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
+ assert.equal(discovery?.type, 'checkbox');
+ assert.equal(discovery?.checked, true);
});
diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts
index 115cd48c..9f87d35a 100644
--- a/src/main/runtime/tray-runtime.ts
+++ b/src/main/runtime/tray-runtime.ts
@@ -39,13 +39,18 @@ export type TrayMenuActionHandlers = {
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
+ showJellyfinDiscovery: boolean;
+ jellyfinDiscoveryActive: boolean;
+ toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
};
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
label?: string;
- type?: 'separator';
+ type?: 'separator' | 'checkbox';
+ checked?: boolean;
+ enabled?: boolean;
click?: () => void;
}> {
return [
@@ -85,6 +90,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup,
},
+ ...(handlers.showJellyfinDiscovery
+ ? [
+ {
+ label: 'Jellyfin Discovery',
+ type: 'checkbox' as const,
+ checked: handlers.jellyfinDiscoveryActive,
+ enabled: true,
+ click: handlers.toggleJellyfinDiscovery,
+ },
+ ]
+ : []),
{
label: 'Configure AniList',
click: handlers.openAnilistSetup,
diff --git a/src/types/config.ts b/src/types/config.ts
index bc1d4f86..8b585eca 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
jellyfin: {
enabled: boolean;
serverUrl: string;
+ recentServers: string[];
username: string;
deviceId: string;
clientName: string;
diff --git a/src/types/integrations.ts b/src/types/integrations.ts
index 37cfe31a..d0e18a55 100644
--- a/src/types/integrations.ts
+++ b/src/types/integrations.ts
@@ -85,6 +85,7 @@ export interface YomitanConfig {
export interface JellyfinConfig {
enabled?: boolean;
serverUrl?: string;
+ recentServers?: string[];
username?: string;
deviceId?: string;
clientName?: string;