From 46a2ac5dc722c73fbd0254e7a3b5430fd549bb11 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 20 Feb 2026 03:26:37 -0800 Subject: [PATCH] feat(jellyfin): store access token in encrypted local store --- ...ncrypted-local-token-store-like-AniList.md | 31 ++++++ docs/configuration.md | 4 +- docs/jellyfin-integration.md | 5 +- docs/public/config.example.jsonc | 5 +- docs/subagents/INDEX.md | 3 + ...yfin-secret-store-20260220T101428Z-om4z.md | 65 ++++++++++++ docs/subagents/collaboration.md | 4 + src/config/definitions.ts | 3 +- src/core/services/jellyfin-token-store.ts | 100 ++++++++++++++++++ src/main.ts | 16 ++- src/main/runtime/jellyfin-cli-auth.test.ts | 9 +- src/main/runtime/jellyfin-cli-auth.ts | 6 +- .../runtime/jellyfin-cli-main-deps.test.ts | 6 +- src/main/runtime/jellyfin-cli-main-deps.ts | 2 + .../jellyfin-client-info-main-deps.test.ts | 2 + .../runtime/jellyfin-client-info-main-deps.ts | 1 + src/main/runtime/jellyfin-client-info.test.ts | 21 ++++ src/main/runtime/jellyfin-client-info.ts | 20 +++- .../jellyfin-setup-window-main-deps.test.ts | 4 +- .../jellyfin-setup-window-main-deps.ts | 1 + .../runtime/jellyfin-setup-window.test.ts | 7 +- src/main/runtime/jellyfin-setup-window.ts | 4 + 22 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 backlog/tasks/task-92 - Store-Jellyfin-token-in-encrypted-local-token-store-like-AniList.md create mode 100644 docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md create mode 100644 src/core/services/jellyfin-token-store.ts diff --git a/backlog/tasks/task-92 - Store-Jellyfin-token-in-encrypted-local-token-store-like-AniList.md b/backlog/tasks/task-92 - Store-Jellyfin-token-in-encrypted-local-token-store-like-AniList.md new file mode 100644 index 0000000..7dc9d52 --- /dev/null +++ b/backlog/tasks/task-92 - Store-Jellyfin-token-in-encrypted-local-token-store-like-AniList.md @@ -0,0 +1,31 @@ +--- +id: TASK-92 +title: Store Jellyfin token in encrypted local token store like AniList +status: Done +assignee: [] +created_date: '2026-02-20 02:22' +updated_date: '2026-02-20 02:22' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Persist Jellyfin access token in local encrypted token storage and resolve `jellyfin.accessToken` from config-or-store, matching AniList token handling pattern. + + +## Acceptance Criteria + +- [ ] #1 Jellyfin login/setup saves token to encrypted local store instead of persisting in config. +- [ ] #2 Jellyfin logout clears local stored token. +- [ ] #3 Runtime resolves Jellyfin token from config override first, then stored token fallback when config value is blank. +- [ ] #4 Jellyfin runtime tests cover new token-store save/clear/fallback behavior. + + +## Implementation Notes + + +Added `src/core/services/jellyfin-token-store.ts` using Electron `safeStorage` with encrypted payload + plaintext fallback/migration behavior matching AniList store pattern. Wired token store in `src/main.ts` and changed Jellyfin flows so login/setup save token in store and write blank `jellyfin.accessToken` to config, while logout clears store. Updated resolved Jellyfin config path (`src/main/runtime/jellyfin-client-info.ts`) to apply config-first + stored-token fallback. Updated runtime deps/tests: `jellyfin-client-info*`, `jellyfin-cli-auth*`, `jellyfin-cli-main-deps*`, `jellyfin-setup-window*`, and `jellyfin-setup-window-main-deps*`. Updated docs/config notes to reflect local encrypted token storage behavior. + diff --git a/docs/configuration.md b/docs/configuration.md index 8c12c2a..d4cdc53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -496,7 +496,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner | `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | | `serverUrl` | string (URL) | Jellyfin server base URL | | `username` | string | Default username used by `--jellyfin-login` | -| `accessToken` | string | Stored Jellyfin access token (treat as secret) | +| `accessToken` | string | Optional explicit Jellyfin access token override; leave empty to use stored local token | | `userId` | string | Jellyfin user id bound to token/session | | `deviceId` | string | Client device id sent in auth headers (default: `subminer`) | | `clientName` | string | Client name sent in auth headers (default: `SubMiner`) | @@ -512,6 +512,8 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner | `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | +When `jellyfin.accessToken` is empty, SubMiner uses the locally stored encrypted token saved from Jellyfin login/setup. + Jellyfin direct app CLI commands (`SubMiner.AppImage ...`): - `--jellyfin`: open the in-app Jellyfin setup window (server/user/password form). diff --git a/docs/jellyfin-integration.md b/docs/jellyfin-integration.md index ff5d4ed..f910d90 100644 --- a/docs/jellyfin-integration.md +++ b/docs/jellyfin-integration.md @@ -149,8 +149,9 @@ User-visible errors are shown through CLI logs and mpv OSD for: ## Security Notes and Limitations -- Jellyfin access token is persisted in `config.jsonc`. -- Treat config files as secrets and avoid committing them. +- Jellyfin access token is stored in local encrypted token storage after login/setup. +- `jellyfin.accessToken` remains as an optional explicit override in `config.jsonc`. +- Treat both token storage and config files as secrets and avoid committing them. - Password is used only for login and is not stored. - Optional setup UI is available via `--jellyfin`; all actions are also available via CLI flags. - `subminer` wrapper uses Jellyfin subcommands (`subminer jellyfin ...`, alias `subminer jf ...`). Use `SubMiner.AppImage` for direct `--jellyfin-libraries` and `--jellyfin-items`. diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 89cfb3d..fa2ea12 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -273,13 +273,14 @@ // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. - // Access token is stored in config and should be treated as a secret. + // Access token is stored in local encrypted token storage after login/setup. + // jellyfin.accessToken below remains an optional explicit override. // ========================================== "jellyfin": { "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "username": "", // Default Jellyfin username used during CLI login. - "accessToken": "", // Access token setting. + "accessToken": "", // Optional explicit access token override; leave empty to use stored local token. "userId": "", // User id setting. "deviceId": "subminer", // Device id setting. "clientName": "SubMiner", // Client name setting. diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index b5c9f8e..eb30b57 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -18,3 +18,6 @@ Read first. Keep concise. | `codex-release-mpv-plugin-20260220T035757Z-d4yf` | `codex-release-mpv-plugin` | `Package optional release assets bundle (mpv plugin + rofi theme), move theme to assets/themes, update install/docs` | `completed` | `docs/subagents/agents/codex-release-mpv-plugin-20260220T035757Z-d4yf.md` | `2026-02-20T04:02:26Z` | | `codex-bundle-config-example-20260220T092408Z-a1b2` | `codex-bundle-config-example` | `Bundle config.example.jsonc in release assets tarball and align install docs` | `completed` | `docs/subagents/agents/codex-bundle-config-example-20260220T092408Z-a1b2.md` | `2026-02-20T09:26:24Z` | | `codex-tsconfig-modernize-20260220T093035Z-68qb` | `codex-tsconfig-modernize` | `Enable noUncheckedIndexedAccess + isolatedModules in root tsconfig and fix resulting compile errors` | `completed` | `docs/subagents/agents/codex-tsconfig-modernize-20260220T093035Z-68qb.md` | `2026-02-20T09:46:26Z` | +| `codex-jellyfin-secret-store-20260220T101428Z-om4z` | `codex-jellyfin-secret-store` | `Verify whether Jellyfin token can use same secret-store path as AniList token` | `completed` | `docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md` | `2026-02-20T10:22:45Z` | +| `codex-vitepress-subagents-ignore-20260220T101755Z-k2m9` | `codex-vitepress-subagents-ignore` | `Exclude docs/subagents from VitePress build` | `completed` | `docs/subagents/agents/codex-vitepress-subagents-ignore-20260220T101755Z-k2m9.md` | `2026-02-20T10:18:30Z` | +| `codex-preserve-linebreak-display-20260220T110436Z-r8f1` | `codex-preserve-linebreak-display` | `Fix visible overlay display artifact when subtitleStyle.preserveLineBreaks is disabled` | `completed` | `docs/subagents/agents/codex-preserve-linebreak-display-20260220T110436Z-r8f1.md` | `2026-02-20T11:10:51Z` | diff --git a/docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md b/docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md new file mode 100644 index 0000000..ce688ed --- /dev/null +++ b/docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md @@ -0,0 +1,65 @@ +# codex-jellyfin-secret-store-20260220T101428Z-om4z + +- alias: `codex-jellyfin-secret-store` +- mission: `Verify whether Jellyfin token can use same secret-store path as AniList token` +- status: `completed` +- last_update_utc: `2026-02-20T10:22:45Z` + +## Intent + +- compare AniList token persistence path vs Jellyfin token persistence path +- answer feasibility + current behavior from code + +## Planned Files + +- `src/main/runtime/anilist-token-refresh.ts` +- `src/main/runtime/*anilist*` +- `src/core/services/jellyfin.ts` +- `src/main/runtime/*jellyfin*` +- `src/config/*` +- docs refs if needed + +## Assumptions + +- user asking architecture/feasibility question; likely no code change requested yet + +## Findings + +- AniList token store present: `src/core/services/anilist/anilist-token-store.ts` (`safeStorage` encrypt/decrypt + persisted file) +- AniList runtime wiring present: `src/main.ts` creates `anilistTokenStore`; refresh/setup paths use `saveToken/loadToken` +- Jellyfin auth currently writes token directly into config via `patchRawConfig({ jellyfin: { accessToken } })` +- Docs confirm current behavior: Jellyfin token persisted in config (`docs/jellyfin-integration.md`) + +## Outcome + +- no code changes; answered feasibility/current state from repo +- implemented requested change: Jellyfin token now persisted in local encrypted token store with config override fallback + +## Files Touched + +- `src/core/services/jellyfin-token-store.ts` +- `src/main.ts` +- `src/main/runtime/jellyfin-client-info.ts` +- `src/main/runtime/jellyfin-client-info.test.ts` +- `src/main/runtime/jellyfin-client-info-main-deps.ts` +- `src/main/runtime/jellyfin-client-info-main-deps.test.ts` +- `src/main/runtime/jellyfin-cli-auth.ts` +- `src/main/runtime/jellyfin-cli-auth.test.ts` +- `src/main/runtime/jellyfin-cli-main-deps.ts` +- `src/main/runtime/jellyfin-cli-main-deps.test.ts` +- `src/main/runtime/jellyfin-setup-window.ts` +- `src/main/runtime/jellyfin-setup-window.test.ts` +- `src/main/runtime/jellyfin-setup-window-main-deps.ts` +- `src/main/runtime/jellyfin-setup-window-main-deps.test.ts` +- `docs/jellyfin-integration.md` +- `docs/configuration.md` +- `docs/public/config.example.jsonc` +- `src/config/definitions.ts` +- `backlog/tasks/task-92 - Store-Jellyfin-token-in-encrypted-local-token-store-like-AniList.md` + +## Verification + +- `bun run build` +- `node --test dist/main/runtime/jellyfin-client-info.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-client-info-main-deps.test.js dist/main/runtime/jellyfin-cli-main-deps.test.js dist/main/runtime/jellyfin-setup-window-main-deps.test.js` +- `bun run test:fast` +- `bun run docs:build` diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index 6bfbe69..1bf8d87 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -12,3 +12,7 @@ Shared notes. Append-only. - [2026-02-20T06:35:38Z] [codex-preserve-linebreaks-20260220T063538Z-s4nd|codex-preserve-linebreaks] overlap note: touching subtitle config + renderer render path (`src/types.ts`, `src/config/*`, `src/renderer/subtitle-render.ts`, docs/config examples) to add optional preserve-line-breaks behavior while keeping default normalization unchanged. - [2026-02-20T06:42:51Z] [codex-preserve-linebreaks-20260220T063538Z-s4nd|codex-preserve-linebreaks] completed TASK-91; added `subtitleStyle.preserveLineBreaks` config (default false), renderer token/source alignment helper to preserve visible overlay line breaks when enabled, config+renderer tests green. - [2026-02-20T09:24:08Z] [codex-bundle-config-example-20260220T092408Z-a1b2|codex-bundle-config-example] conflict note: target file .github/workflows/release.yml already modified by codex-release-mpv-plugin session; applying minimal additive delta for config example bundling only. +- [2026-02-20T10:22:45Z] [codex-jellyfin-secret-store-20260220T101428Z-om4z|codex-jellyfin-secret-store] completed TASK-92: Jellyfin token now stored via local encrypted token store (`safeStorage`) with config override fallback; login/setup save token to store, logout clears store; runtime/docs/tests updated. +- [2026-02-20T11:04:36Z] [codex-preserve-linebreak-display-20260220T110436Z-r8f1|codex-preserve-linebreak-display] overlap note: touching `src/renderer/subtitle-render.ts` + renderer tests to fix preserve-linebreaks disabled display artifact while preserving TASK-91 behavior. +- [2026-02-20T11:07:29Z] [codex-preserve-linebreak-display-20260220T110436Z-r8f1|codex-preserve-linebreak-display] completed follow-up for TASK-91: non-preserve mode now flattens token CR/LF to spaces instead of emitting `
` from token surfaces; regression test added. +- [2026-02-20T11:10:51Z] [codex-preserve-linebreak-display-20260220T110436Z-r8f1|codex-preserve-linebreak-display] second follow-up: handle overlap token streams by aligning non-preserve rendering to normalized source text and skipping unmatched tail tokens (prevents duplicated second-line phrase). diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 53233e8..4724160 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -807,7 +807,8 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'Jellyfin', description: [ 'Optional Jellyfin integration for auth, browsing, and playback launch.', - 'Access token is stored in config and should be treated as a secret.', + 'Access token is stored in local encrypted token storage after login/setup.', + 'jellyfin.accessToken remains an optional explicit override in config.', ], key: 'jellyfin', }, diff --git a/src/core/services/jellyfin-token-store.ts b/src/core/services/jellyfin-token-store.ts new file mode 100644 index 0000000..828120d --- /dev/null +++ b/src/core/services/jellyfin-token-store.ts @@ -0,0 +1,100 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { safeStorage } from 'electron'; + +interface PersistedTokenPayload { + encryptedToken?: string; + plaintextToken?: string; + updatedAt?: number; +} + +export interface JellyfinTokenStore { + loadToken: () => string | null; + saveToken: (token: string) => void; + clearToken: () => void; +} + +function ensureDirectory(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function writePayload(filePath: string, payload: PersistedTokenPayload): void { + ensureDirectory(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); +} + +export function createJellyfinTokenStore( + filePath: string, + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, details?: unknown) => void; + }, +): JellyfinTokenStore { + return { + loadToken(): string | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as PersistedTokenPayload; + if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) { + const encrypted = Buffer.from(parsed.encryptedToken, 'base64'); + if (!safeStorage.isEncryptionAvailable()) { + logger.warn('Jellyfin token encryption is not available on this system.'); + return null; + } + const decrypted = safeStorage.decryptString(encrypted).trim(); + return decrypted.length > 0 ? decrypted : null; + } + if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) { + const plaintext = parsed.plaintextToken.trim(); + this.saveToken(plaintext); + return plaintext; + } + } catch (error) { + logger.error('Failed to read Jellyfin token store.', error); + } + return null; + }, + + saveToken(token: string): void { + const trimmed = token.trim(); + if (trimmed.length === 0) { + this.clearToken(); + return; + } + try { + if (!safeStorage.isEncryptionAvailable()) { + logger.warn('Jellyfin token encryption unavailable; storing token in plaintext fallback.'); + writePayload(filePath, { + plaintextToken: trimmed, + updatedAt: Date.now(), + }); + return; + } + const encrypted = safeStorage.encryptString(trimmed); + writePayload(filePath, { + encryptedToken: encrypted.toString('base64'), + updatedAt: Date.now(), + }); + } catch (error) { + logger.error('Failed to persist Jellyfin token.', error); + } + }, + + clearToken(): void { + if (!fs.existsSync(filePath)) return; + try { + fs.unlinkSync(filePath); + logger.info('Cleared stored Jellyfin token.'); + } catch (error) { + logger.error('Failed to clear stored Jellyfin token.', error); + } + }, + }; +} diff --git a/src/main.ts b/src/main.ts index 69c6667..6a87b16 100644 --- a/src/main.ts +++ b/src/main.ts @@ -530,6 +530,7 @@ import { } from './core/services/anilist/anilist-updater'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; +import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { createAppReadyRuntimeRunner } from './main/app-lifecycle'; import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; @@ -581,6 +582,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json'; +const JELLYFIN_TOKEN_STORE_FILE = 'jellyfin-token-store.json'; const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json'; const TRAY_TOOLTIP = 'SubMiner'; @@ -644,6 +646,14 @@ const anilistTokenStore = createAnilistTokenStore( error: (message: string, details?: unknown) => console.error(message, details), }, ); +const jellyfinTokenStore = createJellyfinTokenStore( + path.join(USER_DATA_PATH, JELLYFIN_TOKEN_STORE_FILE), + { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }, +); const anilistUpdateQueue = createAnilistUpdateQueue( path.join(USER_DATA_PATH, ANILIST_RETRY_QUEUE_FILE), { @@ -1205,6 +1215,7 @@ function getResolvedConfig() { const buildGetResolvedJellyfinConfigMainDepsHandler = createBuildGetResolvedJellyfinConfigMainDepsHandler({ getResolvedConfig: () => getResolvedConfig(), + loadStoredToken: () => jellyfinTokenStore.loadToken(), }); const getResolvedJellyfinConfigHandler = createGetResolvedJellyfinConfigHandler( buildGetResolvedJellyfinConfigMainDepsHandler(), @@ -1345,6 +1356,8 @@ const buildHandleJellyfinAuthCommandsMainDepsHandler = }, authenticateWithPassword: (serverUrl, username, password, clientInfo) => authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo), + saveStoredToken: (token) => jellyfinTokenStore.saveToken(token), + clearStoredToken: () => jellyfinTokenStore.clearToken(), logInfo: (message) => logger.info(message), }); const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands( @@ -1585,13 +1598,14 @@ const buildOpenJellyfinSetupWindowMainDepsHandler = authenticateWithPassword: (server, username, password, clientInfo) => authenticateWithPasswordRuntime(server, username, password, clientInfo), getJellyfinClientInfo: () => getJellyfinClientInfo(), + saveStoredToken: (token) => jellyfinTokenStore.saveToken(token), patchJellyfinConfig: (session) => { configService.patchRawConfig({ jellyfin: { enabled: true, serverUrl: session.serverUrl, username: session.username, - accessToken: session.accessToken, + accessToken: '', userId: session.userId, }, }); diff --git a/src/main/runtime/jellyfin-cli-auth.test.ts b/src/main/runtime/jellyfin-cli-auth.test.ts index f1fa812..358a675 100644 --- a/src/main/runtime/jellyfin-cli-auth.test.ts +++ b/src/main/runtime/jellyfin-cli-auth.test.ts @@ -6,6 +6,8 @@ test('jellyfin auth handler processes logout', async () => { const calls: string[] = []; const handleAuth = createHandleJellyfinAuthCommands({ patchRawConfig: () => calls.push('patch'), + saveStoredToken: () => calls.push('save'), + clearStoredToken: () => calls.push('clear'), authenticateWithPassword: async () => { throw new Error('should not authenticate'); }, @@ -34,13 +36,15 @@ test('jellyfin auth handler processes logout', async () => { }); assert.equal(handled, true); - assert.equal(calls[0], 'patch'); + assert.deepEqual(calls.slice(0, 2), ['clear', 'patch']); }); test('jellyfin auth handler processes login', async () => { const calls: string[] = []; const handleAuth = createHandleJellyfinAuthCommands({ patchRawConfig: () => calls.push('patch'), + saveStoredToken: () => calls.push('save'), + clearStoredToken: () => calls.push('clear'), authenticateWithPassword: async () => ({ serverUrl: 'http://localhost', username: 'user', @@ -72,6 +76,7 @@ test('jellyfin auth handler processes login', async () => { }); assert.equal(handled, true); + assert.ok(calls.includes('save')); assert.ok(calls.includes('patch')); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); }); @@ -79,6 +84,8 @@ test('jellyfin auth handler processes login', async () => { test('jellyfin auth handler no-ops when no auth command', async () => { const handleAuth = createHandleJellyfinAuthCommands({ patchRawConfig: () => {}, + saveStoredToken: () => {}, + clearStoredToken: () => {}, authenticateWithPassword: async () => ({ serverUrl: '', username: '', diff --git a/src/main/runtime/jellyfin-cli-auth.ts b/src/main/runtime/jellyfin-cli-auth.ts index f78dfa9..9e823c6 100644 --- a/src/main/runtime/jellyfin-cli-auth.ts +++ b/src/main/runtime/jellyfin-cli-auth.ts @@ -39,6 +39,8 @@ export function createHandleJellyfinAuthCommands(deps: { password: string, clientInfo: JellyfinClientInfo, ) => Promise; + saveStoredToken: (token: string) => void; + clearStoredToken: () => void; logInfo: (message: string) => void; }) { return async (params: { @@ -48,6 +50,7 @@ export function createHandleJellyfinAuthCommands(deps: { clientInfo: JellyfinClientInfo; }): Promise => { if (params.args.jellyfinLogout) { + deps.clearStoredToken(); deps.patchRawConfig({ jellyfin: { accessToken: '', @@ -70,12 +73,13 @@ export function createHandleJellyfinAuthCommands(deps: { password, params.clientInfo, ); + deps.saveStoredToken(session.accessToken); deps.patchRawConfig({ jellyfin: { enabled: true, serverUrl: session.serverUrl, username: session.username, - accessToken: session.accessToken, + accessToken: '', userId: session.userId, deviceId: params.clientInfo.deviceId, clientName: params.clientInfo.clientName, diff --git a/src/main/runtime/jellyfin-cli-main-deps.test.ts b/src/main/runtime/jellyfin-cli-main-deps.test.ts index 1854447..058a330 100644 --- a/src/main/runtime/jellyfin-cli-main-deps.test.ts +++ b/src/main/runtime/jellyfin-cli-main-deps.test.ts @@ -12,6 +12,8 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => { const deps = createBuildHandleJellyfinAuthCommandsMainDepsHandler({ patchRawConfig: () => calls.push('patch'), authenticateWithPassword: async () => ({}) as never, + saveStoredToken: () => calls.push('save'), + clearStoredToken: () => calls.push('clear'), logInfo: (message) => calls.push(`info:${message}`), })(); @@ -21,8 +23,10 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => { clientName: '', clientVersion: '', }); + deps.saveStoredToken('token'); + deps.clearStoredToken(); deps.logInfo('ok'); - assert.deepEqual(calls, ['patch', 'info:ok']); + assert.deepEqual(calls, ['patch', 'save', 'clear', 'info:ok']); }); test('jellyfin list commands main deps builder maps callbacks', async () => { diff --git a/src/main/runtime/jellyfin-cli-main-deps.ts b/src/main/runtime/jellyfin-cli-main-deps.ts index 7294b35..6afcfd7 100644 --- a/src/main/runtime/jellyfin-cli-main-deps.ts +++ b/src/main/runtime/jellyfin-cli-main-deps.ts @@ -25,6 +25,8 @@ export function createBuildHandleJellyfinAuthCommandsMainDepsHandler( patchRawConfig: (patch) => deps.patchRawConfig(patch), authenticateWithPassword: (serverUrl, username, password, clientInfo) => deps.authenticateWithPassword(serverUrl, username, password, clientInfo), + saveStoredToken: (token) => deps.saveStoredToken(token), + clearStoredToken: () => deps.clearStoredToken(), logInfo: (message: string) => deps.logInfo(message), }); } diff --git a/src/main/runtime/jellyfin-client-info-main-deps.test.ts b/src/main/runtime/jellyfin-client-info-main-deps.test.ts index 8505644..86622e9 100644 --- a/src/main/runtime/jellyfin-client-info-main-deps.test.ts +++ b/src/main/runtime/jellyfin-client-info-main-deps.test.ts @@ -9,8 +9,10 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => { const resolved = { jellyfin: { url: 'https://example.com' } }; const deps = createBuildGetResolvedJellyfinConfigMainDepsHandler({ getResolvedConfig: () => resolved as never, + loadStoredToken: () => 'stored-token', })(); assert.equal(deps.getResolvedConfig(), resolved); + assert.equal(deps.loadStoredToken(), 'stored-token'); }); test('get jellyfin client info main deps builder maps callbacks', () => { diff --git a/src/main/runtime/jellyfin-client-info-main-deps.ts b/src/main/runtime/jellyfin-client-info-main-deps.ts index d530697..6cd5902 100644 --- a/src/main/runtime/jellyfin-client-info-main-deps.ts +++ b/src/main/runtime/jellyfin-client-info-main-deps.ts @@ -11,6 +11,7 @@ export function createBuildGetResolvedJellyfinConfigMainDepsHandler( ) { return (): GetResolvedJellyfinConfigMainDeps => ({ getResolvedConfig: () => deps.getResolvedConfig(), + loadStoredToken: () => deps.loadStoredToken(), }); } diff --git a/src/main/runtime/jellyfin-client-info.test.ts b/src/main/runtime/jellyfin-client-info.test.ts index f4a89e7..36736f6 100644 --- a/src/main/runtime/jellyfin-client-info.test.ts +++ b/src/main/runtime/jellyfin-client-info.test.ts @@ -9,11 +9,32 @@ test('get resolved jellyfin config returns jellyfin section from resolved config const jellyfin = { url: 'https://jellyfin.local' } as never; const getConfig = createGetResolvedJellyfinConfigHandler({ getResolvedConfig: () => ({ jellyfin } as never), + loadStoredToken: () => null, }); assert.equal(getConfig(), jellyfin); }); +test('get resolved jellyfin config falls back to stored token when config token is blank', () => { + const getConfig = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => + ({ + jellyfin: { + serverUrl: 'http://localhost:8096', + accessToken: ' ', + userId: 'uid-1', + }, + }) as never, + loadStoredToken: () => 'stored-token', + }); + + assert.deepEqual(getConfig(), { + serverUrl: 'http://localhost:8096', + accessToken: 'stored-token', + userId: 'uid-1', + }); +}); + test('jellyfin client info resolves defaults when fields are missing', () => { const getClientInfo = createGetJellyfinClientInfoHandler({ getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' } as never), diff --git a/src/main/runtime/jellyfin-client-info.ts b/src/main/runtime/jellyfin-client-info.ts index c203184..335fa1d 100644 --- a/src/main/runtime/jellyfin-client-info.ts +++ b/src/main/runtime/jellyfin-client-info.ts @@ -1,7 +1,25 @@ export function createGetResolvedJellyfinConfigHandler(deps: { getResolvedConfig: () => { jellyfin: unknown }; + loadStoredToken: () => string | null | undefined; }) { - return () => deps.getResolvedConfig().jellyfin as never; + return () => { + const jellyfin = deps.getResolvedConfig().jellyfin as { + accessToken?: string; + [key: string]: unknown; + }; + const configToken = jellyfin.accessToken?.trim() ?? ''; + if (configToken.length > 0) { + return jellyfin as never; + } + const storedToken = deps.loadStoredToken()?.trim() ?? ''; + if (storedToken.length === 0) { + return jellyfin as never; + } + return { + ...jellyfin, + accessToken: storedToken, + } as never; + }; } export function createGetJellyfinClientInfoHandler(deps: { diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts index 5e0d6f6..bce3ef9 100644 --- a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts +++ b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts @@ -17,6 +17,7 @@ test('open jellyfin setup window main deps builder maps callbacks', async () => userId: 'uid', }), getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'dev' }), + saveStoredToken: () => calls.push('save'), patchJellyfinConfig: () => calls.push('patch'), logInfo: (message) => calls.push(`info:${message}`), logError: (message) => calls.push(`error:${message}`), @@ -43,6 +44,7 @@ test('open jellyfin setup window main deps builder maps callbacks', async () => accessToken: 'token', userId: 'uid', }); + deps.saveStoredToken('token'); deps.patchJellyfinConfig({ serverUrl: 'http://127.0.0.1:8096', username: 'alice', @@ -55,5 +57,5 @@ test('open jellyfin setup window main deps builder maps callbacks', async () => deps.clearSetupWindow(); deps.setSetupWindow({} as never); assert.equal(deps.encodeURIComponent('a b'), 'a%20b'); - assert.deepEqual(calls, ['patch', 'info:ok', 'error:bad', 'osd:toast', 'clear', 'set-window']); + assert.deepEqual(calls, ['save', 'patch', 'info:ok', 'error:bad', 'osd:toast', 'clear', 'set-window']); }); diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.ts b/src/main/runtime/jellyfin-setup-window-main-deps.ts index cfa0a41..a4c8954 100644 --- a/src/main/runtime/jellyfin-setup-window-main-deps.ts +++ b/src/main/runtime/jellyfin-setup-window-main-deps.ts @@ -15,6 +15,7 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler( authenticateWithPassword: (server: string, username: string, password: string, clientInfo) => deps.authenticateWithPassword(server, username, password, clientInfo), getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), + saveStoredToken: (token: string) => deps.saveStoredToken(token), patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), logInfo: (message: string) => deps.logInfo(message), logError: (message: string, error: unknown) => deps.logError(message, error), diff --git a/src/main/runtime/jellyfin-setup-window.test.ts b/src/main/runtime/jellyfin-setup-window.test.ts index 6046103..8972eb1 100644 --- a/src/main/runtime/jellyfin-setup-window.test.ts +++ b/src/main/runtime/jellyfin-setup-window.test.ts @@ -49,6 +49,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn userId: 'uid', }), getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredToken: () => calls.push('save'), patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: () => calls.push('error'), @@ -60,7 +61,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', ); assert.equal(handled, true); - assert.deepEqual(calls, ['patch', 'info', 'osd:Jellyfin login success', 'close']); + assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'close']); }); test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async () => { @@ -71,6 +72,7 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async throw new Error('bad credentials'); }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredToken: () => calls.push('save'), patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: () => calls.push('error'), @@ -164,6 +166,7 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is throw new Error('should not auth'); }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredToken: () => {}, patchJellyfinConfig: () => {}, logInfo: () => {}, logError: () => {}, @@ -216,6 +219,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li userId: 'uid', }), getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredToken: () => calls.push('save'), patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: () => calls.push('error'), @@ -245,6 +249,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li await Promise.resolve(); assert.equal(prevented, true); + assert.ok(calls.includes('save')); assert.ok(calls.includes('patch')); assert.ok(calls.includes('osd:Jellyfin login success')); assert.ok(calls.includes('close')); diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts index 92f8488..f94fd2a 100644 --- a/src/main/runtime/jellyfin-setup-window.ts +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -117,6 +117,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: { clientInfo: JellyfinClientInfo, ) => Promise; getJellyfinClientInfo: () => JellyfinClientInfo; + saveStoredToken: (token: string) => void; patchJellyfinConfig: (session: JellyfinSession) => void; logInfo: (message: string) => void; logError: (message: string, error: unknown) => void; @@ -136,6 +137,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: { submission.password, deps.getJellyfinClientInfo(), ); + deps.saveStoredToken(session.accessToken); deps.patchJellyfinConfig(session); deps.logInfo(`Jellyfin setup saved for ${session.username}.`); deps.showMpvOsd('Jellyfin login success'); @@ -195,6 +197,7 @@ export function createOpenJellyfinSetupWindowHandler Promise; getJellyfinClientInfo: () => JellyfinClientInfo; + saveStoredToken: (token: string) => void; patchJellyfinConfig: (session: JellyfinSession) => void; logInfo: (message: string) => void; logError: (message: string, error: unknown) => void; @@ -218,6 +221,7 @@ export function createOpenJellyfinSetupWindowHandler deps.authenticateWithPassword(server, username, password, clientInfo), getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), + saveStoredToken: (token) => deps.saveStoredToken(token), patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), logInfo: (message) => deps.logInfo(message), logError: (message, error) => deps.logError(message, error),