From db30c61327953b1fa7919f1b358a4b628b27c0d5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 2 May 2026 19:56:10 -0700 Subject: [PATCH] [codex] Fix Jellyfin setup and discovery toggle (#59) --- README.md | 4 +- ...n-setup-popup-and-tray-discovery-toggle.md | 44 +++ changes/314-jellyfin-setup-discovery.md | 5 + config.example.jsonc | 1 + docs-site/configuration.md | 4 + docs-site/jellyfin-integration.md | 14 +- docs-site/public/config.example.jsonc | 1 + docs-site/usage.md | 2 + .../definitions/defaults-integrations.ts | 1 + .../definitions/options-integrations.ts | 6 + src/config/resolve/integrations.ts | 20 ++ src/config/resolve/jellyfin.test.ts | 28 ++ src/main.ts | 65 +++- .../jellyfin-runtime-composer.test.ts | 6 +- .../composers/jellyfin-runtime-composer.ts | 2 +- src/main/runtime/jellyfin-cli-auth.test.ts | 52 ++- src/main/runtime/jellyfin-cli-auth.ts | 81 ++++- .../runtime/jellyfin-cli-main-deps.test.ts | 8 +- src/main/runtime/jellyfin-cli-main-deps.ts | 2 +- .../jellyfin-cli-remote-announce.test.ts | 8 +- .../runtime/jellyfin-cli-remote-announce.ts | 4 +- .../jellyfin-remote-session-lifecycle.test.ts | 31 ++ .../jellyfin-remote-session-lifecycle.ts | 4 +- .../jellyfin-setup-window-main-deps.test.ts | 46 ++- .../jellyfin-setup-window-main-deps.ts | 9 +- .../runtime/jellyfin-setup-window.test.ts | 256 ++++++++++++-- src/main/runtime/jellyfin-setup-window.ts | 324 ++++++++++++++++-- .../runtime/jellyfin-tray-discovery.test.ts | 250 ++++++++++++++ src/main/runtime/jellyfin-tray-discovery.ts | 101 ++++++ src/main/runtime/tray-main-actions.test.ts | 7 + src/main/runtime/tray-main-actions.ts | 11 + src/main/runtime/tray-main-deps.test.ts | 8 + src/main/runtime/tray-main-deps.ts | 9 + .../runtime/tray-runtime-handlers.test.ts | 3 + src/main/runtime/tray-runtime.test.ts | 42 ++- src/main/runtime/tray-runtime.ts | 18 +- src/types/config.ts | 1 + src/types/integrations.ts | 1 + 38 files changed, 1372 insertions(+), 107 deletions(-) create mode 100644 backlog/completed/task-314 - Improve-Jellyfin-setup-popup-and-tray-discovery-toggle.md create mode 100644 changes/314-jellyfin-setup-discovery.md create mode 100644 src/main/runtime/jellyfin-tray-discovery.test.ts create mode 100644 src/main/runtime/jellyfin-tray-discovery.ts 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) => + ``, + ) + .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.

+ + - + - + - +
${escapeHtml(state.statusMessage)}
+
+ + ${ + state.hasStoredSession + ? '' + : '' + } + +
Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...
`; } 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;