mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
[codex] Fix Jellyfin setup and discovery toggle (#59)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+44
@@ -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 -->
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ...`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => ({
|
||||
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>`,
|
||||
getResolvedJellyfinConfig: () => ({
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
recentServers: [],
|
||||
}),
|
||||
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}|${state.username}</html>`,
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: async () => ({
|
||||
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) {
|
||||
|
||||
@@ -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, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.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<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");
|
||||
form?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(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") || ""));
|
||||
params.set("password", String(data.get("password") || ""));
|
||||
window.__subminerJellyfinPassword = String(data.get("password") || "");
|
||||
}
|
||||
window.location.href = "subminer://jellyfin-setup?" + params.toString();
|
||||
}
|
||||
form?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
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,
|
||||
});
|
||||
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',
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
|
||||
jellyfin: {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
recentServers: string[];
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface YomitanConfig {
|
||||
export interface JellyfinConfig {
|
||||
enabled?: boolean;
|
||||
serverUrl?: string;
|
||||
recentServers?: string[];
|
||||
username?: string;
|
||||
deviceId?: string;
|
||||
clientName?: string;
|
||||
|
||||
Reference in New Issue
Block a user