[codex] Fix Jellyfin setup and discovery toggle (#59)

This commit is contained in:
2026-05-02 19:56:10 -07:00
committed by GitHub
parent 27f5b2bb58
commit db30c61327
38 changed files with 1372 additions and 107 deletions
+3 -1
View File
@@ -84,7 +84,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr>
<tr>
<td><b>Jellyfin</b></td>
<td>Browse and launch media from your Jellyfin server</td>
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr>
<tr>
<td><b>Jimaku</b></td>
@@ -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.
@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
+5
View File
@@ -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.
+1
View File
@@ -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.
+4
View File
@@ -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.
+11 -3
View File
@@ -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 ...`
+1
View File
@@ -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.
+2
View File
@@ -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.
@@ -117,6 +117,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
jellyfin: {
enabled: false,
serverUrl: '',
recentServers: [],
username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
@@ -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',
+20
View File
@@ -318,6 +318,26 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string array.',
);
}
if (Array.isArray(src.jellyfin.recentServers)) {
const seenRecentServers = new Set<string>();
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)) {
+28
View File
@@ -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,
+62 -3
View File
@@ -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(),
},
@@ -159,8 +159,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
isDestroyed: () => false,
close: () => {},
}) as never,
buildSetupFormHtml: (defaultServer, defaultUser) =>
`<html>${defaultServer}${defaultUser}</html>`,
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}${state.username}</html>`,
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,
},
});
@@ -227,7 +227,7 @@ export function composeJellyfinRuntimeHandlers(
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
})(),
);
+51 -1
View File
@@ -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: () => {},
+68 -13
View File
@@ -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<string>();
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;
@@ -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']);
});
+1 -1
View File
@@ -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),
@@ -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.']);
});
@@ -5,7 +5,7 @@ type JellyfinRemoteSession = {
};
export function createHandleJellyfinRemoteAnnounceCommand(deps: {
startJellyfinRemoteSession: () => Promise<void>;
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
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.');
@@ -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;
@@ -53,11 +53,11 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
}) {
return async (): Promise<void> => {
return async (options?: { explicit?: boolean }): Promise<void> => {
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();
@@ -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: () => '<html></html>',
parseSubmissionUrl: () => ({ server: 's', username: 'u', password: 'p' }),
buildSetupFormHtml: (state) => {
capturedBuildState = state;
return '<html></html>';
},
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'), '<html></html>');
assert.deepEqual(deps.parseSubmissionUrl('subminer://jellyfin-setup?x=1'), {
assert.equal(deps.buildSetupFormHtml(expectedState), '<html></html>');
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',
@@ -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(),
});
}
+235 -21
View File
@@ -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/&quot;x&quot;'));
assert.ok(html.includes('user&quot;name'));
assert.ok(html.includes('Ready &quot;now&quot;'));
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<TestSession>((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) => `<html>${server}|${username}</html>`,
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () => ({
getResolvedJellyfinConfig: () => ({
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
recentServers: [],
}),
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}|${state.username}</html>`,
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) {
+286 -38
View File
@@ -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<unknown>;
};
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, '&quot;');
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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<string>();
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) =>
`<option value="${escapeHtmlAttr(server.serverUrl)}"${
server.serverUrl === state.selectedServerUrl ? ' selected' : ''
}>${escapeHtml(server.label)}</option>`,
)
.join('');
const statusClass = `status ${state.statusKind}`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Jellyfin Setup</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0b1020; color: #e5e7eb; }
main { padding: 20px; }
h1 { margin: 0 0 8px; font-size: 22px; }
p { margin: 0 0 14px; color: #cbd5e1; font-size: 13px; line-height: 1.4; }
label { display: block; margin: 10px 0 4px; font-size: 13px; }
input { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #334155; border-radius: 8px; background: #111827; color: #e5e7eb; }
button { margin-top: 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; font-weight: 600; cursor: pointer; background: #2563eb; color: #f8fafc; }
.hint { margin-top: 12px; font-size: 12px; color: #94a3b8; }
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
main { padding: 22px; }
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
input, select { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.actions .primary { grid-column: 1 / -1; }
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
.status.success { color: var(--accent); }
.status.error { color: var(--danger); }
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
</style>
</head>
<body>
<main>
<h1>Jellyfin Setup</h1>
<p>Login info is used to fetch a token and save Jellyfin config values.</p>
<p>Choose a server, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.</p>
<form id="form">
<label for="serverSelect">Known servers</label>
<select id="serverSelect">${options}</select>
<label for="server">Server URL</label>
<input id="server" name="server" value="${escapeHtmlAttr(defaultServer)}" required />
<input id="server" name="server" value="${escapeHtmlAttr(state.selectedServerUrl)}" required />
<label for="username">Username</label>
<input id="username" name="username" value="${escapeHtmlAttr(defaultUser)}" required />
<input id="username" name="username" value="${escapeHtmlAttr(state.username)}" required />
<label for="password">Password</label>
<input id="password" name="password" type="password" required />
<button type="submit">Save and Login</button>
<div id="status" class="${statusClass}">${escapeHtml(state.statusMessage)}</div>
<div class="actions">
<button class="primary" type="submit">Login</button>
${
state.hasStoredSession
? '<button id="logout" class="danger" type="button">Logout</button>'
: '<span></span>'
}
<button id="done" class="secondary" type="button">Done</button>
</div>
<div class="hint">Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...</div>
</form>
</main>
<script>
const form = document.getElementById("form");
const select = document.getElementById("serverSelect");
const server = document.getElementById("server");
select?.addEventListener("change", () => {
server.value = select.value || server.value;
});
function submitAction(action) {
const params = new URLSearchParams();
params.set("action", action);
if (action === "login") {
const data = new FormData(form);
params.set("server", String(data.get("server") || ""));
params.set("username", String(data.get("username") || ""));
window.__subminerJellyfinPassword = String(data.get("password") || "");
}
window.location.href = "subminer://jellyfin-setup?" + params.toString();
}
form?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const params = new URLSearchParams();
params.set("server", String(data.get("server") || ""));
params.set("username", String(data.get("username") || ""));
params.set("password", String(data.get("password") || ""));
window.location.href = "subminer://jellyfin-setup?" + params.toString();
submitAction("login");
});
document.getElementById("logout")?.addEventListener("click", () => submitAction("logout"));
document.getElementById("done")?.addEventListener("click", () => submitAction("done"));
</script>
</body>
</html>`;
}
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<JellyfinSession>;
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<boolean> => {
let loginInFlight = false;
return async (rawUrl: string, passwordOverride?: string): Promise<boolean> => {
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<string | undefined> {
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<JellyfinSession>;
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();
});
@@ -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<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
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<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return 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',
]);
});
+101
View File
@@ -0,0 +1,101 @@
type JellyfinTrayConfig = {
enabled?: boolean;
serverUrl?: string | null;
accessToken?: string | null;
userId?: string | null;
};
type JellyfinTrayRemoteSession = {
advertiseNow: () => Promise<boolean>;
};
type JellyfinTrayLogger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: unknown) => void;
};
type JellyfinTrayDiscoveryDeps<TSession extends JellyfinTrayRemoteSession> = {
getResolvedJellyfinConfig: () => JellyfinTrayConfig;
getRemoteSession: () => TSession | null;
clearStoredSession: () => void;
stopRemoteSession: () => void;
startRemoteSession: (options: { explicit: true }) => Promise<void>;
refreshTrayMenu: () => void;
logger: JellyfinTrayLogger;
showMpvOsd: (message: string) => void;
};
export function isJellyfinConfiguredForTray(
deps: Pick<JellyfinTrayDiscoveryDeps<JellyfinTrayRemoteSession>, 'getResolvedJellyfinConfig'>,
): boolean {
const jellyfin = deps.getResolvedJellyfinConfig();
return Boolean(jellyfin.enabled !== false && jellyfin.serverUrl);
}
export function clearJellyfinAuthSessionAndRefreshTray<TSession extends JellyfinTrayRemoteSession>(
deps: Pick<
JellyfinTrayDiscoveryDeps<TSession>,
'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<TSession extends JellyfinTrayRemoteSession>(
deps: Pick<
JellyfinTrayDiscoveryDeps<TSession>,
| 'getRemoteSession'
| 'stopRemoteSession'
| 'startRemoteSession'
| 'refreshTrayMenu'
| 'logger'
| 'showMpvOsd'
>,
): Promise<void> {
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();
}
}
@@ -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',
]);
+11
View File
@@ -37,6 +37,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(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<TMenuItem>(deps: {
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void;
quitApp: () => void;
}) {
@@ -84,6 +90,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openJellyfinSetup: () => {
deps.openJellyfinSetupWindow();
},
showJellyfinDiscovery: deps.isJellyfinConfigured(),
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
toggleJellyfinDiscovery: () => {
void deps.toggleJellyfinDiscovery();
},
openAnilistSetup: () => {
deps.openAnilistSetupWindow();
},
+8
View File
@@ -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'),
});
+9
View File
@@ -36,6 +36,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(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<TMenuItem>(deps: {
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void;
quitApp: () => void;
}) {
@@ -64,6 +70,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(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,
});
@@ -32,6 +32,9 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {},
openAnilistSetupWindow: () => {},
quitApp: () => {},
},
+38 -4
View File
@@ -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);
});
+17 -1
View File
@@ -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,
+1
View File
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
jellyfin: {
enabled: boolean;
serverUrl: string;
recentServers: string[];
username: string;
deviceId: string;
clientName: string;
+1
View File
@@ -85,6 +85,7 @@ export interface YomitanConfig {
export interface JellyfinConfig {
enabled?: boolean;
serverUrl?: string;
recentServers?: string[];
username?: string;
deviceId?: string;
clientName?: string;