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>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>Jellyfin</b></td>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>Jimaku</b></td>
|
<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.
|
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]
|
> [!NOTE]
|
||||||
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
|
> 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": {
|
"jellyfin": {
|
||||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||||
|
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
"username": "", // Default Jellyfin username used during CLI login.
|
||||||
"deviceId": "subminer", // Device id setting.
|
"deviceId": "subminer", // Device id setting.
|
||||||
"clientName": "SubMiner", // Client name setting.
|
"clientName": "SubMiner", // Client name setting.
|
||||||
|
|||||||
@@ -1157,6 +1157,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"serverUrl": "http://127.0.0.1:8096",
|
"serverUrl": "http://127.0.0.1:8096",
|
||||||
|
"recentServers": ["http://127.0.0.1:8096"],
|
||||||
"username": "",
|
"username": "",
|
||||||
"remoteControlEnabled": true,
|
"remoteControlEnabled": true,
|
||||||
"remoteControlAutoConnect": 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`) |
|
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
|
||||||
| `serverUrl` | string (URL) | Jellyfin server base URL |
|
| `serverUrl` | string (URL) | Jellyfin server base URL |
|
||||||
|
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
|
||||||
| `username` | string | Default username used by `--jellyfin-login` |
|
| `username` | string | Default username used by `--jellyfin-login` |
|
||||||
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
|
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
|
||||||
| `clientName` | string | Client name 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`.
|
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
|
||||||
|
|
||||||
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.
|
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
|
- listing libraries and media items
|
||||||
- launching item playback in the connected mpv instance
|
- launching item playback in the connected mpv instance
|
||||||
- receiving Jellyfin remote cast-to-device playback events in-app
|
- 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
|
## Requirements
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
|||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"serverUrl": "http://127.0.0.1:8096",
|
"serverUrl": "http://127.0.0.1:8096",
|
||||||
|
"recentServers": ["http://127.0.0.1:8096"],
|
||||||
"username": "your-user",
|
"username": "your-user",
|
||||||
"remoteControlEnabled": true,
|
"remoteControlEnabled": true,
|
||||||
"remoteControlAutoConnect": true,
|
"remoteControlAutoConnect": true,
|
||||||
@@ -48,6 +50,8 @@ subminer jellyfin -l \
|
|||||||
--password 'your-password'
|
--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:
|
3. List libraries:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -66,6 +70,8 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
|||||||
subminer jellyfin -d
|
subminer jellyfin -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
|
||||||
|
|
||||||
Stop discovery session/app:
|
Stop discovery session/app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -129,12 +135,13 @@ remote playback target in Jellyfin's cast-to-device menu.
|
|||||||
- `jellyfin.enabled=true`
|
- `jellyfin.enabled=true`
|
||||||
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
|
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
|
||||||
- `jellyfin.remoteControlEnabled=true` (default)
|
- `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)
|
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
|
||||||
|
|
||||||
### Behavior
|
### Behavior
|
||||||
|
|
||||||
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
|
- 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`.
|
- `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.
|
- 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.
|
- `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:
|
- Device not visible in Jellyfin cast menu:
|
||||||
- ensure SubMiner is running
|
- ensure SubMiner is running
|
||||||
- ensure session token is valid (`--jellyfin-login` again if needed)
|
- 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:
|
- Cast command received but playback does not start:
|
||||||
- verify mpv IPC can connect (`--start` flow)
|
- verify mpv IPC can connect (`--start` flow)
|
||||||
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
|
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||||
|
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
"username": "", // Default Jellyfin username used during CLI login.
|
||||||
"deviceId": "subminer", // Device id setting.
|
"deviceId": "subminer", // Device id setting.
|
||||||
"clientName": "SubMiner", // Client name 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
|
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
|
### Logging and App Mode
|
||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--log-level` controls logger verbosity.
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
jellyfin: {
|
jellyfin: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
|
recentServers: [],
|
||||||
username: '',
|
username: '',
|
||||||
deviceId: 'subminer',
|
deviceId: 'subminer',
|
||||||
clientName: 'SubMiner',
|
clientName: 'SubMiner',
|
||||||
|
|||||||
@@ -265,6 +265,12 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.jellyfin.serverUrl,
|
defaultValue: defaultConfig.jellyfin.serverUrl,
|
||||||
description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
|
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',
|
path: 'jellyfin.username',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -318,6 +318,26 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
'Expected string array.',
|
'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)) {
|
if (isObject(src.discordPresence)) {
|
||||||
|
|||||||
@@ -17,6 +17,34 @@ test('jellyfin directPlayContainers are normalized', () => {
|
|||||||
assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']);
|
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', () => {
|
test('jellyfin legacy auth keys are ignored by resolver', () => {
|
||||||
const { context } = createResolveContext({
|
const { context } = createResolveContext({
|
||||||
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
|
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
|
||||||
|
|||||||
+62
-3
@@ -181,6 +181,8 @@ import {
|
|||||||
buildJellyfinSetupFormHtml,
|
buildJellyfinSetupFormHtml,
|
||||||
parseJellyfinSetupSubmissionUrl,
|
parseJellyfinSetupSubmissionUrl,
|
||||||
getConfiguredJellyfinSession,
|
getConfiguredJellyfinSession,
|
||||||
|
mergeJellyfinRecentServers,
|
||||||
|
persistJellyfinAuthSession,
|
||||||
type ActiveJellyfinRemotePlaybackState,
|
type ActiveJellyfinRemotePlaybackState,
|
||||||
} from './main/runtime/domains/jellyfin';
|
} from './main/runtime/domains/jellyfin';
|
||||||
import {
|
import {
|
||||||
@@ -389,6 +391,11 @@ import {
|
|||||||
launchWindowsMpv,
|
launchWindowsMpv,
|
||||||
} from './main/runtime/windows-mpv-launch';
|
} from './main/runtime/windows-mpv-launch';
|
||||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||||
|
import {
|
||||||
|
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||||
|
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
|
||||||
|
toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime,
|
||||||
|
} from './main/runtime/jellyfin-tray-discovery';
|
||||||
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
||||||
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
||||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||||
@@ -2369,6 +2376,7 @@ const {
|
|||||||
stopJellyfinRemoteSession,
|
stopJellyfinRemoteSession,
|
||||||
runJellyfinCommand,
|
runJellyfinCommand,
|
||||||
openJellyfinSetupWindow,
|
openJellyfinSetupWindow,
|
||||||
|
getJellyfinClientInfo,
|
||||||
} = composeJellyfinRuntimeHandlers({
|
} = composeJellyfinRuntimeHandlers({
|
||||||
getResolvedJellyfinConfigMainDeps: {
|
getResolvedJellyfinConfigMainDeps: {
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
@@ -2488,11 +2496,13 @@ const {
|
|||||||
handleJellyfinAuthCommandsMainDeps: {
|
handleJellyfinAuthCommandsMainDeps: {
|
||||||
patchRawConfig: (patch) => {
|
patchRawConfig: (patch) => {
|
||||||
configService.patchRawConfig(patch);
|
configService.patchRawConfig(patch);
|
||||||
|
refreshTrayMenuIfPresent();
|
||||||
},
|
},
|
||||||
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
|
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
|
||||||
authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo),
|
authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo),
|
||||||
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
|
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
|
||||||
clearStoredSession: () => jellyfinTokenStore.clearSession(),
|
clearStoredSession: () =>
|
||||||
|
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
},
|
},
|
||||||
handleJellyfinListCommandsMainDeps: {
|
handleJellyfinListCommandsMainDeps: {
|
||||||
@@ -2547,21 +2557,43 @@ const {
|
|||||||
createSetupWindow: createCreateJellyfinSetupWindowHandler({
|
createSetupWindow: createCreateJellyfinSetupWindowHandler({
|
||||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||||
}),
|
}),
|
||||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state),
|
||||||
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
|
|
||||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||||
authenticateWithPassword: (server, username, password, clientInfo) =>
|
authenticateWithPassword: (server, username, password, clientInfo) =>
|
||||||
authenticateWithPasswordRuntime(server, username, password, clientInfo),
|
authenticateWithPasswordRuntime(server, username, password, clientInfo),
|
||||||
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
|
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
|
||||||
|
clearStoredSession: () =>
|
||||||
|
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||||
patchJellyfinConfig: (session) => {
|
patchJellyfinConfig: (session) => {
|
||||||
|
const clientInfo = getJellyfinClientInfo();
|
||||||
|
const recentServers = mergeJellyfinRecentServers(
|
||||||
|
session.serverUrl,
|
||||||
|
getResolvedConfig().jellyfin.recentServers || [],
|
||||||
|
);
|
||||||
configService.patchRawConfig({
|
configService.patchRawConfig({
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
serverUrl: session.serverUrl,
|
serverUrl: session.serverUrl,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
|
deviceId: clientInfo.deviceId,
|
||||||
|
clientName: clientInfo.clientName,
|
||||||
|
clientVersion: clientInfo.clientVersion,
|
||||||
|
recentServers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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),
|
logInfo: (message) => logger.info(message),
|
||||||
logError: (message, error) => logger.error(message, error),
|
logError: (message, error) => logger.error(message, error),
|
||||||
showMpvOsd: (message) => showMpvOsd(message),
|
showMpvOsd: (message) => showMpvOsd(message),
|
||||||
@@ -2572,6 +2604,8 @@ const {
|
|||||||
appState.jellyfinSetupWindow = window as BrowserWindow;
|
appState.jellyfinSetupWindow = window as BrowserWindow;
|
||||||
},
|
},
|
||||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
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),
|
setMainWindow: (window) => overlayManager.setMainWindow(window),
|
||||||
setModalWindow: (window) => overlayManager.setModalWindow(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 } =
|
const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||||
createTrayRuntimeHandlers({
|
createTrayRuntimeHandlers({
|
||||||
resolveTrayIconPathDeps: {
|
resolveTrayIconPathDeps: {
|
||||||
@@ -5158,6 +5212,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||||
|
isJellyfinConfigured: () =>
|
||||||
|
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||||
|
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
|
||||||
|
toggleJellyfinDiscovery: () =>
|
||||||
|
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
close: () => {},
|
close: () => {},
|
||||||
}) as never,
|
}) as never,
|
||||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}${state.username}</html>`,
|
||||||
`<html>${defaultServer}${defaultUser}</html>`,
|
|
||||||
parseSubmissionUrl: () => null,
|
parseSubmissionUrl: () => null,
|
||||||
authenticateWithPassword: async () => ({
|
authenticateWithPassword: async () => ({
|
||||||
serverUrl: 'https://example.test',
|
serverUrl: 'https://example.test',
|
||||||
@@ -169,6 +168,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
userId: 'id',
|
userId: 'id',
|
||||||
}),
|
}),
|
||||||
saveStoredSession: () => {},
|
saveStoredSession: () => {},
|
||||||
|
clearStoredSession: () => {},
|
||||||
patchJellyfinConfig: () => {},
|
patchJellyfinConfig: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
@@ -176,6 +176,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
clearSetupWindow: () => {},
|
clearSetupWindow: () => {},
|
||||||
setSetupWindow: () => {},
|
setSetupWindow: () => {},
|
||||||
encodeURIComponent,
|
encodeURIComponent,
|
||||||
|
defaultServerUrl: 'https://example.test',
|
||||||
|
hasStoredSession: () => false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function composeJellyfinRuntimeHandlers(
|
|||||||
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
|
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
|
||||||
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
||||||
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
|
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
|
||||||
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
|
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 () => {
|
test('jellyfin auth handler processes logout', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -70,6 +70,7 @@ test('jellyfin auth handler processes login', async () => {
|
|||||||
jellyfinConfig: {
|
jellyfinConfig: {
|
||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
username: '',
|
username: '',
|
||||||
|
recentServers: ['http://localhost'],
|
||||||
},
|
},
|
||||||
serverUrl: 'http://localhost',
|
serverUrl: 'http://localhost',
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
@@ -91,11 +92,60 @@ test('jellyfin auth handler processes login', async () => {
|
|||||||
deviceId: 'd1',
|
deviceId: 'd1',
|
||||||
clientName: 'SubMiner',
|
clientName: 'SubMiner',
|
||||||
clientVersion: '1.0',
|
clientVersion: '1.0',
|
||||||
|
recentServers: ['http://localhost'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
|
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('persistJellyfinAuthSession stores client metadata and recent servers', () => {
|
||||||
|
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 () => {
|
test('jellyfin auth handler no-ops when no auth command', async () => {
|
||||||
const handleAuth = createHandleJellyfinAuthCommands({
|
const handleAuth = createHandleJellyfinAuthCommands({
|
||||||
patchRawConfig: () => {},
|
patchRawConfig: () => {},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
|
|||||||
type JellyfinConfig = {
|
type JellyfinConfig = {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
recentServers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type JellyfinClientInfo = {
|
type JellyfinClientInfo = {
|
||||||
@@ -18,6 +19,67 @@ type JellyfinSession = {
|
|||||||
userId: string;
|
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: {
|
export function createHandleJellyfinAuthCommands(deps: {
|
||||||
patchRawConfig: (patch: {
|
patchRawConfig: (patch: {
|
||||||
jellyfin: Partial<{
|
jellyfin: Partial<{
|
||||||
@@ -66,19 +128,12 @@ export function createHandleJellyfinAuthCommands(deps: {
|
|||||||
password,
|
password,
|
||||||
params.clientInfo,
|
params.clientInfo,
|
||||||
);
|
);
|
||||||
deps.saveStoredSession({
|
persistJellyfinAuthSession({
|
||||||
accessToken: session.accessToken,
|
session,
|
||||||
userId: session.userId,
|
clientInfo: params.clientInfo,
|
||||||
});
|
existingRecentServers: params.jellyfinConfig.recentServers || [],
|
||||||
deps.patchRawConfig({
|
saveStoredSession: (storedSession) => deps.saveStoredSession(storedSession),
|
||||||
jellyfin: {
|
patchRawConfig: (patch) => deps.patchRawConfig(patch),
|
||||||
enabled: true,
|
|
||||||
serverUrl: session.serverUrl,
|
|
||||||
username: session.username,
|
|
||||||
deviceId: params.clientInfo.deviceId,
|
|
||||||
clientName: params.clientInfo.clientName,
|
|
||||||
clientVersion: params.clientInfo.clientVersion,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
deps.logInfo(`Jellyfin login succeeded for ${session.username}.`);
|
deps.logInfo(`Jellyfin login succeeded for ${session.username}.`);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -94,17 +94,17 @@ test('jellyfin remote announce main deps builder maps callbacks', async () => {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const session = { advertiseNow: async () => true };
|
const session = { advertiseNow: async () => true };
|
||||||
const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
||||||
startJellyfinRemoteSession: async () => {
|
startJellyfinRemoteSession: async (options) => {
|
||||||
calls.push('start');
|
calls.push(`start:${options?.explicit ? 'explicit' : 'default'}`);
|
||||||
},
|
},
|
||||||
getRemoteSession: () => session,
|
getRemoteSession: () => session,
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
await deps.startJellyfinRemoteSession();
|
await deps.startJellyfinRemoteSession({ explicit: true });
|
||||||
assert.equal(deps.getRemoteSession(), session);
|
assert.equal(deps.getRemoteSession(), session);
|
||||||
deps.logInfo('visible');
|
deps.logInfo('visible');
|
||||||
deps.logWarn('not-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,
|
deps: HandleJellyfinRemoteAnnounceCommandMainDeps,
|
||||||
) {
|
) {
|
||||||
return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({
|
return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({
|
||||||
startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(),
|
startJellyfinRemoteSession: (options) => deps.startJellyfinRemoteSession(options),
|
||||||
getRemoteSession: () => deps.getRemoteSession(),
|
getRemoteSession: () => deps.getRemoteSession(),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
logWarn: (message: string) => deps.logWarn(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 () => {
|
test('remote announce handler warns when session is unavailable', async () => {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let started = false;
|
let startOptions: { explicit?: boolean } | undefined;
|
||||||
const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({
|
const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({
|
||||||
startJellyfinRemoteSession: async () => {
|
startJellyfinRemoteSession: async (options) => {
|
||||||
started = true;
|
startOptions = options;
|
||||||
},
|
},
|
||||||
getRemoteSession: () => null,
|
getRemoteSession: () => null,
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
@@ -38,7 +38,7 @@ test('remote announce handler warns when session is unavailable', async () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.equal(started, true);
|
assert.deepEqual(startOptions, { explicit: true });
|
||||||
assert.deepEqual(warnings, ['Jellyfin remote session is not available.']);
|
assert.deepEqual(warnings, ['Jellyfin remote session is not available.']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ type JellyfinRemoteSession = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createHandleJellyfinRemoteAnnounceCommand(deps: {
|
export function createHandleJellyfinRemoteAnnounceCommand(deps: {
|
||||||
startJellyfinRemoteSession: () => Promise<void>;
|
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
|
||||||
getRemoteSession: () => JellyfinRemoteSession | null;
|
getRemoteSession: () => JellyfinRemoteSession | null;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
@@ -15,7 +15,7 @@ export function createHandleJellyfinRemoteAnnounceCommand(deps: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.startJellyfinRemoteSession();
|
await deps.startJellyfinRemoteSession({ explicit: true });
|
||||||
const remoteSession = deps.getRemoteSession();
|
const remoteSession = deps.getRemoteSession();
|
||||||
if (!remoteSession) {
|
if (!remoteSession) {
|
||||||
deps.logWarn('Jellyfin remote session is not available.');
|
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);
|
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 () => {
|
test('start handler creates, starts, and stores session', async () => {
|
||||||
let storedSession: {
|
let storedSession: {
|
||||||
start: () => void;
|
start: () => void;
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string, details?: unknown) => void;
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (): Promise<void> => {
|
return async (options?: { explicit?: boolean }): Promise<void> => {
|
||||||
const jellyfinConfig = deps.getJellyfinConfig();
|
const jellyfinConfig = deps.getJellyfinConfig();
|
||||||
if (jellyfinConfig.enabled === false) return;
|
if (jellyfinConfig.enabled === false) return;
|
||||||
if (jellyfinConfig.remoteControlEnabled === 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;
|
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
|
||||||
|
|
||||||
const existing = deps.getCurrentSession();
|
const existing = deps.getCurrentSession();
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-se
|
|||||||
|
|
||||||
test('open jellyfin setup window main deps builder maps callbacks', async () => {
|
test('open jellyfin setup window main deps builder maps callbacks', async () => {
|
||||||
const calls: string[] = [];
|
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({
|
const deps = createBuildOpenJellyfinSetupWindowMainDepsHandler({
|
||||||
maybeFocusExistingSetupWindow: () => false,
|
maybeFocusExistingSetupWindow: () => false,
|
||||||
createSetupWindow: () => ({}) as never,
|
createSetupWindow: () => ({}) as never,
|
||||||
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }),
|
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }),
|
||||||
buildSetupFormHtml: () => '<html></html>',
|
buildSetupFormHtml: (state) => {
|
||||||
parseSubmissionUrl: () => ({ server: 's', username: 'u', password: 'p' }),
|
capturedBuildState = state;
|
||||||
|
return '<html></html>';
|
||||||
|
},
|
||||||
|
parseSubmissionUrl: (rawUrl) => {
|
||||||
|
capturedParseUrl = rawUrl;
|
||||||
|
return { action: 'login', server: 's', username: 'u', password: 'p' };
|
||||||
|
},
|
||||||
authenticateWithPassword: async () => ({
|
authenticateWithPassword: async () => ({
|
||||||
serverUrl: 'http://127.0.0.1:8096',
|
serverUrl: 'http://127.0.0.1:8096',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
@@ -22,13 +38,17 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
|||||||
deviceId: 'dev',
|
deviceId: 'dev',
|
||||||
}),
|
}),
|
||||||
saveStoredSession: () => calls.push('save'),
|
saveStoredSession: () => calls.push('save'),
|
||||||
|
clearStoredSession: () => calls.push('clear-session'),
|
||||||
patchJellyfinConfig: () => calls.push('patch'),
|
patchJellyfinConfig: () => calls.push('patch'),
|
||||||
|
persistAuthenticatedSession: () => calls.push('persist'),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
logError: (message) => calls.push(`error:${message}`),
|
logError: (message) => calls.push(`error:${message}`),
|
||||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
clearSetupWindow: () => calls.push('clear'),
|
clearSetupWindow: () => calls.push('clear'),
|
||||||
setSetupWindow: () => calls.push('set-window'),
|
setSetupWindow: () => calls.push('set-window'),
|
||||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||||
|
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||||
|
hasStoredSession: () => true,
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
|
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',
|
serverUrl: 'http://127.0.0.1:8096',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
});
|
});
|
||||||
assert.equal(deps.buildSetupFormHtml('a', 'b'), '<html></html>');
|
assert.equal(deps.buildSetupFormHtml(expectedState), '<html></html>');
|
||||||
assert.deepEqual(deps.parseSubmissionUrl('subminer://jellyfin-setup?x=1'), {
|
assert.deepEqual(capturedBuildState, expectedState);
|
||||||
|
const setupUrl = 'subminer://jellyfin-setup?x=1';
|
||||||
|
assert.deepEqual(deps.parseSubmissionUrl(setupUrl), {
|
||||||
|
action: 'login',
|
||||||
server: 's',
|
server: 's',
|
||||||
username: 'u',
|
username: 'u',
|
||||||
password: 'p',
|
password: 'p',
|
||||||
});
|
});
|
||||||
|
assert.equal(capturedParseUrl, setupUrl);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()),
|
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.saveStoredSession({ accessToken: 'token', userId: 'uid' });
|
||||||
|
deps.clearStoredSession();
|
||||||
deps.patchJellyfinConfig({
|
deps.patchJellyfinConfig({
|
||||||
serverUrl: 'http://127.0.0.1:8096',
|
serverUrl: 'http://127.0.0.1:8096',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
accessToken: 'token',
|
accessToken: 'token',
|
||||||
userId: 'uid',
|
userId: 'uid',
|
||||||
});
|
});
|
||||||
|
deps.persistAuthenticatedSession?.(
|
||||||
|
{
|
||||||
|
serverUrl: 'http://127.0.0.1:8096',
|
||||||
|
username: 'alice',
|
||||||
|
accessToken: 'token',
|
||||||
|
userId: 'uid',
|
||||||
|
},
|
||||||
|
deps.getJellyfinClientInfo(),
|
||||||
|
);
|
||||||
deps.logInfo('ok');
|
deps.logInfo('ok');
|
||||||
deps.logError('bad', null);
|
deps.logError('bad', null);
|
||||||
deps.showMpvOsd('toast');
|
deps.showMpvOsd('toast');
|
||||||
deps.clearSetupWindow();
|
deps.clearSetupWindow();
|
||||||
deps.setSetupWindow({} as never);
|
deps.setSetupWindow({} as never);
|
||||||
assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
|
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, [
|
assert.deepEqual(calls, [
|
||||||
'save',
|
'save',
|
||||||
|
'clear-session',
|
||||||
'patch',
|
'patch',
|
||||||
|
'persist',
|
||||||
'info:ok',
|
'info:ok',
|
||||||
'error:bad',
|
'error:bad',
|
||||||
'osd:toast',
|
'osd:toast',
|
||||||
|
|||||||
@@ -9,19 +9,24 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
|
|||||||
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
|
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
|
||||||
createSetupWindow: () => deps.createSetupWindow(),
|
createSetupWindow: () => deps.createSetupWindow(),
|
||||||
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
||||||
buildSetupFormHtml: (defaultServer: string, defaultUser: string) =>
|
buildSetupFormHtml: (state) => deps.buildSetupFormHtml(state),
|
||||||
deps.buildSetupFormHtml(defaultServer, defaultUser),
|
|
||||||
parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl),
|
parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl),
|
||||||
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
|
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
|
||||||
deps.authenticateWithPassword(server, username, password, clientInfo),
|
deps.authenticateWithPassword(server, username, password, clientInfo),
|
||||||
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
||||||
saveStoredSession: (session) => deps.saveStoredSession(session),
|
saveStoredSession: (session) => deps.saveStoredSession(session),
|
||||||
|
clearStoredSession: () => deps.clearStoredSession(),
|
||||||
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
||||||
|
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||||
|
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||||
|
: undefined,
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||||
setSetupWindow: (window) => deps.setSetupWindow(window),
|
setSetupWindow: (window) => deps.setSetupWindow(window),
|
||||||
encodeURIComponent: (value: string) => deps.encodeURIComponent(value),
|
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 assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
buildJellyfinSetupFormHtml,
|
buildJellyfinSetupFormHtml,
|
||||||
|
buildJellyfinSetupViewState,
|
||||||
createHandleJellyfinSetupWindowClosedHandler,
|
createHandleJellyfinSetupWindowClosedHandler,
|
||||||
createHandleJellyfinSetupNavigationHandler,
|
createHandleJellyfinSetupNavigationHandler,
|
||||||
createHandleJellyfinSetupSubmissionHandler,
|
createHandleJellyfinSetupSubmissionHandler,
|
||||||
@@ -12,10 +13,50 @@ import {
|
|||||||
} from './jellyfin-setup-window';
|
} from './jellyfin-setup-window';
|
||||||
|
|
||||||
test('buildJellyfinSetupFormHtml escapes default values', () => {
|
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('http://host/"x"'));
|
||||||
assert.ok(html.includes('user"name'));
|
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.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', () => {
|
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', () => {
|
test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => {
|
||||||
const parsed = parseJellyfinSetupSubmissionUrl(
|
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, {
|
assert.deepEqual(parsed, {
|
||||||
|
action: 'login',
|
||||||
server: 'http://localhost',
|
server: 'http://localhost',
|
||||||
username: 'a',
|
username: 'a',
|
||||||
password: 'b',
|
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);
|
assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,14 +96,18 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let patchPayload: unknown = null;
|
let patchPayload: unknown = null;
|
||||||
let savedSession: unknown = null;
|
let savedSession: unknown = null;
|
||||||
|
let authPassword = '';
|
||||||
const handler = createHandleJellyfinSetupSubmissionHandler({
|
const handler = createHandleJellyfinSetupSubmissionHandler({
|
||||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||||
authenticateWithPassword: async () => ({
|
authenticateWithPassword: async (_server, _username, password) => {
|
||||||
|
authPassword = password;
|
||||||
|
return {
|
||||||
serverUrl: 'http://localhost',
|
serverUrl: 'http://localhost',
|
||||||
username: 'user',
|
username: 'user',
|
||||||
accessToken: 'token',
|
accessToken: 'token',
|
||||||
userId: 'uid',
|
userId: 'uid',
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
getJellyfinClientInfo: () => ({
|
getJellyfinClientInfo: () => ({
|
||||||
clientName: 'SubMiner',
|
clientName: 'SubMiner',
|
||||||
clientVersion: '1.0',
|
clientVersion: '1.0',
|
||||||
@@ -59,6 +117,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
|||||||
savedSession = session;
|
savedSession = session;
|
||||||
calls.push('save');
|
calls.push('save');
|
||||||
},
|
},
|
||||||
|
clearStoredSession: () => calls.push('clear'),
|
||||||
patchJellyfinConfig: (session) => {
|
patchJellyfinConfig: (session) => {
|
||||||
patchPayload = session;
|
patchPayload = session;
|
||||||
calls.push('patch');
|
calls.push('patch');
|
||||||
@@ -67,13 +126,16 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
|||||||
logError: () => calls.push('error'),
|
logError: () => calls.push('error'),
|
||||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
closeSetupWindow: () => calls.push('close'),
|
closeSetupWindow: () => calls.push('close'),
|
||||||
|
reloadSetupWindow: () => calls.push('reload'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handled = await handler(
|
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.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(savedSession, { accessToken: 'token', userId: 'uid' });
|
||||||
assert.deepEqual(patchPayload, {
|
assert.deepEqual(patchPayload, {
|
||||||
serverUrl: 'http://localhost',
|
serverUrl: 'http://localhost',
|
||||||
@@ -96,18 +158,155 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async
|
|||||||
deviceId: 'did',
|
deviceId: 'did',
|
||||||
}),
|
}),
|
||||||
saveStoredSession: () => calls.push('save'),
|
saveStoredSession: () => calls.push('save'),
|
||||||
|
clearStoredSession: () => calls.push('clear'),
|
||||||
patchJellyfinConfig: () => calls.push('patch'),
|
patchJellyfinConfig: () => calls.push('patch'),
|
||||||
logInfo: () => calls.push('info'),
|
logInfo: () => calls.push('info'),
|
||||||
logError: () => calls.push('error'),
|
logError: () => calls.push('error'),
|
||||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
closeSetupWindow: () => calls.push('close'),
|
closeSetupWindow: () => calls.push('close'),
|
||||||
|
reloadSetupWindow: (_state) => calls.push('reload'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handled = await handler(
|
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.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', () => {
|
test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => {
|
||||||
@@ -200,7 +399,10 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
|
|||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
clearSetupWindow: () => {},
|
clearSetupWindow: () => {},
|
||||||
setSetupWindow: () => {},
|
setSetupWindow: () => {},
|
||||||
|
clearStoredSession: () => {},
|
||||||
encodeURIComponent: (value) => value,
|
encodeURIComponent: (value) => value,
|
||||||
|
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||||
|
hasStoredSession: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
handler();
|
handler();
|
||||||
@@ -224,6 +426,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
|
|||||||
willNavigateHandler = handler;
|
willNavigateHandler = handler;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
executeJavaScript: async () => 'pass',
|
||||||
},
|
},
|
||||||
loadURL: (url: string) => {
|
loadURL: (url: string) => {
|
||||||
calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`);
|
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({
|
const handler = createOpenJellyfinSetupWindowHandler({
|
||||||
maybeFocusExistingSetupWindow: () => false,
|
maybeFocusExistingSetupWindow: () => false,
|
||||||
createSetupWindow: () => fakeWindow,
|
createSetupWindow: () => fakeWindow,
|
||||||
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }),
|
getResolvedJellyfinConfig: () => ({
|
||||||
buildSetupFormHtml: (server, username) => `<html>${server}|${username}</html>`,
|
serverUrl: 'http://localhost:8096',
|
||||||
|
username: 'alice',
|
||||||
|
recentServers: [],
|
||||||
|
}),
|
||||||
|
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}|${state.username}</html>`,
|
||||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||||
authenticateWithPassword: async () => ({
|
authenticateWithPassword: async (_server, _username, password) => {
|
||||||
|
calls.push(`password:${password}`);
|
||||||
|
return {
|
||||||
serverUrl: 'http://localhost:8096',
|
serverUrl: 'http://localhost:8096',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
accessToken: 'token',
|
accessToken: 'token',
|
||||||
userId: 'uid',
|
userId: 'uid',
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
getJellyfinClientInfo: () => ({
|
getJellyfinClientInfo: () => ({
|
||||||
clientName: 'SubMiner',
|
clientName: 'SubMiner',
|
||||||
clientVersion: '1.0',
|
clientVersion: '1.0',
|
||||||
deviceId: 'did',
|
deviceId: 'did',
|
||||||
}),
|
}),
|
||||||
saveStoredSession: () => calls.push('save'),
|
saveStoredSession: () => calls.push('save'),
|
||||||
|
clearStoredSession: () => calls.push('clear'),
|
||||||
patchJellyfinConfig: () => calls.push('patch'),
|
patchJellyfinConfig: () => calls.push('patch'),
|
||||||
logInfo: () => calls.push('info'),
|
logInfo: () => calls.push('info'),
|
||||||
logError: () => calls.push('error'),
|
logError: () => calls.push('error'),
|
||||||
@@ -262,6 +473,8 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
|
|||||||
clearSetupWindow: () => calls.push('clear-window'),
|
clearSetupWindow: () => calls.push('clear-window'),
|
||||||
setSetupWindow: () => calls.push('set-window'),
|
setSetupWindow: () => calls.push('set-window'),
|
||||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||||
|
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||||
|
hasStoredSession: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
handler();
|
handler();
|
||||||
@@ -281,15 +494,16 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
|
|||||||
prevented = true;
|
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.equal(prevented, true);
|
||||||
|
assert.ok(calls.includes('password:pass'));
|
||||||
assert.ok(calls.includes('save'));
|
assert.ok(calls.includes('save'));
|
||||||
assert.ok(calls.includes('patch'));
|
assert.ok(calls.includes('patch'));
|
||||||
assert.ok(calls.includes('osd:Jellyfin login success'));
|
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;
|
const onClosed = closedHandler as (() => void) | null;
|
||||||
if (!onClosed) {
|
if (!onClosed) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeJellyfinRecentServers } from './jellyfin-cli-auth';
|
||||||
|
|
||||||
type JellyfinSession = {
|
type JellyfinSession = {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -17,6 +19,7 @@ type FocusableWindowLike = {
|
|||||||
|
|
||||||
type JellyfinSetupWebContentsLike = {
|
type JellyfinSetupWebContentsLike = {
|
||||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||||
|
executeJavaScript?: (code: string, userGesture?: boolean) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JellyfinSetupWindowLike = FocusableWindowLike & {
|
type JellyfinSetupWindowLike = FocusableWindowLike & {
|
||||||
@@ -27,10 +30,43 @@ type JellyfinSetupWindowLike = FocusableWindowLike & {
|
|||||||
close: () => void;
|
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 {
|
function escapeHtmlAttr(value: string): string {
|
||||||
return value.replace(/"/g, '"');
|
return value.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
|
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
|
||||||
getSetupWindow: () => FocusableWindowLike | null;
|
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>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Jellyfin Setup</title>
|
<title>Jellyfin Setup</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0b1020; color: #e5e7eb; }
|
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
||||||
main { padding: 20px; }
|
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
||||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
main { padding: 22px; }
|
||||||
p { margin: 0 0 14px; color: #cbd5e1; font-size: 13px; line-height: 1.4; }
|
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
||||||
label { display: block; margin: 10px 0 4px; font-size: 13px; }
|
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||||
input { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #334155; border-radius: 8px; background: #111827; color: #e5e7eb; }
|
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
button { margin-top: 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; font-weight: 600; cursor: pointer; background: #2563eb; color: #f8fafc; }
|
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; }
|
||||||
.hint { margin-top: 12px; font-size: 12px; color: #94a3b8; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Jellyfin Setup</h1>
|
<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">
|
<form id="form">
|
||||||
|
<label for="serverSelect">Known servers</label>
|
||||||
|
<select id="serverSelect">${options}</select>
|
||||||
<label for="server">Server URL</label>
|
<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>
|
<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>
|
<label for="password">Password</label>
|
||||||
<input id="password" name="password" type="password" required />
|
<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>
|
<div class="hint">Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const form = document.getElementById("form");
|
const form = document.getElementById("form");
|
||||||
form?.addEventListener("submit", (event) => {
|
const select = document.getElementById("serverSelect");
|
||||||
event.preventDefault();
|
const server = document.getElementById("server");
|
||||||
const data = new FormData(form);
|
select?.addEventListener("change", () => {
|
||||||
|
server.value = select.value || server.value;
|
||||||
|
});
|
||||||
|
function submitAction(action) {
|
||||||
const params = new URLSearchParams();
|
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("server", String(data.get("server") || ""));
|
||||||
params.set("username", String(data.get("username") || ""));
|
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();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
||||||
|
action: JellyfinSetupAction;
|
||||||
server: string;
|
server: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -101,7 +233,11 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = new URL(rawUrl);
|
const parsed = new URL(rawUrl);
|
||||||
|
const rawAction = parsed.searchParams.get('action') || 'login';
|
||||||
|
const action: JellyfinSetupAction =
|
||||||
|
rawAction === 'logout' || rawAction === 'done' ? rawAction : 'login';
|
||||||
return {
|
return {
|
||||||
|
action,
|
||||||
server: parsed.searchParams.get('server') || '',
|
server: parsed.searchParams.get('server') || '',
|
||||||
username: parsed.searchParams.get('username') || '',
|
username: parsed.searchParams.get('username') || '',
|
||||||
password: parsed.searchParams.get('password') || '',
|
password: parsed.searchParams.get('password') || '',
|
||||||
@@ -111,7 +247,7 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
|||||||
export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||||
parseSubmissionUrl: (
|
parseSubmissionUrl: (
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
) => { server: string; username: string; password: string } | null;
|
) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
|
||||||
authenticateWithPassword: (
|
authenticateWithPassword: (
|
||||||
server: string,
|
server: string,
|
||||||
username: string,
|
username: string,
|
||||||
@@ -120,37 +256,95 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
|||||||
) => Promise<JellyfinSession>;
|
) => Promise<JellyfinSession>;
|
||||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||||
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
||||||
|
clearStoredSession: () => void;
|
||||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||||
|
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => void;
|
||||||
closeSetupWindow: () => 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);
|
const submission = deps.parseSubmissionUrl(rawUrl);
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submission.action === 'done') {
|
||||||
|
deps.closeSetupWindow();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submission.action === 'logout') {
|
||||||
try {
|
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(
|
const session = await deps.authenticateWithPassword(
|
||||||
submission.server,
|
submission.server,
|
||||||
submission.username,
|
submission.username,
|
||||||
submission.password,
|
passwordOverride ?? submission.password,
|
||||||
deps.getJellyfinClientInfo(),
|
clientInfo,
|
||||||
);
|
);
|
||||||
deps.saveStoredSession({
|
if (deps.persistAuthenticatedSession) {
|
||||||
accessToken: session.accessToken,
|
deps.persistAuthenticatedSession(session, clientInfo);
|
||||||
userId: session.userId,
|
} else {
|
||||||
});
|
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
|
||||||
deps.patchJellyfinConfig(session);
|
deps.patchJellyfinConfig(session);
|
||||||
|
}
|
||||||
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
||||||
deps.showMpvOsd('Jellyfin login success');
|
deps.showMpvOsd('Jellyfin login success');
|
||||||
deps.closeSetupWindow();
|
deps.reloadSetupWindow({
|
||||||
|
selectedServerUrl: session.serverUrl,
|
||||||
|
username: session.username,
|
||||||
|
statusMessage: `Authenticated as ${session.username}.`,
|
||||||
|
statusKind: 'success',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
deps.logError('Jellyfin setup failed', error);
|
deps.logError('Jellyfin setup failed', error);
|
||||||
deps.showMpvOsd(`Jellyfin login failed: ${message}`);
|
deps.showMpvOsd(`Jellyfin login failed: ${message}`);
|
||||||
|
deps.reloadSetupWindow({
|
||||||
|
selectedServerUrl: submission.server,
|
||||||
|
username: submission.username,
|
||||||
|
statusMessage: message,
|
||||||
|
statusKind: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loginInFlight = false;
|
||||||
}
|
}
|
||||||
return true;
|
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: {
|
export function createHandleJellyfinSetupWindowClosedHandler(deps: {
|
||||||
clearSetupWindow: () => void;
|
clearSetupWindow: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -192,11 +407,15 @@ export function createOpenJellyfinSetupWindowHandler<
|
|||||||
>(deps: {
|
>(deps: {
|
||||||
maybeFocusExistingSetupWindow: () => boolean;
|
maybeFocusExistingSetupWindow: () => boolean;
|
||||||
createSetupWindow: () => TWindow;
|
createSetupWindow: () => TWindow;
|
||||||
getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null };
|
getResolvedJellyfinConfig: () => {
|
||||||
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string;
|
serverUrl?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
recentServers?: unknown[];
|
||||||
|
};
|
||||||
|
buildSetupFormHtml: (state: JellyfinSetupViewState) => string;
|
||||||
parseSubmissionUrl: (
|
parseSubmissionUrl: (
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
) => { server: string; username: string; password: string } | null;
|
) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
|
||||||
authenticateWithPassword: (
|
authenticateWithPassword: (
|
||||||
server: string,
|
server: string,
|
||||||
username: string,
|
username: string,
|
||||||
@@ -205,13 +424,17 @@ export function createOpenJellyfinSetupWindowHandler<
|
|||||||
) => Promise<JellyfinSession>;
|
) => Promise<JellyfinSession>;
|
||||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||||
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
||||||
|
clearStoredSession: () => void;
|
||||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||||
|
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => void;
|
||||||
clearSetupWindow: () => void;
|
clearSetupWindow: () => void;
|
||||||
setSetupWindow: (window: TWindow) => void;
|
setSetupWindow: (window: TWindow) => void;
|
||||||
encodeURIComponent: (value: string) => string;
|
encodeURIComponent: (value: string) => string;
|
||||||
|
defaultServerUrl: string;
|
||||||
|
hasStoredSession: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
return (): void => {
|
return (): void => {
|
||||||
if (deps.maybeFocusExistingSetupWindow()) {
|
if (deps.maybeFocusExistingSetupWindow()) {
|
||||||
@@ -219,17 +442,30 @@ export function createOpenJellyfinSetupWindowHandler<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setupWindow = deps.createSetupWindow();
|
const setupWindow = deps.createSetupWindow();
|
||||||
const defaults = deps.getResolvedJellyfinConfig();
|
const loadSetupForm = (overrides: JellyfinSetupViewOverrides = {}) => {
|
||||||
const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096';
|
const state = buildJellyfinSetupViewState({
|
||||||
const defaultUser = defaults.username || '';
|
config: deps.getResolvedJellyfinConfig(),
|
||||||
const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser);
|
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({
|
const handleSubmission = createHandleJellyfinSetupSubmissionHandler({
|
||||||
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
|
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
|
||||||
authenticateWithPassword: (server, username, password, clientInfo) =>
|
authenticateWithPassword: (server, username, password, clientInfo) =>
|
||||||
deps.authenticateWithPassword(server, username, password, clientInfo),
|
deps.authenticateWithPassword(server, username, password, clientInfo),
|
||||||
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
||||||
saveStoredSession: (session) => deps.saveStoredSession(session),
|
saveStoredSession: (session) => deps.saveStoredSession(session),
|
||||||
|
clearStoredSession: () => deps.clearStoredSession(),
|
||||||
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
||||||
|
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||||
|
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||||
|
: undefined,
|
||||||
logInfo: (message) => deps.logInfo(message),
|
logInfo: (message) => deps.logInfo(message),
|
||||||
logError: (message, error) => deps.logError(message, error),
|
logError: (message, error) => deps.logError(message, error),
|
||||||
showMpvOsd: (message) => deps.showMpvOsd(message),
|
showMpvOsd: (message) => deps.showMpvOsd(message),
|
||||||
@@ -238,10 +474,22 @@ export function createOpenJellyfinSetupWindowHandler<
|
|||||||
setupWindow.close();
|
setupWindow.close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
reloadSetupWindow: (state) => {
|
||||||
|
if (!setupWindow.isDestroyed()) {
|
||||||
|
loadSetupForm(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
|
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
|
||||||
setupSchemePrefix: 'subminer://jellyfin-setup',
|
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),
|
logError: (message, error) => deps.logError(message, error),
|
||||||
});
|
});
|
||||||
const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({
|
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', () => {
|
setupWindow.on('closed', () => {
|
||||||
handleWindowClosed();
|
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.openYomitanSettings();
|
||||||
handlers.openRuntimeOptions();
|
handlers.openRuntimeOptions();
|
||||||
handlers.openJellyfinSetup();
|
handlers.openJellyfinSetup();
|
||||||
|
handlers.toggleJellyfinDiscovery();
|
||||||
handlers.openAnilistSetup();
|
handlers.openAnilistSetup();
|
||||||
handlers.quitApp();
|
handlers.quitApp();
|
||||||
return [{ label: 'ok' }] as never;
|
return [{ label: 'ok' }] as never;
|
||||||
@@ -65,6 +66,11 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
|
isJellyfinConfigured: () => true,
|
||||||
|
isJellyfinDiscoveryActive: () => false,
|
||||||
|
toggleJellyfinDiscovery: async () => {
|
||||||
|
calls.push('jellyfin-discovery');
|
||||||
|
},
|
||||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
});
|
});
|
||||||
@@ -80,6 +86,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
'yomitan',
|
'yomitan',
|
||||||
'runtime-options',
|
'runtime-options',
|
||||||
'jellyfin',
|
'jellyfin',
|
||||||
|
'jellyfin-discovery',
|
||||||
'anilist',
|
'anilist',
|
||||||
'quit',
|
'quit',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openRuntimeOptions: () => void;
|
openRuntimeOptions: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
|
showJellyfinDiscovery: boolean;
|
||||||
|
jellyfinDiscoveryActive: boolean;
|
||||||
|
toggleJellyfinDiscovery: () => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
}) => TMenuItem[];
|
}) => TMenuItem[];
|
||||||
@@ -50,6 +53,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
|
isJellyfinConfigured: () => boolean;
|
||||||
|
isJellyfinDiscoveryActive: () => boolean;
|
||||||
|
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||||
openAnilistSetupWindow: () => void;
|
openAnilistSetupWindow: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -84,6 +90,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
openJellyfinSetup: () => {
|
openJellyfinSetup: () => {
|
||||||
deps.openJellyfinSetupWindow();
|
deps.openJellyfinSetupWindow();
|
||||||
},
|
},
|
||||||
|
showJellyfinDiscovery: deps.isJellyfinConfigured(),
|
||||||
|
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
|
||||||
|
toggleJellyfinDiscovery: () => {
|
||||||
|
void deps.toggleJellyfinDiscovery();
|
||||||
|
},
|
||||||
openAnilistSetup: () => {
|
openAnilistSetup: () => {
|
||||||
deps.openAnilistSetupWindow();
|
deps.openAnilistSetupWindow();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||||
|
isJellyfinConfigured: () => true,
|
||||||
|
isJellyfinDiscoveryActive: () => false,
|
||||||
|
toggleJellyfinDiscovery: () => {
|
||||||
|
calls.push('jellyfin-discovery');
|
||||||
|
},
|
||||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
})();
|
})();
|
||||||
@@ -46,6 +51,9 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||||
|
showJellyfinDiscovery: true,
|
||||||
|
jellyfinDiscoveryActive: false,
|
||||||
|
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
|
||||||
openAnilistSetup: () => calls.push('open-anilist'),
|
openAnilistSetup: () => calls.push('open-anilist'),
|
||||||
quitApp: () => calls.push('quit-app'),
|
quitApp: () => calls.push('quit-app'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openRuntimeOptions: () => void;
|
openRuntimeOptions: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
|
showJellyfinDiscovery: boolean;
|
||||||
|
jellyfinDiscoveryActive: boolean;
|
||||||
|
toggleJellyfinDiscovery: () => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
}) => TMenuItem[];
|
}) => TMenuItem[];
|
||||||
@@ -49,6 +52,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
openJellyfinSetupWindow: () => void;
|
openJellyfinSetupWindow: () => void;
|
||||||
|
isJellyfinConfigured: () => boolean;
|
||||||
|
isJellyfinDiscoveryActive: () => boolean;
|
||||||
|
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||||
openAnilistSetupWindow: () => void;
|
openAnilistSetupWindow: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -64,6 +70,9 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||||
|
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||||
|
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
|
||||||
|
toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
|
||||||
openAnilistSetupWindow: deps.openAnilistSetupWindow,
|
openAnilistSetupWindow: deps.openAnilistSetupWindow,
|
||||||
quitApp: deps.quitApp,
|
quitApp: deps.quitApp,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
|||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
openJellyfinSetupWindow: () => {},
|
openJellyfinSetupWindow: () => {},
|
||||||
|
isJellyfinConfigured: () => false,
|
||||||
|
isJellyfinDiscoveryActive: () => false,
|
||||||
|
toggleJellyfinDiscovery: () => {},
|
||||||
openAnilistSetupWindow: () => {},
|
openAnilistSetupWindow: () => {},
|
||||||
quitApp: () => {},
|
quitApp: () => {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,22 +38,29 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openRuntimeOptions: () => calls.push('runtime'),
|
openRuntimeOptions: () => calls.push('runtime'),
|
||||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||||
|
showJellyfinDiscovery: true,
|
||||||
|
jellyfinDiscoveryActive: false,
|
||||||
|
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
|
||||||
openAnilistSetup: () => calls.push('anilist'),
|
openAnilistSetup: () => calls.push('anilist'),
|
||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(template.length, 10);
|
assert.equal(template.length, 11);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
template.some((entry) => entry.label === 'Open Overlay'),
|
template.some((entry) => entry.label === 'Open Overlay'),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(template[0]!.label, 'Open Help');
|
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?.();
|
template[0]!.click?.();
|
||||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||||
template[1]!.click?.();
|
template[1]!.click?.();
|
||||||
template[8]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
template[9]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||||
template[9]!.click?.();
|
template[10]!.click?.();
|
||||||
assert.deepEqual(calls, ['help', 'texthooker', 'separator', 'quit']);
|
assert.deepEqual(calls, ['jellyfin-discovery', 'help', 'texthooker', 'separator', 'quit']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
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,
|
openYomitanSettings: () => undefined,
|
||||||
openRuntimeOptions: () => undefined,
|
openRuntimeOptions: () => undefined,
|
||||||
openJellyfinSetup: () => undefined,
|
openJellyfinSetup: () => undefined,
|
||||||
|
showJellyfinDiscovery: false,
|
||||||
|
jellyfinDiscoveryActive: false,
|
||||||
|
toggleJellyfinDiscovery: () => undefined,
|
||||||
openAnilistSetup: () => undefined,
|
openAnilistSetup: () => undefined,
|
||||||
quitApp: () => 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('Complete Setup'), false);
|
||||||
assert.equal(labels.includes('Manage Windows mpv launcher'), 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;
|
openYomitanSettings: () => void;
|
||||||
openRuntimeOptions: () => void;
|
openRuntimeOptions: () => void;
|
||||||
openJellyfinSetup: () => void;
|
openJellyfinSetup: () => void;
|
||||||
|
showJellyfinDiscovery: boolean;
|
||||||
|
jellyfinDiscoveryActive: boolean;
|
||||||
|
toggleJellyfinDiscovery: () => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
||||||
label?: string;
|
label?: string;
|
||||||
type?: 'separator';
|
type?: 'separator' | 'checkbox';
|
||||||
|
checked?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
click?: () => void;
|
click?: () => void;
|
||||||
}> {
|
}> {
|
||||||
return [
|
return [
|
||||||
@@ -85,6 +90,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
|||||||
label: 'Configure Jellyfin',
|
label: 'Configure Jellyfin',
|
||||||
click: handlers.openJellyfinSetup,
|
click: handlers.openJellyfinSetup,
|
||||||
},
|
},
|
||||||
|
...(handlers.showJellyfinDiscovery
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Jellyfin Discovery',
|
||||||
|
type: 'checkbox' as const,
|
||||||
|
checked: handlers.jellyfinDiscoveryActive,
|
||||||
|
enabled: true,
|
||||||
|
click: handlers.toggleJellyfinDiscovery,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: 'Configure AniList',
|
label: 'Configure AniList',
|
||||||
click: handlers.openAnilistSetup,
|
click: handlers.openAnilistSetup,
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
|
|||||||
jellyfin: {
|
jellyfin: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
recentServers: string[];
|
||||||
username: string;
|
username: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export interface YomitanConfig {
|
|||||||
export interface JellyfinConfig {
|
export interface JellyfinConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
|
recentServers?: string[];
|
||||||
username?: string;
|
username?: string;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
clientName?: string;
|
clientName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user