From d5d71816ac47feadd28e683791647519740df6ca Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Feb 2026 16:57:06 -0800 Subject: [PATCH] refactor: split main runtime flows into focused modules --- docs/subagents/INDEX.md | 4 +- .../codex-task85-20260219T233711Z-46hc.md | 55 +- src/main.ts | 1124 +++++++---------- src/main/runtime/anilist-media-guess.test.ts | 65 + src/main/runtime/anilist-media-guess.ts | 112 ++ src/main/runtime/anilist-post-watch.test.ts | 78 ++ src/main/runtime/anilist-post-watch.ts | 195 +++ src/main/runtime/anilist-setup-window.test.ts | 226 ++++ src/main/runtime/anilist-setup-window.ts | 181 +++ .../runtime/anilist-token-refresh.test.ts | 113 ++ src/main/runtime/anilist-token-refresh.ts | 93 ++ .../runtime/cli-command-prechecks.test.ts | 59 + src/main/runtime/cli-command-prechecks.ts | 21 + src/main/runtime/initial-args-handler.test.ts | 86 ++ src/main/runtime/initial-args-handler.ts | 39 + src/main/runtime/jellyfin-cli-auth.test.ts | 113 ++ src/main/runtime/jellyfin-cli-auth.ts | 88 ++ src/main/runtime/jellyfin-cli-list.test.ts | 176 +++ src/main/runtime/jellyfin-cli-list.ts | 116 ++ src/main/runtime/jellyfin-cli-play.test.ts | 106 ++ src/main/runtime/jellyfin-cli-play.ts | 53 + .../jellyfin-cli-remote-announce.test.ts | 85 ++ .../runtime/jellyfin-cli-remote-announce.ts | 35 + .../jellyfin-remote-session-lifecycle.test.ts | 149 +++ .../jellyfin-remote-session-lifecycle.ts | 135 ++ .../runtime/jellyfin-setup-window.test.ts | 146 +++ src/main/runtime/jellyfin-setup-window.ts | 171 +++ .../runtime/protocol-url-handlers.test.ts | 34 + src/main/runtime/protocol-url-handlers.ts | 27 + src/main/runtime/subtitle-position.test.ts | 35 + src/main/runtime/subtitle-position.ts | 22 + 31 files changed, 3270 insertions(+), 672 deletions(-) create mode 100644 src/main/runtime/anilist-media-guess.test.ts create mode 100644 src/main/runtime/anilist-media-guess.ts create mode 100644 src/main/runtime/anilist-post-watch.test.ts create mode 100644 src/main/runtime/anilist-post-watch.ts create mode 100644 src/main/runtime/anilist-setup-window.test.ts create mode 100644 src/main/runtime/anilist-setup-window.ts create mode 100644 src/main/runtime/anilist-token-refresh.test.ts create mode 100644 src/main/runtime/anilist-token-refresh.ts create mode 100644 src/main/runtime/cli-command-prechecks.test.ts create mode 100644 src/main/runtime/cli-command-prechecks.ts create mode 100644 src/main/runtime/initial-args-handler.test.ts create mode 100644 src/main/runtime/initial-args-handler.ts create mode 100644 src/main/runtime/jellyfin-cli-auth.test.ts create mode 100644 src/main/runtime/jellyfin-cli-auth.ts create mode 100644 src/main/runtime/jellyfin-cli-list.test.ts create mode 100644 src/main/runtime/jellyfin-cli-list.ts create mode 100644 src/main/runtime/jellyfin-cli-play.test.ts create mode 100644 src/main/runtime/jellyfin-cli-play.ts create mode 100644 src/main/runtime/jellyfin-cli-remote-announce.test.ts create mode 100644 src/main/runtime/jellyfin-cli-remote-announce.ts create mode 100644 src/main/runtime/jellyfin-remote-session-lifecycle.test.ts create mode 100644 src/main/runtime/jellyfin-remote-session-lifecycle.ts create mode 100644 src/main/runtime/jellyfin-setup-window.test.ts create mode 100644 src/main/runtime/jellyfin-setup-window.ts create mode 100644 src/main/runtime/protocol-url-handlers.test.ts create mode 100644 src/main/runtime/protocol-url-handlers.ts create mode 100644 src/main/runtime/subtitle-position.test.ts create mode 100644 src/main/runtime/subtitle-position.ts diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 347f9c2..033185c 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -6,5 +6,7 @@ Read first. Keep concise. | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | | `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T00:00:55Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T00:47:26Z` | | `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | +| `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | +| `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | diff --git a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md index 32528e3..6dfc571 100644 --- a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md +++ b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md @@ -9,6 +9,28 @@ ## Current Work (newest first) +- [2026-02-20T00:47:26Z] progress: extracted CLI runtime prechecks + initial arg orchestration into `src/main/runtime/cli-command-prechecks.ts` and `src/main/runtime/initial-args-handler.ts`; rewired `handleCliCommand` texthooker-only transition and `handleInitialArgs`. +- [2026-02-20T00:47:26Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (50/50). +- [2026-02-20T00:45:18Z] progress: extracted setup-window lifecycle/focus bookkeeping helpers into runtime modules (`createMaybeFocusExistingAnilistSetupWindowHandler`, `createHandleAnilistSetupWindowClosedHandler`, `createHandleAnilistSetupWindowOpenedHandler`, `createMaybeFocusExistingJellyfinSetupWindowHandler`, `createHandleJellyfinSetupWindowClosedHandler`, `createHandleJellyfinSetupWindowOpenedHandler`); rewired `openAnilistSetupWindow` + `openJellyfinSetupWindow`. +- [2026-02-20T00:45:18Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (43/43). +- [2026-02-20T00:41:54Z] progress: extracted AniList setup fallback load-event callbacks into `src/main/runtime/anilist-setup-window.ts` (`createAnilistSetupDidFailLoadHandler`, `createAnilistSetupDidFinishLoadHandler`); rewired `did-fail-load` and `did-finish-load` hooks in `openAnilistSetupWindow`. +- [2026-02-20T00:41:54Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (37/37). +- [2026-02-20T00:40:53Z] progress: extracted AniList setup URL event handlers from `openAnilistSetupWindow` into `src/main/runtime/anilist-setup-window.ts` (`createAnilistSetupWindowOpenHandler`, `createAnilistSetupWillNavigateHandler`, `createAnilistSetupWillRedirectHandler`, `createAnilistSetupDidNavigateHandler`); rewired `main.ts` callbacks. +- [2026-02-20T00:40:53Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (34/34). +- [2026-02-20T00:35:26Z] progress: extracted Jellyfin setup window navigation interception into `createHandleJellyfinSetupNavigationHandler` in `src/main/runtime/jellyfin-setup-window.ts`; rewired `openJellyfinSetupWindow` `will-navigate` branch in `src/main.ts`. +- [2026-02-20T00:35:26Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (26/26). +- [2026-02-20T00:34:12Z] progress: extracted Jellyfin remote lifecycle orchestration from `src/main.ts` into `src/main/runtime/jellyfin-remote-session-lifecycle.ts` (`createStartJellyfinRemoteSessionHandler`, `createStopJellyfinRemoteSessionHandler`); rewired start/stop call sites and callbacks; `src/main.ts` now 2731 LOC. +- [2026-02-20T00:34:12Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (20/20). +- [2026-02-20T00:32:07Z] progress: extracted remaining `runJellyfinCommand` action branches into `src/main/runtime/jellyfin-cli-play.ts` and `src/main/runtime/jellyfin-cli-remote-announce.ts`; rewired main command router; `src/main.ts` now 2773 LOC. +- [2026-02-20T00:32:07Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (16/16). +- [2026-02-20T00:29:07Z] progress: extracted Jellyfin CLI listing branches (`--jellyfin-libraries`, `--jellyfin-items`, `--jellyfin-subtitles`) from `runJellyfinCommand` into `src/main/runtime/jellyfin-cli-list.ts`; rewired through `createHandleJellyfinListCommands`; `src/main.ts` now 2820 LOC. +- [2026-02-20T00:29:07Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-auth.test.js` pass (9/9). +- [2026-02-20T00:19:13Z] progress: extracted AniList post-watch + queue orchestration into `src/main/runtime/anilist-post-watch.ts`, subtitle position state wrappers into `src/main/runtime/subtitle-position.ts`, and app protocol URL event wiring into `src/main/runtime/protocol-url-handlers.ts`; rewired `main.ts`; `src/main.ts` now 2853 LOC. +- [2026-02-20T00:19:13Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/protocol-url-handlers.test.js dist/main/runtime/subtitle-position.test.js dist/main/runtime/anilist-post-watch.test.js dist/main/runtime/anilist-media-guess.test.js dist/main/runtime/anilist-token-refresh.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/anilist-setup-protocol.test.js dist/main/runtime/jellyfin-remote-connection.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (52/52). +- [2026-02-20T00:12:40Z] progress: extracted AniList token/media resolution helpers from `src/main.ts` into `src/main/runtime/anilist-token-refresh.ts` and `src/main/runtime/anilist-media-guess.ts`; rewired `refreshAnilistClientSecretState`, `maybeProbeAnilistDuration`, and `ensureAnilistMediaGuess`; `src/main.ts` now 2892 LOC. +- [2026-02-20T00:12:40Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-media-guess.test.js dist/main/runtime/anilist-token-refresh.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/anilist-setup-protocol.test.js dist/main/runtime/jellyfin-remote-connection.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (45/45). +- [2026-02-20T00:08:57Z] progress: extracted Jellyfin setup window form/submission logic from `src/main.ts` into `src/main/runtime/jellyfin-setup-window.ts` (`buildJellyfinSetupFormHtml`, `parseJellyfinSetupSubmissionUrl`, `createHandleJellyfinSetupSubmissionHandler`); kept window lifecycle in main. +- [2026-02-20T00:08:57Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/anilist-setup-protocol.test.js dist/main/runtime/jellyfin-remote-connection.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (39/39). - [2026-02-20T00:00:55Z] progress: extracted AniList setup/protocol handlers (`notifyAnilistSetup`, `consumeAnilistSetupTokenFromUrl`, `handleAnilistSetupProtocolUrl`, `registerSubminerProtocolClient`) from `src/main.ts` into `src/main/runtime/anilist-setup-protocol.ts`; rewired protocol registration and callback wiring; `src/main.ts` now 2988 LOC. - [2026-02-20T00:00:55Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-protocol.test.js dist/main/runtime/jellyfin-remote-connection.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (35/35). - [2026-02-19T23:58:32Z] progress: extracted Jellyfin MPV connection/bootstrap handlers (`waitForMpvConnected`, `launchMpvIdleForJellyfinPlayback`, `ensureMpvConnectedForJellyfinPlayback`) from `src/main.ts` into `src/main/runtime/jellyfin-remote-connection.ts`; rewired call sites; `src/main.ts` now 2996 LOC. @@ -38,6 +60,36 @@ - `src/main/runtime/jellyfin-remote-connection.test.ts` - `src/main/runtime/anilist-setup-protocol.ts` - `src/main/runtime/anilist-setup-protocol.test.ts` +- `src/main/runtime/jellyfin-setup-window.ts` +- `src/main/runtime/jellyfin-setup-window.test.ts` +- `src/main/runtime/initial-args-handler.ts` +- `src/main/runtime/initial-args-handler.test.ts` +- `src/main/runtime/cli-command-prechecks.ts` +- `src/main/runtime/cli-command-prechecks.test.ts` +- `src/main/runtime/anilist-token-refresh.ts` +- `src/main/runtime/anilist-token-refresh.test.ts` +- `src/main/runtime/anilist-media-guess.ts` +- `src/main/runtime/anilist-media-guess.test.ts` +- `src/main/runtime/anilist-post-watch.ts` +- `src/main/runtime/anilist-post-watch.test.ts` +- `src/main/runtime/subtitle-position.ts` +- `src/main/runtime/subtitle-position.test.ts` +- `src/main/runtime/protocol-url-handlers.ts` +- `src/main/runtime/protocol-url-handlers.test.ts` +- `src/main/runtime/jellyfin-cli-auth.ts` +- `src/main/runtime/jellyfin-cli-auth.test.ts` +- `src/main/runtime/jellyfin-cli-list.ts` +- `src/main/runtime/jellyfin-cli-list.test.ts` +- `src/main/runtime/jellyfin-cli-play.ts` +- `src/main/runtime/jellyfin-cli-play.test.ts` +- `src/main/runtime/jellyfin-cli-remote-announce.ts` +- `src/main/runtime/jellyfin-cli-remote-announce.test.ts` +- `src/main/runtime/jellyfin-remote-session-lifecycle.ts` +- `src/main/runtime/jellyfin-remote-session-lifecycle.test.ts` +- `src/main/runtime/anilist-setup-window.ts` +- `src/main/runtime/anilist-setup-window.test.ts` +- `src/main/runtime/jellyfin-setup-window.ts` +- `src/main/runtime/jellyfin-setup-window.test.ts` ## Open Questions / Blockers @@ -45,5 +97,4 @@ ## Next Step -- identify next extractable `src/main.ts` domain slice, add seam test, extract to `src/main/runtime/*`, run build + targeted tests. -- extract next `src/main.ts` slice likely Jellyfin setup UI branch (`openJellyfinSetupWindow` form handling + config patch helpers) into `src/main/runtime/jellyfin-setup-window.ts` with seam tests. +- extract next `src/main.ts` CLI command context composition slice into helper factory + tests. diff --git a/src/main.ts b/src/main.ts index 2373164..043d239 100644 --- a/src/main.ts +++ b/src/main.ts @@ -94,6 +94,7 @@ import { createNotifyAnilistSetupHandler, createRegisterSubminerProtocolClientHandler, } from './main/runtime/anilist-setup-protocol'; +import { createRefreshAnilistClientSecretStateHandler } from './main/runtime/anilist-token-refresh'; import { createHandleJellyfinRemoteGeneralCommand, createHandleJellyfinRemotePlay, @@ -110,6 +111,53 @@ import { createLaunchMpvIdleForJellyfinPlaybackHandler, createWaitForMpvConnectedHandler, } from './main/runtime/jellyfin-remote-connection'; +import { + createHandleJellyfinSetupWindowClosedHandler, + buildJellyfinSetupFormHtml, + createHandleJellyfinSetupNavigationHandler, + createHandleJellyfinSetupWindowOpenedHandler, + createHandleJellyfinSetupSubmissionHandler, + createMaybeFocusExistingJellyfinSetupWindowHandler, + parseJellyfinSetupSubmissionUrl, +} from './main/runtime/jellyfin-setup-window'; +import { + createHandleAnilistSetupWindowClosedHandler, + createHandleAnilistSetupWindowOpenedHandler, + createMaybeFocusExistingAnilistSetupWindowHandler, + createAnilistSetupDidFailLoadHandler, + createAnilistSetupDidFinishLoadHandler, + createAnilistSetupDidNavigateHandler, + createAnilistSetupFallbackHandler, + createAnilistSetupWillNavigateHandler, + createAnilistSetupWillRedirectHandler, + createAnilistSetupWindowOpenHandler, + createHandleManualAnilistSetupSubmissionHandler, +} from './main/runtime/anilist-setup-window'; +import { + createEnsureAnilistMediaGuessHandler, + createMaybeProbeAnilistDurationHandler, +} from './main/runtime/anilist-media-guess'; +import { + buildAnilistAttemptKey, + createMaybeRunAnilistPostWatchUpdateHandler, + createProcessNextAnilistRetryUpdateHandler, + rememberAnilistAttemptedUpdateKey, +} from './main/runtime/anilist-post-watch'; +import { + createLoadSubtitlePositionHandler, + createSaveSubtitlePositionHandler, +} from './main/runtime/subtitle-position'; +import { registerProtocolUrlHandlers } from './main/runtime/protocol-url-handlers'; +import { createHandleJellyfinAuthCommands } from './main/runtime/jellyfin-cli-auth'; +import { createHandleJellyfinListCommands } from './main/runtime/jellyfin-cli-list'; +import { createHandleJellyfinPlayCommand } from './main/runtime/jellyfin-cli-play'; +import { createHandleJellyfinRemoteAnnounceCommand } from './main/runtime/jellyfin-cli-remote-announce'; +import { + createStartJellyfinRemoteSessionHandler, + createStopJellyfinRemoteSessionHandler, +} from './main/runtime/jellyfin-remote-session-lifecycle'; +import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler'; +import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks'; import { buildRestartRequiredConfigMessage, createConfigHotReloadAppliedHandler, @@ -1020,45 +1068,78 @@ async function playJellyfinItemInMpv(params: { showMpvOsd(`Jellyfin ${plan.mode}: ${plan.title}`); } +const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands({ + patchRawConfig: (patch) => { + configService.patchRawConfig(patch); + }, + authenticateWithPassword: (serverUrl, username, password, clientInfo) => + authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo), + logInfo: (message) => logger.info(message), +}); + +const handleJellyfinListCommands = createHandleJellyfinListCommands({ + listJellyfinLibraries: (session, clientInfo) => listJellyfinLibrariesRuntime(session, clientInfo), + listJellyfinItems: (session, clientInfo, params) => + listJellyfinItemsRuntime(session, clientInfo, params), + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId), + logInfo: (message) => logger.info(message), +}); + +const handleJellyfinPlayCommand = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: (params) => + playJellyfinItemInMpv(params as Parameters[0]), + logWarn: (message) => logger.warn(message), +}); + +const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: () => startJellyfinRemoteSession(), + getRemoteSession: () => appState.jellyfinRemoteSession, + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), +}); + +const startJellyfinRemoteSession = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => getResolvedJellyfinConfig(), + getCurrentSession: () => appState.jellyfinRemoteSession, + setCurrentSession: (session) => { + appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; + }, + createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), + defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, + defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, + defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, + handlePlay: (payload) => handleJellyfinRemotePlay(payload), + handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), + handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), + logInfo: (message) => logger.info(message), + logWarn: (message, details) => logger.warn(message, details), +}); + +const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler({ + getCurrentSession: () => appState.jellyfinRemoteSession, + setCurrentSession: (session) => { + appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; + }, + clearActivePlayback: () => { + activeJellyfinRemotePlayback = null; + }, +}); + async function runJellyfinCommand(args: CliArgs): Promise { const jellyfinConfig = getResolvedJellyfinConfig(); const serverUrl = args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || DEFAULT_CONFIG.jellyfin.serverUrl; const clientInfo = getJellyfinClientInfo(jellyfinConfig); - if (args.jellyfinLogout) { - configService.patchRawConfig({ - jellyfin: { - accessToken: '', - userId: '', - }, - }); - logger.info('Cleared stored Jellyfin access token.'); - return; - } - - if (args.jellyfinLogin) { - const username = (args.jellyfinUsername || jellyfinConfig.username).trim(); - const password = args.jellyfinPassword || ''; - const session = await authenticateWithPasswordRuntime( + if ( + await handleJellyfinAuthCommands({ + args, + jellyfinConfig, serverUrl, - username, - password, clientInfo, - ); - configService.patchRawConfig({ - jellyfin: { - enabled: true, - serverUrl: session.serverUrl, - username: session.username, - accessToken: session.accessToken, - userId: session.userId, - deviceId: clientInfo.deviceId, - clientName: clientInfo.clientName, - clientVersion: clientInfo.clientVersion, - }, - }); - logger.info(`Jellyfin login succeeded for ${session.username}.`); + }) + ) { return; } @@ -1074,174 +1155,31 @@ async function runJellyfinCommand(args: CliArgs): Promise { username: jellyfinConfig.username, }; - if (args.jellyfinRemoteAnnounce) { - await startJellyfinRemoteSession(); - const remoteSession = appState.jellyfinRemoteSession; - if (!remoteSession) { - logger.warn('Jellyfin remote session is not available.'); - return; - } - const visible = await remoteSession.advertiseNow(); - if (visible) { - logger.info('Jellyfin cast target is visible in server sessions.'); - } else { - logger.warn( - 'Jellyfin remote announce sent, but cast target is not visible in server sessions yet.', - ); - } + if (await handleJellyfinRemoteAnnounceCommand(args)) { return; } - if (args.jellyfinLibraries) { - const libraries = await listJellyfinLibrariesRuntime(session, clientInfo); - if (libraries.length === 0) { - logger.info('No Jellyfin libraries found.'); - return; - } - for (const library of libraries) { - logger.info( - `Jellyfin library: ${library.name} [${library.id}] (${library.collectionType || library.type || 'unknown'})`, - ); - } - return; - } - - if (args.jellyfinItems) { - const libraryId = args.jellyfinLibraryId || jellyfinConfig.defaultLibraryId; - if (!libraryId) { - throw new Error( - 'Missing Jellyfin library id. Use --jellyfin-library-id or set jellyfin.defaultLibraryId.', - ); - } - const items = await listJellyfinItemsRuntime(session, clientInfo, { - libraryId, - searchTerm: args.jellyfinSearch, - limit: args.jellyfinLimit ?? 100, - }); - if (items.length === 0) { - logger.info('No Jellyfin items found for the selected library/search.'); - return; - } - for (const item of items) { - logger.info(`Jellyfin item: ${item.title} [${item.id}] (${item.type})`); - } - return; - } - - if (args.jellyfinSubtitles) { - if (!args.jellyfinItemId) { - throw new Error('Missing --jellyfin-item-id for --jellyfin-subtitles.'); - } - const tracks = await listJellyfinSubtitleTracksRuntime( - session, - clientInfo, - args.jellyfinItemId, - ); - if (tracks.length === 0) { - logger.info('No Jellyfin subtitle tracks found for item.'); - return; - } - for (const track of tracks) { - if (args.jellyfinSubtitleUrlsOnly) { - if (track.deliveryUrl) logger.info(track.deliveryUrl); - continue; - } - logger.info( - `Jellyfin subtitle: index=${track.index} lang=${track.language || 'unknown'} title="${track.title || '-'}" method=${track.deliveryMethod || 'unknown'} codec=${track.codec || 'unknown'} default=${track.isDefault ? 'yes' : 'no'} forced=${track.isForced ? 'yes' : 'no'} external=${track.isExternal ? 'yes' : 'no'} url=${track.deliveryUrl || '-'}`, - ); - } - return; - } - - if (args.jellyfinPlay) { - if (!args.jellyfinItemId) { - logger.warn('Ignoring --jellyfin-play without --jellyfin-item-id.'); - return; - } - await playJellyfinItemInMpv({ + if ( + await handleJellyfinListCommands({ + args, session, clientInfo, jellyfinConfig, - itemId: args.jellyfinItemId, - audioStreamIndex: args.jellyfinAudioStreamIndex, - subtitleStreamIndex: args.jellyfinSubtitleStreamIndex, - setQuitOnDisconnectArm: true, - }); + }) + ) { return; } -} -async function startJellyfinRemoteSession(): Promise { - const jellyfinConfig = getResolvedJellyfinConfig(); - if (jellyfinConfig.remoteControlEnabled === false) return; - if (jellyfinConfig.remoteControlAutoConnect === false) return; - if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) { + if ( + await handleJellyfinPlayCommand({ + args, + session, + clientInfo, + jellyfinConfig, + }) + ) { return; } - if (appState.jellyfinRemoteSession) { - appState.jellyfinRemoteSession.stop(); - appState.jellyfinRemoteSession = null; - } - - const service = new JellyfinRemoteSessionService({ - serverUrl: jellyfinConfig.serverUrl, - accessToken: jellyfinConfig.accessToken, - deviceId: jellyfinConfig.deviceId || DEFAULT_CONFIG.jellyfin.deviceId, - clientName: jellyfinConfig.clientName || DEFAULT_CONFIG.jellyfin.clientName, - clientVersion: jellyfinConfig.clientVersion || DEFAULT_CONFIG.jellyfin.clientVersion, - deviceName: - jellyfinConfig.remoteControlDeviceName || - jellyfinConfig.clientName || - DEFAULT_CONFIG.jellyfin.clientName, - capabilities: { - PlayableMediaTypes: 'Video,Audio', - SupportedCommands: - 'Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent', - SupportsMediaControl: true, - }, - onConnected: () => { - logger.info('Jellyfin remote websocket connected.'); - if (jellyfinConfig.autoAnnounce) { - void service.advertiseNow().then((registered) => { - if (registered) { - logger.info('Jellyfin cast target is visible to server sessions.'); - } else { - logger.warn('Jellyfin remote connected but device not visible in server sessions yet.'); - } - }); - } - }, - onDisconnected: () => { - logger.warn('Jellyfin remote websocket disconnected; retrying.'); - }, - onPlay: (payload) => { - void handleJellyfinRemotePlay(payload).catch((error) => { - logger.warn('Failed handling Jellyfin remote Play event', error); - }); - }, - onPlaystate: (payload) => { - void handleJellyfinRemotePlaystate(payload).catch((error) => { - logger.warn('Failed handling Jellyfin remote Playstate event', error); - }); - }, - onGeneralCommand: (payload) => { - void handleJellyfinRemoteGeneralCommand(payload).catch((error) => { - logger.warn('Failed handling Jellyfin remote GeneralCommand event', error); - }); - }, - }); - service.start(); - appState.jellyfinRemoteSession = service; - logger.info( - `Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, - ); -} - -function stopJellyfinRemoteSession(): void { - if (!appState.jellyfinRemoteSession) return; - appState.jellyfinRemoteSession.stop(); - appState.jellyfinRemoteSession = null; - activeJellyfinRemotePlayback = null; } const notifyAnilistSetup = createNotifyAnilistSetupHandler({ @@ -1295,8 +1233,10 @@ const registerSubminerProtocolClient = createRegisterSubminerProtocolClientHandl }); function openAnilistSetupWindow(): void { - if (appState.anilistSetupWindow) { - appState.anilistSetupWindow.focus(); + const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ + getSetupWindow: () => appState.anilistSetupWindow, + }); + if (maybeFocusExistingAnilistSetupWindow()) { return; } @@ -1317,131 +1257,123 @@ function openAnilistSetupWindow(): void { responseType: ANILIST_SETUP_RESPONSE_TYPE, }); const consumeCallbackUrl = (rawUrl: string): boolean => consumeAnilistSetupTokenFromUrl(rawUrl); - - const handleManualAnilistSetupSubmission = (rawUrl: string): boolean => { - if (!rawUrl.startsWith('subminer://anilist-setup')) { - return false; - } - try { - const parsed = new URL(rawUrl); - const accessToken = parsed.searchParams.get('access_token')?.trim() ?? ''; - if (accessToken.length > 0) { - return consumeCallbackUrl( - `${ANILIST_REDIRECT_URI}#access_token=${encodeURIComponent(accessToken)}`, - ); - } - logger.warn('AniList setup submission missing access token'); - return true; - } catch { - logger.warn('AniList setup submission had invalid callback input'); - return true; - } - }; - - setupWindow.webContents.setWindowOpenHandler(({ url }) => { - if (!isAllowedAnilistExternalUrl(url)) { - logger.warn('Blocked unsafe AniList setup external URL', { url }); - return { action: 'deny' }; - } - void shell.openExternal(url); - return { action: 'deny' }; + const openSetupInBrowser = () => + openAnilistSetupInBrowser({ + authorizeUrl, + openExternal: (url) => shell.openExternal(url), + logError: (message, error) => logger.error(message, error), + }); + const loadManualTokenEntry = () => + loadAnilistManualTokenEntry({ + setupWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), + }); + const handleManualAnilistSetupSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl), + redirectUri: ANILIST_REDIRECT_URI, + logWarn: (message) => logger.warn(message), }); + const anilistSetupFallback = createAnilistSetupFallbackHandler({ + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + setupWindow, + openSetupInBrowser, + loadManualTokenEntry, + logError: (message, details) => logger.error(message, details), + logWarn: (message) => logger.warn(message), + }); + const handleAnilistSetupWindowOpen = createAnilistSetupWindowOpenHandler({ + isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url), + openExternal: (url) => { + void shell.openExternal(url); + }, + logWarn: (message, details) => logger.warn(message, details), + }); + const handleAnilistSetupWillNavigate = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: (url) => handleManualAnilistSetupSubmission(url), + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + redirectUri: ANILIST_REDIRECT_URI, + isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url), + logWarn: (message, details) => logger.warn(message, details), + }); + const handleAnilistSetupWillRedirect = createAnilistSetupWillRedirectHandler({ + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + }); + const handleAnilistSetupDidNavigate = createAnilistSetupDidNavigateHandler({ + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + }); + const handleAnilistSetupDidFailLoad = createAnilistSetupDidFailLoadHandler({ + onLoadFailure: (details) => anilistSetupFallback.onLoadFailure(details), + }); + const handleAnilistSetupDidFinishLoad = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => setupWindow.webContents.getURL(), + onBlankPageLoaded: () => anilistSetupFallback.onBlankPageLoaded(), + }); + const handleAnilistSetupWindowClosed = createHandleAnilistSetupWindowClosedHandler({ + clearSetupWindow: () => { + appState.anilistSetupWindow = null; + }, + setSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + }); + const handleAnilistSetupWindowOpened = createHandleAnilistSetupWindowOpenedHandler({ + setSetupWindow: () => { + appState.anilistSetupWindow = setupWindow; + }, + setSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + }); + + setupWindow.webContents.setWindowOpenHandler(({ url }) => handleAnilistSetupWindowOpen({ url })); setupWindow.webContents.on('will-navigate', (event, url) => { - if (handleManualAnilistSetupSubmission(url)) { - event.preventDefault(); - return; - } - if (consumeCallbackUrl(url)) { - event.preventDefault(); - return; - } - if (url.startsWith(ANILIST_REDIRECT_URI)) { - event.preventDefault(); - return; - } - if (url.startsWith(`${ANILIST_REDIRECT_URI}#`)) { - event.preventDefault(); - return; - } - if (isAllowedAnilistSetupNavigationUrl(url)) { - return; - } - event.preventDefault(); - logger.warn('Blocked unsafe AniList setup navigation URL', { url }); + handleAnilistSetupWillNavigate({ + url, + preventDefault: () => event.preventDefault(), + }); }); setupWindow.webContents.on('will-redirect', (event, url) => { - if (!consumeCallbackUrl(url)) { - return; - } - event.preventDefault(); + handleAnilistSetupWillRedirect({ + url, + preventDefault: () => event.preventDefault(), + }); }); setupWindow.webContents.on('did-navigate', (_event, url) => { - consumeCallbackUrl(url); + handleAnilistSetupDidNavigate(url); }); setupWindow.webContents.on( 'did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { - logger.error('AniList setup window failed to load', { + handleAnilistSetupDidFailLoad({ errorCode, errorDescription, validatedURL, }); - openAnilistSetupInBrowser({ - authorizeUrl, - openExternal: (url) => shell.openExternal(url), - logError: (message, error) => logger.error(message, error), - }); - if (!setupWindow.isDestroyed()) { - loadAnilistManualTokenEntry({ - setupWindow, - authorizeUrl, - developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - logWarn: (message, data) => logger.warn(message, data), - }); - } }, ); setupWindow.webContents.on('did-finish-load', () => { - const loadedUrl = setupWindow.webContents.getURL(); - if (!loadedUrl || loadedUrl === 'about:blank') { - logger.warn('AniList setup loaded a blank page; using fallback'); - openAnilistSetupInBrowser({ - authorizeUrl, - openExternal: (url) => shell.openExternal(url), - logError: (message, error) => logger.error(message, error), - }); - if (!setupWindow.isDestroyed()) { - loadAnilistManualTokenEntry({ - setupWindow, - authorizeUrl, - developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - logWarn: (message, data) => logger.warn(message, data), - }); - } - } + handleAnilistSetupDidFinishLoad(); }); - loadAnilistManualTokenEntry({ - setupWindow, - authorizeUrl, - developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - logWarn: (message, data) => logger.warn(message, data), - }); + loadManualTokenEntry(); setupWindow.on('closed', () => { - appState.anilistSetupWindow = null; - appState.anilistSetupPageOpened = false; + handleAnilistSetupWindowClosed(); }); - appState.anilistSetupWindow = setupWindow; - appState.anilistSetupPageOpened = true; + handleAnilistSetupWindowOpened(); } function openJellyfinSetupWindow(): void { - if (appState.jellyfinSetupWindow) { - appState.jellyfinSetupWindow.focus(); + const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler({ + getSetupWindow: () => appState.jellyfinSetupWindow, + }); + if (maybeFocusExistingJellyfinSetupWindow()) { return; } @@ -1460,164 +1392,87 @@ function openJellyfinSetupWindow(): void { const defaults = getResolvedJellyfinConfig(); const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096'; const defaultUser = defaults.username || ''; - - const formHtml = ` - - - - Jellyfin Setup - - - -
-

Jellyfin Setup

-

Login info is used to fetch a token and save Jellyfin config values.

-
- - - - - - - -
Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...
-
-
- - -`; + const formHtml = buildJellyfinSetupFormHtml(defaultServer, defaultUser); + const handleJellyfinSetupSubmission = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: (server, username, password, clientInfo) => + authenticateWithPasswordRuntime(server, username, password, clientInfo), + getJellyfinClientInfo: () => getJellyfinClientInfo(), + patchJellyfinConfig: (session) => { + configService.patchRawConfig({ + jellyfin: { + enabled: true, + serverUrl: session.serverUrl, + username: session.username, + accessToken: session.accessToken, + userId: session.userId, + }, + }); + }, + logInfo: (message) => logger.info(message), + logError: (message, error) => logger.error(message, error), + showMpvOsd: (message) => showMpvOsd(message), + closeSetupWindow: () => { + if (!setupWindow.isDestroyed()) { + setupWindow.close(); + } + }, + }); + const handleJellyfinSetupNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: (rawUrl) => handleJellyfinSetupSubmission(rawUrl), + logError: (message, error) => logger.error(message, error), + }); + const handleJellyfinSetupWindowClosed = createHandleJellyfinSetupWindowClosedHandler({ + clearSetupWindow: () => { + appState.jellyfinSetupWindow = null; + }, + }); + const handleJellyfinSetupWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({ + setSetupWindow: () => { + appState.jellyfinSetupWindow = setupWindow; + }, + }); setupWindow.webContents.on('will-navigate', (event, url) => { - if (!url.startsWith('subminer://jellyfin-setup')) return; - event.preventDefault(); - void (async () => { - try { - const parsed = new URL(url); - const server = parsed.searchParams.get('server') || ''; - const username = parsed.searchParams.get('username') || ''; - const password = parsed.searchParams.get('password') || ''; - const session = await authenticateWithPasswordRuntime( - server, - username, - password, - getJellyfinClientInfo(), - ); - configService.patchRawConfig({ - jellyfin: { - enabled: true, - serverUrl: session.serverUrl, - username: session.username, - accessToken: session.accessToken, - userId: session.userId, - }, - }); - logger.info(`Jellyfin setup saved for ${session.username}.`); - showMpvOsd('Jellyfin login success'); - if (!setupWindow.isDestroyed()) { - setupWindow.close(); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.error('Jellyfin setup failed', error); - showMpvOsd(`Jellyfin login failed: ${message}`); - } - })(); + handleJellyfinSetupNavigation({ + url, + preventDefault: () => event.preventDefault(), + }); }); void setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(formHtml)}`); setupWindow.on('closed', () => { - appState.jellyfinSetupWindow = null; + handleJellyfinSetupWindowClosed(); }); - appState.jellyfinSetupWindow = setupWindow; + handleJellyfinSetupWindowOpened(); } -async function refreshAnilistClientSecretState(options?: { - force?: boolean; -}): Promise { - const resolved = getResolvedConfig(); - const now = Date.now(); - if (!isAnilistTrackingEnabled(resolved)) { - anilistCachedAccessToken = null; - anilistStateRuntime.setClientSecretState({ - status: 'not_checked', - source: 'none', - message: 'anilist tracking disabled', - resolvedAt: null, - errorAt: null, - }); - appState.anilistSetupPageOpened = false; - return null; - } - const rawAccessToken = resolved.anilist.accessToken.trim(); - if (rawAccessToken.length > 0) { - if (options?.force || rawAccessToken !== anilistCachedAccessToken) { - anilistTokenStore.saveToken(rawAccessToken); - } - anilistCachedAccessToken = rawAccessToken; - anilistStateRuntime.setClientSecretState({ - status: 'resolved', - source: 'literal', - message: 'using configured anilist.accessToken', - resolvedAt: now, - errorAt: null, - }); - appState.anilistSetupPageOpened = false; - return rawAccessToken; - } - - if (!options?.force && anilistCachedAccessToken && anilistCachedAccessToken.length > 0) { - return anilistCachedAccessToken; - } - - const storedToken = anilistTokenStore.loadToken()?.trim() ?? ''; - if (storedToken.length > 0) { - anilistCachedAccessToken = storedToken; - anilistStateRuntime.setClientSecretState({ - status: 'resolved', - source: 'stored', - message: 'using stored anilist access token', - resolvedAt: now, - errorAt: null, - }); - appState.anilistSetupPageOpened = false; - return storedToken; - } - - anilistCachedAccessToken = null; - anilistStateRuntime.setClientSecretState({ - status: 'error', - source: 'none', - message: 'cannot authenticate without anilist.accessToken', - resolvedAt: null, - errorAt: now, - }); - if (isAnilistTrackingEnabled(resolved) && !appState.anilistSetupPageOpened) { +const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => getResolvedConfig(), + isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config), + getCachedAccessToken: () => anilistCachedAccessToken, + setCachedAccessToken: (token) => { + anilistCachedAccessToken = token; + }, + saveStoredToken: (token) => { + anilistTokenStore.saveToken(token); + }, + loadStoredToken: () => anilistTokenStore.loadToken(), + setClientSecretState: (state) => { + anilistStateRuntime.setClientSecretState(state); + }, + getAnilistSetupPageOpened: () => appState.anilistSetupPageOpened, + setAnilistSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + openAnilistSetupWindow: () => { openAnilistSetupWindow(); - } - return null; -} + }, + now: () => Date.now(), +}); function getCurrentAnilistMediaKey(): string | null { const path = appState.currentMediaPath?.trim(); @@ -1632,233 +1487,168 @@ function resetAnilistMediaTracking(mediaKey: string | null): void { anilistLastDurationProbeAtMs = 0; } -async function maybeProbeAnilistDuration(mediaKey: string): Promise { - if (anilistCurrentMediaKey !== mediaKey) { - return null; - } - if (typeof anilistCurrentMediaDurationSec === 'number' && anilistCurrentMediaDurationSec > 0) { - return anilistCurrentMediaDurationSec; - } - const now = Date.now(); - if (now - anilistLastDurationProbeAtMs < ANILIST_DURATION_RETRY_INTERVAL_MS) { - return null; - } - anilistLastDurationProbeAtMs = now; +const getAnilistMediaGuessRuntimeState = () => ({ + mediaKey: anilistCurrentMediaKey, + mediaDurationSec: anilistCurrentMediaDurationSec, + mediaGuess: anilistCurrentMediaGuess, + mediaGuessPromise: anilistCurrentMediaGuessPromise, + lastDurationProbeAtMs: anilistLastDurationProbeAtMs, +}); - try { - const durationCandidate = await appState.mpvClient?.requestProperty('duration'); - const duration = - typeof durationCandidate === 'number' && Number.isFinite(durationCandidate) - ? durationCandidate - : null; - if (duration && duration > 0 && anilistCurrentMediaKey === mediaKey) { - anilistCurrentMediaDurationSec = duration; - return duration; - } - } catch (error) { - logger.warn('AniList duration probe failed:', error); - } - return null; -} +const setAnilistMediaGuessRuntimeState = (state: { + mediaKey: string | null; + mediaDurationSec: number | null; + mediaGuess: AnilistMediaGuess | null; + mediaGuessPromise: Promise | null; + lastDurationProbeAtMs: number; +}) => { + anilistCurrentMediaKey = state.mediaKey; + anilistCurrentMediaDurationSec = state.mediaDurationSec; + anilistCurrentMediaGuess = state.mediaGuess; + anilistCurrentMediaGuessPromise = state.mediaGuessPromise; + anilistLastDurationProbeAtMs = state.lastDurationProbeAtMs; +}; -async function ensureAnilistMediaGuess(mediaKey: string): Promise { - if (anilistCurrentMediaKey !== mediaKey) { - return null; - } - if (anilistCurrentMediaGuess) { - return anilistCurrentMediaGuess; - } - if (anilistCurrentMediaGuessPromise) { - return anilistCurrentMediaGuessPromise; - } +const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler({ + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + durationRetryIntervalMs: ANILIST_DURATION_RETRY_INTERVAL_MS, + now: () => Date.now(), + requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'), + logWarn: (message, error) => logger.warn(message, error), +}); - const mediaPathForGuess = mediaRuntime.resolveMediaPathForJimaku(appState.currentMediaPath); - anilistCurrentMediaGuessPromise = guessAnilistMediaInfo( - mediaPathForGuess, - appState.currentMediaTitle, - ) - .then((guess) => { - if (anilistCurrentMediaKey === mediaKey) { - anilistCurrentMediaGuess = guess; - } - return guess; - }) - .finally(() => { - if (anilistCurrentMediaKey === mediaKey) { - anilistCurrentMediaGuessPromise = null; - } - }); - return anilistCurrentMediaGuessPromise; -} +const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler({ + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + resolveMediaPathForJimaku: (currentMediaPath) => mediaRuntime.resolveMediaPathForJimaku(currentMediaPath), + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), +}); -function buildAnilistAttemptKey(mediaKey: string, episode: number): string { - return `${mediaKey}::${episode}`; -} +const rememberAnilistAttemptedUpdate = (key: string): void => { + rememberAnilistAttemptedUpdateKey(anilistAttemptedUpdateKeys, key, ANILIST_MAX_ATTEMPTED_UPDATE_KEYS); +}; -function rememberAnilistAttemptedUpdateKey(key: string): void { - anilistAttemptedUpdateKeys.add(key); - if (anilistAttemptedUpdateKeys.size <= ANILIST_MAX_ATTEMPTED_UPDATE_KEYS) { - return; - } - const oldestKey = anilistAttemptedUpdateKeys.values().next().value; - if (typeof oldestKey === 'string') { - anilistAttemptedUpdateKeys.delete(oldestKey); - } -} +const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler({ + nextReady: () => anilistUpdateQueue.nextReady(), + refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), + setLastAttemptAt: (value) => { + appState.anilistRetryQueueState.lastAttemptAt = value; + }, + setLastError: (value) => { + appState.anilistRetryQueueState.lastError = value; + }, + refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress(accessToken, title, episode), + markSuccess: (key) => { + anilistUpdateQueue.markSuccess(key); + }, + rememberAttemptedUpdateKey: (key) => { + rememberAnilistAttemptedUpdate(key); + }, + markFailure: (key, message) => { + anilistUpdateQueue.markFailure(key, message); + }, + logInfo: (message) => logger.info(message), + now: () => Date.now(), +}); -async function processNextAnilistRetryUpdate(): Promise<{ - ok: boolean; - message: string; -}> { - const queued = anilistUpdateQueue.nextReady(); - anilistStateRuntime.refreshRetryQueueState(); - if (!queued) { - return { ok: true, message: 'AniList queue has no ready items.' }; - } - - appState.anilistRetryQueueState.lastAttemptAt = Date.now(); - const accessToken = await refreshAnilistClientSecretState(); - if (!accessToken) { - appState.anilistRetryQueueState.lastError = 'AniList token unavailable for queued retry.'; - return { ok: false, message: appState.anilistRetryQueueState.lastError }; - } - - const result = await updateAnilistPostWatchProgress(accessToken, queued.title, queued.episode); - if (result.status === 'updated' || result.status === 'skipped') { - anilistUpdateQueue.markSuccess(queued.key); - rememberAnilistAttemptedUpdateKey(queued.key); - appState.anilistRetryQueueState.lastError = null; - anilistStateRuntime.refreshRetryQueueState(); - logger.info(`[AniList queue] ${result.message}`); - return { ok: true, message: result.message }; - } - - anilistUpdateQueue.markFailure(queued.key, result.message); - appState.anilistRetryQueueState.lastError = result.message; - anilistStateRuntime.refreshRetryQueueState(); - return { ok: false, message: result.message }; -} - -async function maybeRunAnilistPostWatchUpdate(): Promise { - if (anilistUpdateInFlight) { - return; - } - - const resolved = getResolvedConfig(); - if (!isAnilistTrackingEnabled(resolved)) { - return; - } - - const mediaKey = getCurrentAnilistMediaKey(); - if (!mediaKey || !appState.mpvClient) { - return; - } - if (anilistCurrentMediaKey !== mediaKey) { +const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => anilistUpdateInFlight, + setInFlight: (value) => { + anilistUpdateInFlight = value; + }, + getResolvedConfig: () => getResolvedConfig(), + isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), + getCurrentMediaKey: () => getCurrentAnilistMediaKey(), + hasMpvClient: () => Boolean(appState.mpvClient), + getTrackedMediaKey: () => anilistCurrentMediaKey, + resetTrackedMedia: (mediaKey) => { resetAnilistMediaTracking(mediaKey); - } + }, + getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN, + maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey), + hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), + enqueueRetry: (key, title, episode) => { + anilistUpdateQueue.enqueue(key, title, episode); + }, + markRetryFailure: (key, message) => { + anilistUpdateQueue.markFailure(key, message); + }, + markRetrySuccess: (key) => { + anilistUpdateQueue.markSuccess(key); + }, + refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress(accessToken, title, episode), + rememberAttemptedUpdateKey: (key) => { + rememberAnilistAttemptedUpdate(key); + }, + showMpvOsd: (message) => showMpvOsd(message), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, + minWatchRatio: ANILIST_UPDATE_MIN_WATCH_RATIO, +}); - const watchedSeconds = appState.mpvClient.currentTimePos; - if (!Number.isFinite(watchedSeconds) || watchedSeconds < ANILIST_UPDATE_MIN_WATCH_SECONDS) { - return; - } +const loadSubtitlePosition = createLoadSubtitlePositionHandler({ + loadSubtitlePositionCore: () => + loadSubtitlePositionCore({ + currentMediaPath: appState.currentMediaPath, + fallbackPosition: getResolvedConfig().subtitlePosition, + subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, + }), + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, +}); - const duration = await maybeProbeAnilistDuration(mediaKey); - if (!duration || duration <= 0) { - return; - } - if (watchedSeconds / duration < ANILIST_UPDATE_MIN_WATCH_RATIO) { - return; - } - - const guess = await ensureAnilistMediaGuess(mediaKey); - if (!guess?.title || !guess.episode || guess.episode <= 0) { - return; - } - - const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode); - if (anilistAttemptedUpdateKeys.has(attemptKey)) { - return; - } - - anilistUpdateInFlight = true; - try { - await processNextAnilistRetryUpdate(); - - const accessToken = await refreshAnilistClientSecretState(); - if (!accessToken) { - anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode); - anilistUpdateQueue.markFailure(attemptKey, 'cannot authenticate without anilist.accessToken'); - anilistStateRuntime.refreshRetryQueueState(); - showMpvOsd('AniList: access token not configured'); - return; - } - const result = await updateAnilistPostWatchProgress(accessToken, guess.title, guess.episode); - if (result.status === 'updated') { - rememberAnilistAttemptedUpdateKey(attemptKey); - anilistUpdateQueue.markSuccess(attemptKey); - anilistStateRuntime.refreshRetryQueueState(); - showMpvOsd(result.message); - logger.info(result.message); - return; - } - if (result.status === 'skipped') { - rememberAnilistAttemptedUpdateKey(attemptKey); - anilistUpdateQueue.markSuccess(attemptKey); - anilistStateRuntime.refreshRetryQueueState(); - logger.info(result.message); - return; - } - anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode); - anilistUpdateQueue.markFailure(attemptKey, result.message); - anilistStateRuntime.refreshRetryQueueState(); - showMpvOsd(`AniList: ${result.message}`); - logger.warn(result.message); - } finally { - anilistUpdateInFlight = false; - } -} - -function loadSubtitlePosition(): SubtitlePosition | null { - appState.subtitlePosition = loadSubtitlePositionCore({ - currentMediaPath: appState.currentMediaPath, - fallbackPosition: getResolvedConfig().subtitlePosition, - subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, - }); - return appState.subtitlePosition; -} - -function saveSubtitlePosition(position: SubtitlePosition): void { - appState.subtitlePosition = position; - saveSubtitlePositionCore({ - position, - currentMediaPath: appState.currentMediaPath, - subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, - onQueuePending: (queued) => { - appState.pendingSubtitlePosition = queued; - }, - onPersisted: () => { - appState.pendingSubtitlePosition = null; - }, - }); -} +const saveSubtitlePosition = createSaveSubtitlePositionHandler({ + saveSubtitlePositionCore: (position) => { + saveSubtitlePositionCore({ + position, + currentMediaPath: appState.currentMediaPath, + subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, + onQueuePending: (queued) => { + appState.pendingSubtitlePosition = queued; + }, + onPersisted: () => { + appState.pendingSubtitlePosition = null; + }, + }); + }, + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, +}); registerSubminerProtocolClient(); -app.on('open-url', (event, rawUrl) => { - event.preventDefault(); - if (!handleAnilistSetupProtocolUrl(rawUrl)) { +registerProtocolUrlHandlers({ + registerOpenUrl: (listener) => { + app.on('open-url', listener); + }, + registerSecondInstance: (listener) => { + app.on('second-instance', listener); + }, + handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl), + findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv), + logUnhandledOpenUrl: (rawUrl) => { logger.warn('Unhandled app protocol URL', { rawUrl }); - } -}); - -app.on('second-instance', (_event, argv) => { - const rawUrl = findAnilistSetupDeepLinkArgvUrl(argv); - if (!rawUrl) { - return; - } - if (!handleAnilistSetupProtocolUrl(rawUrl)) { + }, + logUnhandledSecondInstanceUrl: (rawUrl) => { logger.warn('Unhandled second-instance protocol URL', { rawUrl }); - } + }, }); const startupState = runStartupBootstrapRuntime( @@ -2071,15 +1861,15 @@ void refreshAnilistClientSecretState({ force: true }); anilistStateRuntime.refreshRetryQueueState(); function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): void { - if ( - appState.texthookerOnlyMode && - !args.texthooker && - (args.start || commandNeedsOverlayRuntime(args)) - ) { - appState.texthookerOnlyMode = false; - logger.info('Disabling texthooker-only mode after overlay/start command.'); - startBackgroundWarmups(); - } + createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + setTexthookerOnlyMode: (enabled) => { + appState.texthookerOnlyMode = enabled; + }, + commandNeedsOverlayRuntime: (inputArgs) => commandNeedsOverlayRuntime(inputArgs), + startBackgroundWarmups: () => startBackgroundWarmups(), + logInfo: (message) => logger.info(message), + })(args); handleCliCommandRuntimeServiceWithContext(args, source, { getSocketPath: () => appState.mpvSocketPath, @@ -2143,20 +1933,16 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): } function handleInitialArgs(): void { - if (!appState.initialArgs) return; - if (appState.backgroundMode) { - ensureTray(); - } - if ( - !appState.texthookerOnlyMode && - appState.immersionTracker && - appState.mpvClient && - !appState.mpvClient.connected - ) { - logger.info('Auto-connecting MPV client for immersion tracking'); - appState.mpvClient.connect(); - } - handleCliCommand(appState.initialArgs, 'initial'); + createHandleInitialArgsHandler({ + getInitialArgs: () => appState.initialArgs, + isBackgroundMode: () => appState.backgroundMode, + ensureTray: () => ensureTray(), + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + hasImmersionTracker: () => Boolean(appState.immersionTracker), + getMpvClient: () => appState.mpvClient, + logInfo: (message) => logger.info(message), + handleCliCommand: (args, source) => handleCliCommand(args, source), + })(); } function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts new file mode 100644 index 0000000..6a862c6 --- /dev/null +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEnsureAnilistMediaGuessHandler, + createMaybeProbeAnilistDurationHandler, + type AnilistMediaGuessRuntimeState, +} from './anilist-media-guess'; + +test('maybeProbeAnilistDuration updates state with probed duration', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + const probe = createMaybeProbeAnilistDurationHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + durationRetryIntervalMs: 1000, + now: () => 2000, + requestMpvDuration: async () => 321, + logWarn: () => {}, + }); + + const duration = await probe('/tmp/video.mkv'); + assert.equal(duration, 321); + assert.equal(state.mediaDurationSec, 321); +}); + +test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + let calls = 0; + const ensureGuess = createEnsureAnilistMediaGuessHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + resolveMediaPathForJimaku: (value) => value, + getCurrentMediaPath: () => '/tmp/video.mkv', + getCurrentMediaTitle: () => 'Episode 1', + guessAnilistMediaInfo: async () => { + calls += 1; + return { title: 'Show', episode: 1, source: 'guessit' }; + }, + }); + + const [first, second] = await Promise.all([ + ensureGuess('/tmp/video.mkv'), + ensureGuess('/tmp/video.mkv'), + ]); + assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' }); + assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' }); + assert.equal(calls, 1); + assert.deepEqual(state.mediaGuess, { title: 'Show', episode: 1, source: 'guessit' }); + assert.equal(state.mediaGuessPromise, null); +}); diff --git a/src/main/runtime/anilist-media-guess.ts b/src/main/runtime/anilist-media-guess.ts new file mode 100644 index 0000000..7a0a799 --- /dev/null +++ b/src/main/runtime/anilist-media-guess.ts @@ -0,0 +1,112 @@ +import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; + +export type AnilistMediaGuessRuntimeState = { + mediaKey: string | null; + mediaDurationSec: number | null; + mediaGuess: AnilistMediaGuess | null; + mediaGuessPromise: Promise | null; + lastDurationProbeAtMs: number; +}; + +type GuessAnilistMediaInfo = ( + mediaPath: string | null, + mediaTitle: string | null, +) => Promise; + +export function createMaybeProbeAnilistDurationHandler(deps: { + getState: () => AnilistMediaGuessRuntimeState; + setState: (state: AnilistMediaGuessRuntimeState) => void; + durationRetryIntervalMs: number; + now: () => number; + requestMpvDuration: () => Promise; + logWarn: (message: string, error: unknown) => void; +}) { + return async (mediaKey: string): Promise => { + const state = deps.getState(); + if (state.mediaKey !== mediaKey) { + return null; + } + if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) { + return state.mediaDurationSec; + } + const now = deps.now(); + if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) { + return null; + } + + deps.setState({ + ...state, + lastDurationProbeAtMs: now, + }); + + try { + const durationCandidate = await deps.requestMpvDuration(); + const duration = + typeof durationCandidate === 'number' && Number.isFinite(durationCandidate) + ? durationCandidate + : null; + const latestState = deps.getState(); + if (duration && duration > 0 && latestState.mediaKey === mediaKey) { + deps.setState({ + ...latestState, + mediaDurationSec: duration, + }); + return duration; + } + } catch (error) { + deps.logWarn('AniList duration probe failed:', error); + } + return null; + }; +} + +export function createEnsureAnilistMediaGuessHandler(deps: { + getState: () => AnilistMediaGuessRuntimeState; + setState: (state: AnilistMediaGuessRuntimeState) => void; + resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null; + getCurrentMediaPath: () => string | null; + getCurrentMediaTitle: () => string | null; + guessAnilistMediaInfo: GuessAnilistMediaInfo; +}) { + return async (mediaKey: string): Promise => { + const state = deps.getState(); + if (state.mediaKey !== mediaKey) { + return null; + } + if (state.mediaGuess) { + return state.mediaGuess; + } + if (state.mediaGuessPromise) { + return state.mediaGuessPromise; + } + + const mediaPathForGuess = deps.resolveMediaPathForJimaku(deps.getCurrentMediaPath()); + const promise = deps + .guessAnilistMediaInfo(mediaPathForGuess, deps.getCurrentMediaTitle()) + .then((guess) => { + const latestState = deps.getState(); + if (latestState.mediaKey === mediaKey) { + deps.setState({ + ...latestState, + mediaGuess: guess, + }); + } + return guess; + }) + .finally(() => { + const latestState = deps.getState(); + if (latestState.mediaKey === mediaKey) { + deps.setState({ + ...latestState, + mediaGuessPromise: null, + }); + } + }); + + deps.setState({ + ...state, + mediaGuessPromise: promise, + }); + return promise; + }; +} diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts new file mode 100644 index 0000000..0b95dcf --- /dev/null +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAnilistAttemptKey, + createMaybeRunAnilistPostWatchUpdateHandler, + createProcessNextAnilistRetryUpdateHandler, + rememberAnilistAttemptedUpdateKey, +} from './anilist-post-watch'; + +test('buildAnilistAttemptKey formats media and episode', () => { + assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3'); +}); + +test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => { + const set = new Set(['a', 'b']); + rememberAnilistAttemptedUpdateKey(set, 'c', 2); + assert.deepEqual(Array.from(set), ['b', 'c']); +}); + +test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { + const calls: string[] = []; + const handler = createProcessNextAnilistRetryUpdateHandler({ + nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }), + refreshRetryQueueState: () => calls.push('refresh'), + setLastAttemptAt: () => calls.push('attempt'), + setLastError: (value) => calls.push(`error:${value ?? 'null'}`), + refreshAnilistClientSecretState: async () => 'token', + updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }), + markSuccess: () => calls.push('success'), + rememberAttemptedUpdateKey: () => calls.push('remember'), + markFailure: () => calls.push('failure'), + logInfo: () => calls.push('info'), + now: () => 1, + }); + + const result = await handler(); + assert.deepEqual(result, { ok: true, message: 'updated ok' }); + assert.ok(calls.includes('success')); + assert.ok(calls.includes('remember')); +}); + +test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => { + const calls: string[] = []; + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => '/tmp/video.mkv', + hasMpvClient: () => true, + getTrackedMediaKey: () => '/tmp/video.mkv', + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => 1000, + ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }), + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), + refreshAnilistClientSecretState: async () => null, + enqueueRetry: () => calls.push('enqueue'), + markRetryFailure: () => calls.push('mark-failure'), + markRetrySuccess: () => calls.push('mark-success'), + refreshRetryQueueState: () => calls.push('refresh'), + updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }), + rememberAttemptedUpdateKey: () => calls.push('remember'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + minWatchSeconds: 600, + minWatchRatio: 0.85, + }); + + await handler(); + assert.ok(calls.includes('enqueue')); + assert.ok(calls.includes('mark-failure')); + assert.ok(calls.includes('osd:AniList: access token not configured')); + assert.ok(calls.includes('inflight:true')); + assert.ok(calls.includes('inflight:false')); +}); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts new file mode 100644 index 0000000..1deb055 --- /dev/null +++ b/src/main/runtime/anilist-post-watch.ts @@ -0,0 +1,195 @@ +type AnilistGuess = { + title: string; + episode: number | null; +}; + +type AnilistUpdateResult = { + status: 'updated' | 'skipped' | 'error'; + message: string; +}; + +type RetryQueueItem = { + key: string; + title: string; + episode: number; +}; + +export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { + return `${mediaKey}::${episode}`; +} + +export function rememberAnilistAttemptedUpdateKey( + attemptedKeys: Set, + key: string, + maxSize: number, +): void { + attemptedKeys.add(key); + if (attemptedKeys.size <= maxSize) { + return; + } + const oldestKey = attemptedKeys.values().next().value; + if (typeof oldestKey === 'string') { + attemptedKeys.delete(oldestKey); + } +} + +export function createProcessNextAnilistRetryUpdateHandler(deps: { + nextReady: () => RetryQueueItem | null; + refreshRetryQueueState: () => void; + setLastAttemptAt: (value: number) => void; + setLastError: (value: string | null) => void; + refreshAnilistClientSecretState: () => Promise; + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + ) => Promise; + markSuccess: (key: string) => void; + rememberAttemptedUpdateKey: (key: string) => void; + markFailure: (key: string, message: string) => void; + logInfo: (message: string) => void; + now: () => number; +}) { + return async (): Promise<{ ok: boolean; message: string }> => { + const queued = deps.nextReady(); + deps.refreshRetryQueueState(); + if (!queued) { + return { ok: true, message: 'AniList queue has no ready items.' }; + } + + deps.setLastAttemptAt(deps.now()); + const accessToken = await deps.refreshAnilistClientSecretState(); + if (!accessToken) { + deps.setLastError('AniList token unavailable for queued retry.'); + return { ok: false, message: 'AniList token unavailable for queued retry.' }; + } + + const result = await deps.updateAnilistPostWatchProgress(accessToken, queued.title, queued.episode); + if (result.status === 'updated' || result.status === 'skipped') { + deps.markSuccess(queued.key); + deps.rememberAttemptedUpdateKey(queued.key); + deps.setLastError(null); + deps.refreshRetryQueueState(); + deps.logInfo(`[AniList queue] ${result.message}`); + return { ok: true, message: result.message }; + } + + deps.markFailure(queued.key, result.message); + deps.setLastError(result.message); + deps.refreshRetryQueueState(); + return { ok: false, message: result.message }; + }; +} + +export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { + getInFlight: () => boolean; + setInFlight: (value: boolean) => void; + getResolvedConfig: () => unknown; + isAnilistTrackingEnabled: (config: unknown) => boolean; + getCurrentMediaKey: () => string | null; + hasMpvClient: () => boolean; + getTrackedMediaKey: () => string | null; + resetTrackedMedia: (mediaKey: string | null) => void; + getWatchedSeconds: () => number; + maybeProbeAnilistDuration: (mediaKey: string) => Promise; + ensureAnilistMediaGuess: (mediaKey: string) => Promise; + hasAttemptedUpdateKey: (key: string) => boolean; + processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>; + refreshAnilistClientSecretState: () => Promise; + enqueueRetry: (key: string, title: string, episode: number) => void; + markRetryFailure: (key: string, message: string) => void; + markRetrySuccess: (key: string) => void; + refreshRetryQueueState: () => void; + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + ) => Promise; + rememberAttemptedUpdateKey: (key: string) => void; + showMpvOsd: (message: string) => void; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + minWatchSeconds: number; + minWatchRatio: number; +}) { + return async (): Promise => { + if (deps.getInFlight()) { + return; + } + + const resolved = deps.getResolvedConfig(); + if (!deps.isAnilistTrackingEnabled(resolved)) { + return; + } + + const mediaKey = deps.getCurrentMediaKey(); + if (!mediaKey || !deps.hasMpvClient()) { + return; + } + if (deps.getTrackedMediaKey() !== mediaKey) { + deps.resetTrackedMedia(mediaKey); + } + + const watchedSeconds = deps.getWatchedSeconds(); + if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { + return; + } + + const duration = await deps.maybeProbeAnilistDuration(mediaKey); + if (!duration || duration <= 0) { + return; + } + if (watchedSeconds / duration < deps.minWatchRatio) { + return; + } + + const guess = await deps.ensureAnilistMediaGuess(mediaKey); + if (!guess?.title || !guess.episode || guess.episode <= 0) { + return; + } + + const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode); + if (deps.hasAttemptedUpdateKey(attemptKey)) { + return; + } + + deps.setInFlight(true); + try { + await deps.processNextAnilistRetryUpdate(); + + const accessToken = await deps.refreshAnilistClientSecretState(); + if (!accessToken) { + deps.enqueueRetry(attemptKey, guess.title, guess.episode); + deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken'); + deps.refreshRetryQueueState(); + deps.showMpvOsd('AniList: access token not configured'); + return; + } + + const result = await deps.updateAnilistPostWatchProgress(accessToken, guess.title, guess.episode); + if (result.status === 'updated') { + deps.rememberAttemptedUpdateKey(attemptKey); + deps.markRetrySuccess(attemptKey); + deps.refreshRetryQueueState(); + deps.showMpvOsd(result.message); + deps.logInfo(result.message); + return; + } + if (result.status === 'skipped') { + deps.rememberAttemptedUpdateKey(attemptKey); + deps.markRetrySuccess(attemptKey); + deps.refreshRetryQueueState(); + deps.logInfo(result.message); + return; + } + + deps.enqueueRetry(attemptKey, guess.title, guess.episode); + deps.markRetryFailure(attemptKey, result.message); + deps.refreshRetryQueueState(); + deps.showMpvOsd(`AniList: ${result.message}`); + deps.logWarn(result.message); + } finally { + deps.setInFlight(false); + } + }; +} diff --git a/src/main/runtime/anilist-setup-window.test.ts b/src/main/runtime/anilist-setup-window.test.ts new file mode 100644 index 0000000..628e87a --- /dev/null +++ b/src/main/runtime/anilist-setup-window.test.ts @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHandleAnilistSetupWindowClosedHandler, + createMaybeFocusExistingAnilistSetupWindowHandler, + createHandleAnilistSetupWindowOpenedHandler, + createAnilistSetupDidFailLoadHandler, + createAnilistSetupDidFinishLoadHandler, + createAnilistSetupDidNavigateHandler, + createAnilistSetupFallbackHandler, + createAnilistSetupWillNavigateHandler, + createAnilistSetupWillRedirectHandler, + createAnilistSetupWindowOpenHandler, + createHandleManualAnilistSetupSubmissionHandler, +} from './anilist-setup-window'; + +test('manual anilist setup submission forwards access token to callback consumer', () => { + const consumed: string[] = []; + const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: (rawUrl) => { + consumed.push(rawUrl); + return true; + }, + redirectUri: 'https://anilist.subminer.moe/', + logWarn: () => {}, + }); + + const handled = handleSubmission('subminer://anilist-setup?access_token=abc123'); + assert.equal(handled, true); + assert.equal(consumed.length, 1); + assert.ok(consumed[0].includes('https://anilist.subminer.moe/#access_token=abc123')); +}); + +test('maybe focus anilist setup window focuses existing window', () => { + let focused = false; + const handler = createMaybeFocusExistingAnilistSetupWindowHandler({ + getSetupWindow: () => ({ + focus: () => { + focused = true; + }, + }), + }); + const handled = handler(); + assert.equal(handled, true); + assert.equal(focused, true); +}); + +test('manual anilist setup submission warns on missing token', () => { + const warnings: string[] = []; + const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: () => false, + redirectUri: 'https://anilist.subminer.moe/', + logWarn: (message) => warnings.push(message), + }); + + const handled = handleSubmission('subminer://anilist-setup'); + assert.equal(handled, true); + assert.deepEqual(warnings, ['AniList setup submission missing access token']); +}); + +test('anilist setup fallback handler triggers browser + manual entry on load fail', () => { + const calls: string[] = []; + const fallback = createAnilistSetupFallbackHandler({ + authorizeUrl: 'https://anilist.co', + developerSettingsUrl: 'https://anilist.co/settings/developer', + setupWindow: { + isDestroyed: () => false, + }, + openSetupInBrowser: () => calls.push('open-browser'), + loadManualTokenEntry: () => calls.push('load-manual'), + logError: () => calls.push('error'), + logWarn: () => calls.push('warn'), + }); + + fallback.onLoadFailure({ + errorCode: -1, + errorDescription: 'failed', + validatedURL: 'about:blank', + }); + + assert.deepEqual(calls, ['error', 'open-browser', 'load-manual']); +}); + +test('anilist setup window open handler denies unsafe url', () => { + const calls: string[] = []; + const handler = createAnilistSetupWindowOpenHandler({ + isAllowedExternalUrl: () => false, + openExternal: () => calls.push('open'), + logWarn: () => calls.push('warn'), + }); + + const result = handler({ url: 'https://malicious.example' }); + assert.deepEqual(result, { action: 'deny' }); + assert.deepEqual(calls, ['warn']); +}); + +test('anilist setup will-navigate handler blocks callback redirect uri', () => { + let prevented = false; + const handler = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: () => false, + consumeCallbackUrl: () => false, + redirectUri: 'https://anilist.subminer.moe/', + isAllowedNavigationUrl: () => true, + logWarn: () => {}, + }); + + handler({ + url: 'https://anilist.subminer.moe/#access_token=abc', + preventDefault: () => { + prevented = true; + }, + }); + + assert.equal(prevented, true); +}); + +test('anilist setup will-navigate handler blocks unsafe urls', () => { + const calls: string[] = []; + let prevented = false; + const handler = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: () => false, + consumeCallbackUrl: () => false, + redirectUri: 'https://anilist.subminer.moe/', + isAllowedNavigationUrl: () => false, + logWarn: () => calls.push('warn'), + }); + + handler({ + url: 'https://unsafe.example', + preventDefault: () => { + prevented = true; + }, + }); + + assert.equal(prevented, true); + assert.deepEqual(calls, ['warn']); +}); + +test('anilist setup will-redirect handler prevents callback redirects', () => { + let prevented = false; + const handler = createAnilistSetupWillRedirectHandler({ + consumeCallbackUrl: () => true, + }); + + handler({ + url: 'https://anilist.subminer.moe/#access_token=abc', + preventDefault: () => { + prevented = true; + }, + }); + + assert.equal(prevented, true); +}); + +test('anilist setup did-navigate handler consumes callback url', () => { + const seen: string[] = []; + const handler = createAnilistSetupDidNavigateHandler({ + consumeCallbackUrl: (url) => { + seen.push(url); + return true; + }, + }); + + handler('https://anilist.subminer.moe/#access_token=abc'); + assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']); +}); + +test('anilist setup did-fail-load handler forwards details', () => { + const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = []; + const handler = createAnilistSetupDidFailLoadHandler({ + onLoadFailure: (details) => seen.push(details), + }); + + handler({ + errorCode: -3, + errorDescription: 'timeout', + validatedURL: 'https://anilist.co/api/v2/oauth/authorize', + }); + + assert.equal(seen.length, 1); + assert.equal(seen[0].errorCode, -3); +}); + +test('anilist setup did-finish-load handler triggers fallback on blank page', () => { + const calls: string[] = []; + const handler = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => 'about:blank', + onBlankPageLoaded: () => calls.push('fallback'), + }); + + handler(); + assert.deepEqual(calls, ['fallback']); +}); + +test('anilist setup did-finish-load handler no-ops on non-blank page', () => { + const calls: string[] = []; + const handler = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => 'https://anilist.co/api/v2/oauth/authorize', + onBlankPageLoaded: () => calls.push('fallback'), + }); + + handler(); + assert.equal(calls.length, 0); +}); + +test('anilist setup window closed handler clears references', () => { + const calls: string[] = []; + const handler = createHandleAnilistSetupWindowClosedHandler({ + clearSetupWindow: () => calls.push('clear-window'), + setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), + }); + + handler(); + assert.deepEqual(calls, ['clear-window', 'opened:no']); +}); + +test('anilist setup window opened handler sets references', () => { + const calls: string[] = []; + const handler = createHandleAnilistSetupWindowOpenedHandler({ + setSetupWindow: () => calls.push('set-window'), + setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), + }); + + handler(); + assert.deepEqual(calls, ['set-window', 'opened:yes']); +}); diff --git a/src/main/runtime/anilist-setup-window.ts b/src/main/runtime/anilist-setup-window.ts new file mode 100644 index 0000000..5ed6f0e --- /dev/null +++ b/src/main/runtime/anilist-setup-window.ts @@ -0,0 +1,181 @@ +type SetupWindowLike = { + isDestroyed: () => boolean; +}; + +type OpenHandlerDecision = { action: 'deny' }; + +type FocusableWindowLike = { + focus: () => void; +}; + +export function createHandleManualAnilistSetupSubmissionHandler(deps: { + consumeCallbackUrl: (rawUrl: string) => boolean; + redirectUri: string; + logWarn: (message: string) => void; +}) { + return (rawUrl: string): boolean => { + if (!rawUrl.startsWith('subminer://anilist-setup')) { + return false; + } + try { + const parsed = new URL(rawUrl); + const accessToken = parsed.searchParams.get('access_token')?.trim() ?? ''; + if (accessToken.length > 0) { + return deps.consumeCallbackUrl( + `${deps.redirectUri}#access_token=${encodeURIComponent(accessToken)}`, + ); + } + deps.logWarn('AniList setup submission missing access token'); + return true; + } catch { + deps.logWarn('AniList setup submission had invalid callback input'); + return true; + } + }; +} + +export function createMaybeFocusExistingAnilistSetupWindowHandler(deps: { + getSetupWindow: () => FocusableWindowLike | null; +}) { + return (): boolean => { + const window = deps.getSetupWindow(); + if (!window) { + return false; + } + window.focus(); + return true; + }; +} + +export function createAnilistSetupWindowOpenHandler(deps: { + isAllowedExternalUrl: (url: string) => boolean; + openExternal: (url: string) => void; + logWarn: (message: string, details?: unknown) => void; +}) { + return ({ url }: { url: string }): OpenHandlerDecision => { + if (!deps.isAllowedExternalUrl(url)) { + deps.logWarn('Blocked unsafe AniList setup external URL', { url }); + return { action: 'deny' }; + } + deps.openExternal(url); + return { action: 'deny' }; + }; +} + +export function createAnilistSetupWillNavigateHandler(deps: { + handleManualSubmission: (url: string) => boolean; + consumeCallbackUrl: (url: string) => boolean; + redirectUri: string; + isAllowedNavigationUrl: (url: string) => boolean; + logWarn: (message: string, details?: unknown) => void; +}) { + return (params: { url: string; preventDefault: () => void }): void => { + const { url, preventDefault } = params; + if (deps.handleManualSubmission(url)) { + preventDefault(); + return; + } + if (deps.consumeCallbackUrl(url)) { + preventDefault(); + return; + } + if (url.startsWith(deps.redirectUri)) { + preventDefault(); + return; + } + if (url.startsWith(`${deps.redirectUri}#`)) { + preventDefault(); + return; + } + if (deps.isAllowedNavigationUrl(url)) { + return; + } + preventDefault(); + deps.logWarn('Blocked unsafe AniList setup navigation URL', { url }); + }; +} + +export function createAnilistSetupWillRedirectHandler(deps: { + consumeCallbackUrl: (url: string) => boolean; +}) { + return (params: { url: string; preventDefault: () => void }): void => { + if (deps.consumeCallbackUrl(params.url)) { + params.preventDefault(); + } + }; +} + +export function createAnilistSetupDidNavigateHandler(deps: { + consumeCallbackUrl: (url: string) => boolean; +}) { + return (url: string): void => { + deps.consumeCallbackUrl(url); + }; +} + +export function createAnilistSetupDidFailLoadHandler(deps: { + onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => void; +}) { + return (details: { errorCode: number; errorDescription: string; validatedURL: string }): void => { + deps.onLoadFailure(details); + }; +} + +export function createAnilistSetupDidFinishLoadHandler(deps: { + getLoadedUrl: () => string; + onBlankPageLoaded: () => void; +}) { + return (): void => { + const loadedUrl = deps.getLoadedUrl(); + if (!loadedUrl || loadedUrl === 'about:blank') { + deps.onBlankPageLoaded(); + } + }; +} + +export function createHandleAnilistSetupWindowClosedHandler(deps: { + clearSetupWindow: () => void; + setSetupPageOpened: (opened: boolean) => void; +}) { + return (): void => { + deps.clearSetupWindow(); + deps.setSetupPageOpened(false); + }; +} + +export function createHandleAnilistSetupWindowOpenedHandler(deps: { + setSetupWindow: () => void; + setSetupPageOpened: (opened: boolean) => void; +}) { + return (): void => { + deps.setSetupWindow(); + deps.setSetupPageOpened(true); + }; +} + +export function createAnilistSetupFallbackHandler(deps: { + authorizeUrl: string; + developerSettingsUrl: string; + setupWindow: SetupWindowLike; + openSetupInBrowser: () => void; + loadManualTokenEntry: () => void; + logError: (message: string, details: unknown) => void; + logWarn: (message: string) => void; +}) { + return { + onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => { + deps.logError('AniList setup window failed to load', details); + deps.openSetupInBrowser(); + if (!deps.setupWindow.isDestroyed()) { + deps.loadManualTokenEntry(); + } + }, + onBlankPageLoaded: () => { + deps.logWarn('AniList setup loaded a blank page; using fallback'); + deps.openSetupInBrowser(); + if (!deps.setupWindow.isDestroyed()) { + deps.loadManualTokenEntry(); + } + }, + }; +} diff --git a/src/main/runtime/anilist-token-refresh.test.ts b/src/main/runtime/anilist-token-refresh.test.ts new file mode 100644 index 0000000..b41406e --- /dev/null +++ b/src/main/runtime/anilist-token-refresh.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh'; + +test('refresh handler marks state not_checked when tracking disabled', async () => { + let cached: string | null = 'abc'; + let opened = true; + const states: Array<{ status: string; source: string }> = []; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: '' } }), + isAnilistTrackingEnabled: () => false, + getCachedAccessToken: () => cached, + setCachedAccessToken: (token) => { + cached = token; + }, + saveStoredToken: () => {}, + loadStoredToken: () => '', + setClientSecretState: (state) => { + states.push({ status: state.status, source: state.source }); + }, + getAnilistSetupPageOpened: () => opened, + setAnilistSetupPageOpened: (next) => { + opened = next; + }, + openAnilistSetupWindow: () => {}, + now: () => 100, + }); + + const token = await refresh(); + assert.equal(token, null); + assert.equal(cached, null); + assert.equal(opened, false); + assert.deepEqual(states, [{ status: 'not_checked', source: 'none' }]); +}); + +test('refresh handler uses literal config token and stores it', async () => { + let cached: string | null = null; + const saves: string[] = []; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: ' token-1 ' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => cached, + setCachedAccessToken: (token) => { + cached = token; + }, + saveStoredToken: (token) => saves.push(token), + loadStoredToken: () => '', + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => {}, + openAnilistSetupWindow: () => {}, + now: () => 200, + }); + + const token = await refresh({ force: true }); + assert.equal(token, 'token-1'); + assert.equal(cached, 'token-1'); + assert.deepEqual(saves, ['token-1']); +}); + +test('refresh handler prefers cached token when not forced', async () => { + let loadCalls = 0; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: '' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => 'cached-token', + setCachedAccessToken: () => {}, + saveStoredToken: () => {}, + loadStoredToken: () => { + loadCalls += 1; + return 'stored-token'; + }, + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => {}, + openAnilistSetupWindow: () => {}, + now: () => 300, + }); + + const token = await refresh(); + assert.equal(token, 'cached-token'); + assert.equal(loadCalls, 0); +}); + +test('refresh handler falls back to stored token then opens setup when missing', async () => { + let cached: string | null = null; + let opened = false; + let openCalls = 0; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: '' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => cached, + setCachedAccessToken: (token) => { + cached = token; + }, + saveStoredToken: () => {}, + loadStoredToken: () => '', + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => opened, + setAnilistSetupPageOpened: (next) => { + opened = next; + }, + openAnilistSetupWindow: () => { + openCalls += 1; + }, + now: () => 400, + }); + + const token = await refresh({ force: true }); + assert.equal(token, null); + assert.equal(cached, null); + assert.equal(openCalls, 1); +}); diff --git a/src/main/runtime/anilist-token-refresh.ts b/src/main/runtime/anilist-token-refresh.ts new file mode 100644 index 0000000..583eda1 --- /dev/null +++ b/src/main/runtime/anilist-token-refresh.ts @@ -0,0 +1,93 @@ +type AnilistSecretResolutionState = { + status: 'not_checked' | 'resolved' | 'error'; + source: 'none' | 'literal' | 'stored'; + message: string | null; + resolvedAt: number | null; + errorAt: number | null; +}; + +type ConfigWithAnilistToken = { + anilist: { + accessToken: string; + }; +}; + +export function createRefreshAnilistClientSecretStateHandler(deps: { + getResolvedConfig: () => TConfig; + isAnilistTrackingEnabled: (config: TConfig) => boolean; + getCachedAccessToken: () => string | null; + setCachedAccessToken: (token: string | null) => void; + saveStoredToken: (token: string) => void; + loadStoredToken: () => string | null | undefined; + setClientSecretState: (state: AnilistSecretResolutionState) => void; + getAnilistSetupPageOpened: () => boolean; + setAnilistSetupPageOpened: (opened: boolean) => void; + openAnilistSetupWindow: () => void; + now: () => number; +}) { + return async (options?: { force?: boolean }): Promise => { + const resolved = deps.getResolvedConfig(); + const now = deps.now(); + if (!deps.isAnilistTrackingEnabled(resolved)) { + deps.setCachedAccessToken(null); + deps.setClientSecretState({ + status: 'not_checked', + source: 'none', + message: 'anilist tracking disabled', + resolvedAt: null, + errorAt: null, + }); + deps.setAnilistSetupPageOpened(false); + return null; + } + + const rawAccessToken = resolved.anilist.accessToken.trim(); + if (rawAccessToken.length > 0) { + if (options?.force || rawAccessToken !== deps.getCachedAccessToken()) { + deps.saveStoredToken(rawAccessToken); + } + deps.setCachedAccessToken(rawAccessToken); + deps.setClientSecretState({ + status: 'resolved', + source: 'literal', + message: 'using configured anilist.accessToken', + resolvedAt: now, + errorAt: null, + }); + deps.setAnilistSetupPageOpened(false); + return rawAccessToken; + } + + const cachedAccessToken = deps.getCachedAccessToken(); + if (!options?.force && cachedAccessToken && cachedAccessToken.length > 0) { + return cachedAccessToken; + } + + const storedToken = deps.loadStoredToken()?.trim() ?? ''; + if (storedToken.length > 0) { + deps.setCachedAccessToken(storedToken); + deps.setClientSecretState({ + status: 'resolved', + source: 'stored', + message: 'using stored anilist access token', + resolvedAt: now, + errorAt: null, + }); + deps.setAnilistSetupPageOpened(false); + return storedToken; + } + + deps.setCachedAccessToken(null); + deps.setClientSecretState({ + status: 'error', + source: 'none', + message: 'cannot authenticate without anilist.accessToken', + resolvedAt: null, + errorAt: now, + }); + if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) { + deps.openAnilistSetupWindow(); + } + return null; + }; +} diff --git a/src/main/runtime/cli-command-prechecks.test.ts b/src/main/runtime/cli-command-prechecks.test.ts new file mode 100644 index 0000000..0541d11 --- /dev/null +++ b/src/main/runtime/cli-command-prechecks.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks'; + +test('texthooker precheck no-ops when mode is disabled', () => { + let warmups = 0; + const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => false, + setTexthookerOnlyMode: () => {}, + commandNeedsOverlayRuntime: () => true, + startBackgroundWarmups: () => { + warmups += 1; + }, + logInfo: () => {}, + }); + + handlePrecheck({ start: true, texthooker: false } as never); + assert.equal(warmups, 0); +}); + +test('texthooker precheck disables mode and warms up on start command', () => { + let mode = true; + let warmups = 0; + let logs = 0; + const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => mode, + setTexthookerOnlyMode: (enabled) => { + mode = enabled; + }, + commandNeedsOverlayRuntime: () => false, + startBackgroundWarmups: () => { + warmups += 1; + }, + logInfo: () => { + logs += 1; + }, + }); + + handlePrecheck({ start: true, texthooker: false } as never); + assert.equal(mode, false); + assert.equal(warmups, 1); + assert.equal(logs, 1); +}); + +test('texthooker precheck no-ops for texthooker command', () => { + let mode = true; + const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => mode, + setTexthookerOnlyMode: (enabled) => { + mode = enabled; + }, + commandNeedsOverlayRuntime: () => true, + startBackgroundWarmups: () => {}, + logInfo: () => {}, + }); + + handlePrecheck({ start: true, texthooker: true } as never); + assert.equal(mode, true); +}); diff --git a/src/main/runtime/cli-command-prechecks.ts b/src/main/runtime/cli-command-prechecks.ts new file mode 100644 index 0000000..ee51c1b --- /dev/null +++ b/src/main/runtime/cli-command-prechecks.ts @@ -0,0 +1,21 @@ +import type { CliArgs } from '../../cli/args'; + +export function createHandleTexthookerOnlyModeTransitionHandler(deps: { + isTexthookerOnlyMode: () => boolean; + setTexthookerOnlyMode: (enabled: boolean) => void; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + startBackgroundWarmups: () => void; + logInfo: (message: string) => void; +}) { + return (args: CliArgs): void => { + if ( + deps.isTexthookerOnlyMode() && + !args.texthooker && + (args.start || deps.commandNeedsOverlayRuntime(args)) + ) { + deps.setTexthookerOnlyMode(false); + deps.logInfo('Disabling texthooker-only mode after overlay/start command.'); + deps.startBackgroundWarmups(); + } + }; +} diff --git a/src/main/runtime/initial-args-handler.test.ts b/src/main/runtime/initial-args-handler.test.ts new file mode 100644 index 0000000..d01d648 --- /dev/null +++ b/src/main/runtime/initial-args-handler.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleInitialArgsHandler } from './initial-args-handler'; + +test('initial args handler no-ops without initial args', () => { + let handled = false; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => null, + isBackgroundMode: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => false, + getMpvClient: () => null, + logInfo: () => {}, + handleCliCommand: () => { + handled = true; + }, + }); + + handleInitialArgs(); + assert.equal(handled, false); +}); + +test('initial args handler ensures tray in background mode', () => { + let ensuredTray = false; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => true, + ensureTray: () => { + ensuredTray = true; + }, + isTexthookerOnlyMode: () => true, + hasImmersionTracker: () => false, + getMpvClient: () => null, + logInfo: () => {}, + handleCliCommand: () => {}, + }); + + handleInitialArgs(); + assert.equal(ensuredTray, true); +}); + +test('initial args handler auto-connects mpv when needed', () => { + let connectCalls = 0; + let logged = false; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => true, + getMpvClient: () => ({ + connected: false, + connect: () => { + connectCalls += 1; + }, + }), + logInfo: () => { + logged = true; + }, + handleCliCommand: () => {}, + }); + + handleInitialArgs(); + assert.equal(connectCalls, 1); + assert.equal(logged, true); +}); + +test('initial args handler forwards args to cli handler', () => { + const seenSources: string[] = []; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => false, + getMpvClient: () => null, + logInfo: () => {}, + handleCliCommand: (_args, source) => { + seenSources.push(source); + }, + }); + + handleInitialArgs(); + assert.deepEqual(seenSources, ['initial']); +}); diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts new file mode 100644 index 0000000..2dcc02e --- /dev/null +++ b/src/main/runtime/initial-args-handler.ts @@ -0,0 +1,39 @@ +import type { CliArgs } from '../../cli/args'; + +type MpvClientLike = { + connected: boolean; + connect: () => void; +}; + +export function createHandleInitialArgsHandler(deps: { + getInitialArgs: () => CliArgs | null; + isBackgroundMode: () => boolean; + ensureTray: () => void; + isTexthookerOnlyMode: () => boolean; + hasImmersionTracker: () => boolean; + getMpvClient: () => MpvClientLike | null; + logInfo: (message: string) => void; + handleCliCommand: (args: CliArgs, source: 'initial') => void; +}) { + return (): void => { + const initialArgs = deps.getInitialArgs(); + if (!initialArgs) return; + + if (deps.isBackgroundMode()) { + deps.ensureTray(); + } + + const mpvClient = deps.getMpvClient(); + if ( + !deps.isTexthookerOnlyMode() && + deps.hasImmersionTracker() && + mpvClient && + !mpvClient.connected + ) { + deps.logInfo('Auto-connecting MPV client for immersion tracking'); + mpvClient.connect(); + } + + deps.handleCliCommand(initialArgs, 'initial'); + }; +} diff --git a/src/main/runtime/jellyfin-cli-auth.test.ts b/src/main/runtime/jellyfin-cli-auth.test.ts new file mode 100644 index 0000000..f1fa812 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-auth.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinAuthCommands } from './jellyfin-cli-auth'; + +test('jellyfin auth handler processes logout', async () => { + const calls: string[] = []; + const handleAuth = createHandleJellyfinAuthCommands({ + patchRawConfig: () => calls.push('patch'), + authenticateWithPassword: async () => { + throw new Error('should not authenticate'); + }, + logInfo: (message) => calls.push(message), + }); + + const handled = await handleAuth({ + args: { + jellyfinLogout: true, + jellyfinLogin: false, + jellyfinUsername: undefined, + jellyfinPassword: undefined, + } as never, + jellyfinConfig: { + serverUrl: '', + username: '', + accessToken: '', + userId: '', + }, + serverUrl: 'http://localhost', + clientInfo: { + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + + assert.equal(handled, true); + assert.equal(calls[0], 'patch'); +}); + +test('jellyfin auth handler processes login', async () => { + const calls: string[] = []; + const handleAuth = createHandleJellyfinAuthCommands({ + patchRawConfig: () => calls.push('patch'), + authenticateWithPassword: async () => ({ + serverUrl: 'http://localhost', + username: 'user', + accessToken: 'token', + userId: 'uid', + }), + logInfo: (message) => calls.push(message), + }); + + const handled = await handleAuth({ + args: { + jellyfinLogout: false, + jellyfinLogin: true, + jellyfinUsername: 'user', + jellyfinPassword: 'pw', + } as never, + jellyfinConfig: { + serverUrl: '', + username: '', + accessToken: '', + userId: '', + }, + serverUrl: 'http://localhost', + clientInfo: { + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + + assert.equal(handled, true); + assert.ok(calls.includes('patch')); + assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); +}); + +test('jellyfin auth handler no-ops when no auth command', async () => { + const handleAuth = createHandleJellyfinAuthCommands({ + patchRawConfig: () => {}, + authenticateWithPassword: async () => ({ + serverUrl: '', + username: '', + accessToken: '', + userId: '', + }), + logInfo: () => {}, + }); + + const handled = await handleAuth({ + args: { + jellyfinLogout: false, + jellyfinLogin: false, + jellyfinUsername: undefined, + jellyfinPassword: undefined, + } as never, + jellyfinConfig: { + serverUrl: '', + username: '', + accessToken: '', + userId: '', + }, + serverUrl: 'http://localhost', + clientInfo: { + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + + assert.equal(handled, false); +}); diff --git a/src/main/runtime/jellyfin-cli-auth.ts b/src/main/runtime/jellyfin-cli-auth.ts new file mode 100644 index 0000000..f78dfa9 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-auth.ts @@ -0,0 +1,88 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinConfig = { + serverUrl: string; + username: string; + accessToken: string; + userId: string; +}; + +type JellyfinClientInfo = { + deviceId: string; + clientName: string; + clientVersion: string; +}; + +type JellyfinSession = { + serverUrl: string; + username: string; + accessToken: string; + userId: string; +}; + +export function createHandleJellyfinAuthCommands(deps: { + patchRawConfig: (patch: { + jellyfin: Partial<{ + enabled: boolean; + serverUrl: string; + username: string; + accessToken: string; + userId: string; + deviceId: string; + clientName: string; + clientVersion: string; + }>; + }) => void; + authenticateWithPassword: ( + serverUrl: string, + username: string, + password: string, + clientInfo: JellyfinClientInfo, + ) => Promise; + logInfo: (message: string) => void; +}) { + return async (params: { + args: CliArgs; + jellyfinConfig: JellyfinConfig; + serverUrl: string; + clientInfo: JellyfinClientInfo; + }): Promise => { + if (params.args.jellyfinLogout) { + deps.patchRawConfig({ + jellyfin: { + accessToken: '', + userId: '', + }, + }); + deps.logInfo('Cleared stored Jellyfin access token.'); + return true; + } + + if (!params.args.jellyfinLogin) { + return false; + } + + const username = (params.args.jellyfinUsername || params.jellyfinConfig.username).trim(); + const password = params.args.jellyfinPassword || ''; + const session = await deps.authenticateWithPassword( + params.serverUrl, + username, + password, + params.clientInfo, + ); + deps.patchRawConfig({ + jellyfin: { + enabled: true, + serverUrl: session.serverUrl, + username: session.username, + accessToken: session.accessToken, + userId: session.userId, + deviceId: params.clientInfo.deviceId, + clientName: params.clientInfo.clientName, + clientVersion: params.clientInfo.clientVersion, + }, + }); + deps.logInfo(`Jellyfin login succeeded for ${session.username}.`); + return true; + }; +} diff --git a/src/main/runtime/jellyfin-cli-list.test.ts b/src/main/runtime/jellyfin-cli-list.test.ts new file mode 100644 index 0000000..591bdf8 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-list.test.ts @@ -0,0 +1,176 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinListCommands } from './jellyfin-cli-list'; + +const baseSession = { + serverUrl: 'http://localhost', + accessToken: 'token', + userId: 'user-id', + username: 'user', +}; + +const baseClientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'device-id', +}; + +const baseConfig = { + defaultLibraryId: '', +}; + +test('list handler no-ops when no list command is set', async () => { + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }); + + const handled = await handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, false); +}); + +test('list handler logs libraries', async () => { + const logs: string[] = []; + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: (message) => logs.push(message), + }); + + const handled = await handler({ + args: { + jellyfinLibraries: true, + jellyfinItems: false, + jellyfinSubtitles: false, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)'))); +}); + +test('list handler resolves items using default library id', async () => { + let usedLibraryId = ''; + const logs: string[] = []; + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async (_session, _clientInfo, params) => { + usedLibraryId = params.libraryId; + return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }]; + }, + listJellyfinSubtitleTracks: async () => [], + logInfo: (message) => logs.push(message), + }); + + const handled = await handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: true, + jellyfinSubtitles: false, + jellyfinLibraryId: '', + jellyfinSearch: 'episode', + jellyfinLimit: 10, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: { + defaultLibraryId: 'default-lib', + }, + }); + + assert.equal(handled, true); + assert.equal(usedLibraryId, 'default-lib'); + assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)'))); +}); + +test('list handler throws when items command has no library id', async () => { + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }); + + await assert.rejects( + handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: true, + jellyfinSubtitles: false, + jellyfinLibraryId: '', + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }), + /Missing Jellyfin library id/, + ); +}); + +test('list handler logs subtitle urls only when requested', async () => { + const logs: string[] = []; + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [ + { index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' }, + { index: 2, language: 'jpn' }, + ], + logInfo: (message) => logs.push(message), + }); + + const handled = await handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: true, + jellyfinItemId: 'item1', + jellyfinSubtitleUrlsOnly: true, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.deepEqual(logs, ['http://localhost/sub1.srt']); +}); + +test('list handler throws when subtitle command has no item id', async () => { + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }); + + await assert.rejects( + handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: true, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }), + /Missing --jellyfin-item-id/, + ); +}); diff --git a/src/main/runtime/jellyfin-cli-list.ts b/src/main/runtime/jellyfin-cli-list.ts new file mode 100644 index 0000000..949cd48 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-list.ts @@ -0,0 +1,116 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type JellyfinConfig = { + defaultLibraryId: string; +}; + +export function createHandleJellyfinListCommands(deps: { + listJellyfinLibraries: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + ) => Promise>; + listJellyfinItems: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + params: { libraryId: string; searchTerm?: string; limit: number }, + ) => Promise>; + listJellyfinSubtitleTracks: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + itemId: string, + ) => Promise< + Array<{ + index: number; + language?: string; + title?: string; + deliveryMethod?: string; + codec?: string; + isDefault?: boolean; + isForced?: boolean; + isExternal?: boolean; + deliveryUrl?: string | null; + }> + >; + logInfo: (message: string) => void; +}) { + return async (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: JellyfinConfig; + }): Promise => { + const { args, session, clientInfo, jellyfinConfig } = params; + + if (args.jellyfinLibraries) { + const libraries = await deps.listJellyfinLibraries(session, clientInfo); + if (libraries.length === 0) { + deps.logInfo('No Jellyfin libraries found.'); + return true; + } + for (const library of libraries) { + deps.logInfo( + `Jellyfin library: ${library.name} [${library.id}] (${library.collectionType || library.type || 'unknown'})`, + ); + } + return true; + } + + if (args.jellyfinItems) { + const libraryId = args.jellyfinLibraryId || jellyfinConfig.defaultLibraryId; + if (!libraryId) { + throw new Error( + 'Missing Jellyfin library id. Use --jellyfin-library-id or set jellyfin.defaultLibraryId.', + ); + } + const items = await deps.listJellyfinItems(session, clientInfo, { + libraryId, + searchTerm: args.jellyfinSearch, + limit: args.jellyfinLimit ?? 100, + }); + if (items.length === 0) { + deps.logInfo('No Jellyfin items found for the selected library/search.'); + return true; + } + for (const item of items) { + deps.logInfo(`Jellyfin item: ${item.title} [${item.id}] (${item.type})`); + } + return true; + } + + if (args.jellyfinSubtitles) { + if (!args.jellyfinItemId) { + throw new Error('Missing --jellyfin-item-id for --jellyfin-subtitles.'); + } + const tracks = await deps.listJellyfinSubtitleTracks(session, clientInfo, args.jellyfinItemId); + if (tracks.length === 0) { + deps.logInfo('No Jellyfin subtitle tracks found for item.'); + return true; + } + for (const track of tracks) { + if (args.jellyfinSubtitleUrlsOnly) { + if (track.deliveryUrl) deps.logInfo(track.deliveryUrl); + continue; + } + deps.logInfo( + `Jellyfin subtitle: index=${track.index} lang=${track.language || 'unknown'} title="${track.title || '-'}" method=${track.deliveryMethod || 'unknown'} codec=${track.codec || 'unknown'} default=${track.isDefault ? 'yes' : 'no'} forced=${track.isForced ? 'yes' : 'no'} external=${track.isExternal ? 'yes' : 'no'} url=${track.deliveryUrl || '-'}`, + ); + } + return true; + } + + return false; + }; +} diff --git a/src/main/runtime/jellyfin-cli-play.test.ts b/src/main/runtime/jellyfin-cli-play.test.ts new file mode 100644 index 0000000..82494f5 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-play.test.ts @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinPlayCommand } from './jellyfin-cli-play'; + +const baseSession = { + serverUrl: 'http://localhost', + accessToken: 'token', + userId: 'user-id', + username: 'user', +}; + +const baseClientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'device-id', +}; + +const baseConfig = { + defaultLibraryId: '', +}; + +test('play handler no-ops when play flag is disabled', async () => { + let called = false; + const handlePlay = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: async () => { + called = true; + }, + logWarn: () => {}, + }); + + const handled = await handlePlay({ + args: { + jellyfinPlay: false, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, false); + assert.equal(called, false); +}); + +test('play handler warns when item id is missing', async () => { + const warnings: string[] = []; + const handlePlay = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: async () => { + throw new Error('should not play'); + }, + logWarn: (message) => warnings.push(message), + }); + + const handled = await handlePlay({ + args: { + jellyfinPlay: true, + jellyfinItemId: '', + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.deepEqual(warnings, ['Ignoring --jellyfin-play without --jellyfin-item-id.']); +}); + +test('play handler runs playback with stream overrides', async () => { + let called = false; + const received: { + itemId: string; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + setQuitOnDisconnectArm?: boolean; + } = { + itemId: '', + }; + const handlePlay = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: async (params) => { + called = true; + received.itemId = params.itemId; + received.audioStreamIndex = params.audioStreamIndex; + received.subtitleStreamIndex = params.subtitleStreamIndex; + received.setQuitOnDisconnectArm = params.setQuitOnDisconnectArm; + }, + logWarn: () => {}, + }); + + const handled = await handlePlay({ + args: { + jellyfinPlay: true, + jellyfinItemId: 'item-1', + jellyfinAudioStreamIndex: 2, + jellyfinSubtitleStreamIndex: 3, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.equal(called, true); + assert.equal(received.itemId, 'item-1'); + assert.equal(received.audioStreamIndex, 2); + assert.equal(received.subtitleStreamIndex, 3); + assert.equal(received.setQuitOnDisconnectArm, true); +}); diff --git a/src/main/runtime/jellyfin-cli-play.ts b/src/main/runtime/jellyfin-cli-play.ts new file mode 100644 index 0000000..277682c --- /dev/null +++ b/src/main/runtime/jellyfin-cli-play.ts @@ -0,0 +1,53 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +export function createHandleJellyfinPlayCommand(deps: { + playJellyfinItemInMpv: (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + itemId: string; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + setQuitOnDisconnectArm?: boolean; + }) => Promise; + logWarn: (message: string) => void; +}) { + return async (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + }): Promise => { + const { args, session, clientInfo, jellyfinConfig } = params; + if (!args.jellyfinPlay) { + return false; + } + if (!args.jellyfinItemId) { + deps.logWarn('Ignoring --jellyfin-play without --jellyfin-item-id.'); + return true; + } + await deps.playJellyfinItemInMpv({ + session, + clientInfo, + jellyfinConfig, + itemId: args.jellyfinItemId, + audioStreamIndex: args.jellyfinAudioStreamIndex, + subtitleStreamIndex: args.jellyfinSubtitleStreamIndex, + setQuitOnDisconnectArm: true, + }); + return true; + }; +} diff --git a/src/main/runtime/jellyfin-cli-remote-announce.test.ts b/src/main/runtime/jellyfin-cli-remote-announce.test.ts new file mode 100644 index 0000000..d9bebef --- /dev/null +++ b/src/main/runtime/jellyfin-cli-remote-announce.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinRemoteAnnounceCommand } from './jellyfin-cli-remote-announce'; + +test('remote announce handler no-ops when flag is disabled', async () => { + let started = false; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => { + started = true; + }, + getRemoteSession: () => null, + logInfo: () => {}, + logWarn: () => {}, + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: false, + } as never); + + assert.equal(handled, false); + assert.equal(started, false); +}); + +test('remote announce handler warns when session is unavailable', async () => { + const warnings: string[] = []; + let started = false; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => { + started = true; + }, + getRemoteSession: () => null, + logInfo: () => {}, + logWarn: (message) => warnings.push(message), + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: true, + } as never); + + assert.equal(handled, true); + assert.equal(started, true); + assert.deepEqual(warnings, ['Jellyfin remote session is not available.']); +}); + +test('remote announce handler reports visibility result', async () => { + const infos: string[] = []; + const warnings: string[] = []; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => {}, + getRemoteSession: () => ({ + advertiseNow: async () => true, + }), + logInfo: (message) => infos.push(message), + logWarn: (message) => warnings.push(message), + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: true, + } as never); + + assert.equal(handled, true); + assert.deepEqual(infos, ['Jellyfin cast target is visible in server sessions.']); + assert.equal(warnings.length, 0); +}); + +test('remote announce handler warns when visibility is not confirmed', async () => { + const warnings: string[] = []; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => {}, + getRemoteSession: () => ({ + advertiseNow: async () => false, + }), + logInfo: () => {}, + logWarn: (message) => warnings.push(message), + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: true, + } as never); + + assert.equal(handled, true); + assert.deepEqual(warnings, [ + 'Jellyfin remote announce sent, but cast target is not visible in server sessions yet.', + ]); +}); diff --git a/src/main/runtime/jellyfin-cli-remote-announce.ts b/src/main/runtime/jellyfin-cli-remote-announce.ts new file mode 100644 index 0000000..5417a8e --- /dev/null +++ b/src/main/runtime/jellyfin-cli-remote-announce.ts @@ -0,0 +1,35 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinRemoteSession = { + advertiseNow: () => Promise; +}; + +export function createHandleJellyfinRemoteAnnounceCommand(deps: { + startJellyfinRemoteSession: () => Promise; + getRemoteSession: () => JellyfinRemoteSession | null; + logInfo: (message: string) => void; + logWarn: (message: string) => void; +}) { + return async (args: CliArgs): Promise => { + if (!args.jellyfinRemoteAnnounce) { + return false; + } + + await deps.startJellyfinRemoteSession(); + const remoteSession = deps.getRemoteSession(); + if (!remoteSession) { + deps.logWarn('Jellyfin remote session is not available.'); + return true; + } + + const visible = await remoteSession.advertiseNow(); + if (visible) { + deps.logInfo('Jellyfin cast target is visible in server sessions.'); + } else { + deps.logWarn( + 'Jellyfin remote announce sent, but cast target is not visible in server sessions yet.', + ); + } + return true; + }; +} diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts new file mode 100644 index 0000000..15c03a6 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createStartJellyfinRemoteSessionHandler, + createStopJellyfinRemoteSessionHandler, +} from './jellyfin-remote-session-lifecycle'; + +function createConfig(overrides?: Partial>) { + return { + remoteControlEnabled: true, + remoteControlAutoConnect: true, + serverUrl: 'http://localhost', + accessToken: 'token', + userId: 'user-id', + deviceId: '', + clientName: '', + clientVersion: '', + remoteControlDeviceName: '', + autoAnnounce: false, + ...(overrides || {}), + } as never; +} + +test('start handler no-ops when remote control is disabled', async () => { + let created = false; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig({ remoteControlEnabled: false }), + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: () => { + created = true; + 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, false); +}); + +test('start handler creates, starts, and stores session', async () => { + let storedSession: { start: () => void; stop: () => void; advertiseNow: () => Promise } | null = + null; + let started = false; + const infos: string[] = []; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig({ clientName: 'Desk' }), + getCurrentSession: () => null, + setCurrentSession: (session) => { + storedSession = session as never; + }, + createRemoteSessionService: (options) => { + assert.equal(options.deviceName, 'Desk'); + return { + start: () => { + started = true; + }, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'default-device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: (message) => infos.push(message), + logWarn: () => {}, + }); + + await startRemote(); + + assert.equal(started, true); + assert.ok(storedSession); + assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).'))); +}); + +test('start handler stops previous session before replacing', async () => { + let stopCalls = 0; + const oldSession = { + start: () => {}, + stop: () => { + stopCalls += 1; + }, + advertiseNow: async () => true, + }; + let current: typeof oldSession | null = oldSession; + + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig(), + getCurrentSession: () => current, + setCurrentSession: (session) => { + current = session as never; + }, + createRemoteSessionService: () => ({ + 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(stopCalls, 1); +}); + +test('stop handler stops active session and clears playback', () => { + let stopCalls = 0; + let clearCalls = 0; + let currentSession: { stop: () => void } | null = { + stop: () => { + stopCalls += 1; + }, + }; + + const stopRemote = createStopJellyfinRemoteSessionHandler({ + getCurrentSession: () => currentSession as never, + setCurrentSession: (session) => { + currentSession = session as never; + }, + clearActivePlayback: () => { + clearCalls += 1; + }, + }); + + stopRemote(); + assert.equal(stopCalls, 1); + assert.equal(clearCalls, 1); + assert.equal(currentSession, null); +}); diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts new file mode 100644 index 0000000..1911f72 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts @@ -0,0 +1,135 @@ +type JellyfinRemoteConfig = { + remoteControlEnabled: boolean; + remoteControlAutoConnect: boolean; + serverUrl: string; + accessToken: string; + userId: string; + deviceId: string; + clientName: string; + clientVersion: string; + remoteControlDeviceName: string; + autoAnnounce: boolean; +}; + +type JellyfinRemoteService = { + start: () => void; + stop: () => void; + advertiseNow: () => Promise; +}; + +type JellyfinRemoteEventPayload = unknown; + +type JellyfinRemoteServiceOptions = { + serverUrl: string; + accessToken: string; + deviceId: string; + clientName: string; + clientVersion: string; + deviceName: string; + capabilities: { + PlayableMediaTypes: string; + SupportedCommands: string; + SupportsMediaControl: boolean; + }; + onConnected: () => void; + onDisconnected: () => void; + onPlay: (payload: JellyfinRemoteEventPayload) => void; + onPlaystate: (payload: JellyfinRemoteEventPayload) => void; + onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void; +}; + +export function createStartJellyfinRemoteSessionHandler(deps: { + getJellyfinConfig: () => JellyfinRemoteConfig; + getCurrentSession: () => JellyfinRemoteService | null; + setCurrentSession: (session: JellyfinRemoteService | null) => void; + createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService; + defaultDeviceId: string; + defaultClientName: string; + defaultClientVersion: string; + handlePlay: (payload: JellyfinRemoteEventPayload) => Promise; + handlePlaystate: (payload: JellyfinRemoteEventPayload) => Promise; + handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise; + logInfo: (message: string) => void; + logWarn: (message: string, details?: unknown) => void; +}) { + return async (): Promise => { + const jellyfinConfig = deps.getJellyfinConfig(); + if (jellyfinConfig.remoteControlEnabled === false) return; + if (jellyfinConfig.remoteControlAutoConnect === false) return; + if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; + + const existing = deps.getCurrentSession(); + if (existing) { + existing.stop(); + deps.setCurrentSession(null); + } + + const service = deps.createRemoteSessionService({ + serverUrl: jellyfinConfig.serverUrl, + accessToken: jellyfinConfig.accessToken, + deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId, + clientName: jellyfinConfig.clientName || deps.defaultClientName, + clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion, + deviceName: + jellyfinConfig.remoteControlDeviceName || + jellyfinConfig.clientName || + deps.defaultClientName, + capabilities: { + PlayableMediaTypes: 'Video,Audio', + SupportedCommands: + 'Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent', + SupportsMediaControl: true, + }, + onConnected: () => { + deps.logInfo('Jellyfin remote websocket connected.'); + if (jellyfinConfig.autoAnnounce) { + void service.advertiseNow().then((registered) => { + if (registered) { + deps.logInfo('Jellyfin cast target is visible to server sessions.'); + } else { + deps.logWarn('Jellyfin remote connected but device not visible in server sessions yet.'); + } + }); + } + }, + onDisconnected: () => { + deps.logWarn('Jellyfin remote websocket disconnected; retrying.'); + }, + onPlay: (payload) => { + void deps.handlePlay(payload).catch((error) => { + deps.logWarn('Failed handling Jellyfin remote Play event', error); + }); + }, + onPlaystate: (payload) => { + void deps.handlePlaystate(payload).catch((error) => { + deps.logWarn('Failed handling Jellyfin remote Playstate event', error); + }); + }, + onGeneralCommand: (payload) => { + void deps.handleGeneralCommand(payload).catch((error) => { + deps.logWarn('Failed handling Jellyfin remote GeneralCommand event', error); + }); + }, + }); + + service.start(); + deps.setCurrentSession(service); + deps.logInfo( + `Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, + ); + }; +} + +export function createStopJellyfinRemoteSessionHandler(deps: { + getCurrentSession: () => JellyfinRemoteService | null; + setCurrentSession: (session: JellyfinRemoteService | null) => void; + clearActivePlayback: () => void; +}) { + return (): void => { + const session = deps.getCurrentSession(); + if (!session) return; + session.stop(); + deps.setCurrentSession(null); + deps.clearActivePlayback(); + }; +} diff --git a/src/main/runtime/jellyfin-setup-window.test.ts b/src/main/runtime/jellyfin-setup-window.test.ts new file mode 100644 index 0000000..3803dae --- /dev/null +++ b/src/main/runtime/jellyfin-setup-window.test.ts @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildJellyfinSetupFormHtml, + createHandleJellyfinSetupWindowClosedHandler, + createHandleJellyfinSetupNavigationHandler, + createHandleJellyfinSetupSubmissionHandler, + createHandleJellyfinSetupWindowOpenedHandler, + createMaybeFocusExistingJellyfinSetupWindowHandler, + parseJellyfinSetupSubmissionUrl, +} from './jellyfin-setup-window'; + +test('buildJellyfinSetupFormHtml escapes default values', () => { + const html = buildJellyfinSetupFormHtml('http://host/"x"', 'user"name'); + assert.ok(html.includes('http://host/"x"')); + assert.ok(html.includes('user"name')); + assert.ok(html.includes('subminer://jellyfin-setup?')); +}); + +test('maybe focus jellyfin setup window no-ops without window', () => { + const handler = createMaybeFocusExistingJellyfinSetupWindowHandler({ + getSetupWindow: () => null, + }); + const handled = handler(); + assert.equal(handled, false); +}); + +test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => { + const parsed = parseJellyfinSetupSubmissionUrl( + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', + ); + assert.deepEqual(parsed, { + server: 'http://localhost', + username: 'a', + password: 'b', + }); + assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null); +}); + +test('createHandleJellyfinSetupSubmissionHandler applies successful login', async () => { + const calls: string[] = []; + const handler = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async () => ({ + serverUrl: 'http://localhost', + username: 'user', + accessToken: 'token', + userId: 'uid', + }), + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + closeSetupWindow: () => calls.push('close'), + }); + + const handled = await handler( + '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']); +}); + +test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async () => { + const calls: string[] = []; + const handler = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async () => { + throw new Error('bad credentials'); + }, + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + closeSetupWindow: () => calls.push('close'), + }); + + const handled = await handler( + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', + ); + assert.equal(handled, true); + assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials']); +}); + +test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => { + const handleNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: async () => {}, + logError: () => {}, + }); + let prevented = false; + const handled = handleNavigation({ + url: 'https://example.com', + preventDefault: () => { + prevented = true; + }, + }); + assert.equal(handled, false); + assert.equal(prevented, false); +}); + +test('createHandleJellyfinSetupNavigationHandler intercepts setup urls', async () => { + const submittedUrls: string[] = []; + const handleNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: async (rawUrl) => { + submittedUrls.push(rawUrl); + }, + logError: () => {}, + }); + let prevented = false; + const handled = handleNavigation({ + url: 'subminer://jellyfin-setup?server=http%3A%2F%2F127.0.0.1%3A8096', + preventDefault: () => { + prevented = true; + }, + }); + await Promise.resolve(); + assert.equal(handled, true); + assert.equal(prevented, true); + assert.equal(submittedUrls.length, 1); +}); + +test('createHandleJellyfinSetupWindowClosedHandler clears setup window ref', () => { + let cleared = false; + const handler = createHandleJellyfinSetupWindowClosedHandler({ + clearSetupWindow: () => { + cleared = true; + }, + }); + handler(); + assert.equal(cleared, true); +}); + +test('createHandleJellyfinSetupWindowOpenedHandler sets setup window ref', () => { + let set = false; + const handler = createHandleJellyfinSetupWindowOpenedHandler({ + setSetupWindow: () => { + set = true; + }, + }); + handler(); + assert.equal(set, true); +}); diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts new file mode 100644 index 0000000..c8c3f64 --- /dev/null +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -0,0 +1,171 @@ +type JellyfinSession = { + serverUrl: string; + username: string; + accessToken: string; + userId: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type FocusableWindowLike = { + focus: () => void; +}; + +function escapeHtmlAttr(value: string): string { + return value.replace(/"/g, '"'); +} + +export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: { + getSetupWindow: () => FocusableWindowLike | null; +}) { + return (): boolean => { + const window = deps.getSetupWindow(); + if (!window) { + return false; + } + window.focus(); + return true; + }; +} + +export function buildJellyfinSetupFormHtml(defaultServer: string, defaultUser: string): string { + return ` + + + + Jellyfin Setup + + + +
+

Jellyfin Setup

+

Login info is used to fetch a token and save Jellyfin config values.

+
+ + + + + + + +
Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...
+
+
+ + +`; +} + +export function parseJellyfinSetupSubmissionUrl(rawUrl: string): { + server: string; + username: string; + password: string; +} | null { + if (!rawUrl.startsWith('subminer://jellyfin-setup')) { + return null; + } + const parsed = new URL(rawUrl); + return { + server: parsed.searchParams.get('server') || '', + username: parsed.searchParams.get('username') || '', + password: parsed.searchParams.get('password') || '', + }; +} + +export function createHandleJellyfinSetupSubmissionHandler(deps: { + parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null; + authenticateWithPassword: ( + server: string, + username: string, + password: string, + clientInfo: JellyfinClientInfo, + ) => Promise; + getJellyfinClientInfo: () => JellyfinClientInfo; + patchJellyfinConfig: (session: JellyfinSession) => void; + logInfo: (message: string) => void; + logError: (message: string, error: unknown) => void; + showMpvOsd: (message: string) => void; + closeSetupWindow: () => void; +}) { + return async (rawUrl: string): Promise => { + const submission = deps.parseSubmissionUrl(rawUrl); + if (!submission) { + return false; + } + + try { + const session = await deps.authenticateWithPassword( + submission.server, + submission.username, + submission.password, + deps.getJellyfinClientInfo(), + ); + deps.patchJellyfinConfig(session); + deps.logInfo(`Jellyfin setup saved for ${session.username}.`); + deps.showMpvOsd('Jellyfin login success'); + deps.closeSetupWindow(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.logError('Jellyfin setup failed', error); + deps.showMpvOsd(`Jellyfin login failed: ${message}`); + } + return true; + }; +} + +export function createHandleJellyfinSetupNavigationHandler(deps: { + setupSchemePrefix: string; + handleSubmission: (rawUrl: string) => Promise; + logError: (message: string, error: unknown) => void; +}) { + return (params: { url: string; preventDefault: () => void }): boolean => { + if (!params.url.startsWith(deps.setupSchemePrefix)) { + return false; + } + params.preventDefault(); + void deps.handleSubmission(params.url).catch((error) => { + deps.logError('Failed handling Jellyfin setup submission', error); + }); + return true; + }; +} + +export function createHandleJellyfinSetupWindowClosedHandler(deps: { + clearSetupWindow: () => void; +}) { + return (): void => { + deps.clearSetupWindow(); + }; +} + +export function createHandleJellyfinSetupWindowOpenedHandler(deps: { + setSetupWindow: () => void; +}) { + return (): void => { + deps.setSetupWindow(); + }; +} diff --git a/src/main/runtime/protocol-url-handlers.test.ts b/src/main/runtime/protocol-url-handlers.test.ts new file mode 100644 index 0000000..46498f9 --- /dev/null +++ b/src/main/runtime/protocol-url-handlers.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerProtocolUrlHandlers } from './protocol-url-handlers'; + +test('registerProtocolUrlHandlers wires open-url and second-instance handling', () => { + const listeners = new Map void>(); + const calls: string[] = []; + registerProtocolUrlHandlers({ + registerOpenUrl: (listener) => { + listeners.set('open-url', listener as (...args: unknown[]) => void); + }, + registerSecondInstance: (listener) => { + listeners.set('second-instance', listener as (...args: unknown[]) => void); + }, + handleAnilistSetupProtocolUrl: (rawUrl) => rawUrl.includes('anilist-setup'), + findAnilistSetupDeepLinkArgvUrl: (argv) => + argv.find((entry) => entry.startsWith('subminer://')) ?? null, + logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`), + logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`), + }); + + const openUrlListener = listeners.get('open-url'); + const secondInstanceListener = listeners.get('second-instance'); + if (!openUrlListener || !secondInstanceListener) { + throw new Error('expected listeners'); + } + + let prevented = false; + openUrlListener({ preventDefault: () => (prevented = true) }, 'subminer://noop'); + secondInstanceListener({}, ['foo', 'subminer://noop']); + + assert.equal(prevented, true); + assert.deepEqual(calls, ['open:subminer://noop', 'second:subminer://noop']); +}); diff --git a/src/main/runtime/protocol-url-handlers.ts b/src/main/runtime/protocol-url-handlers.ts new file mode 100644 index 0000000..e6b52ab --- /dev/null +++ b/src/main/runtime/protocol-url-handlers.ts @@ -0,0 +1,27 @@ +export function registerProtocolUrlHandlers(deps: { + registerOpenUrl: ( + listener: (event: { preventDefault: () => void }, rawUrl: string) => void, + ) => void; + registerSecondInstance: (listener: (_event: unknown, argv: string[]) => void) => void; + handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean; + findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => string | null; + logUnhandledOpenUrl: (rawUrl: string) => void; + logUnhandledSecondInstanceUrl: (rawUrl: string) => void; +}) { + deps.registerOpenUrl((event, rawUrl) => { + event.preventDefault(); + if (!deps.handleAnilistSetupProtocolUrl(rawUrl)) { + deps.logUnhandledOpenUrl(rawUrl); + } + }); + + deps.registerSecondInstance((_event, argv) => { + const rawUrl = deps.findAnilistSetupDeepLinkArgvUrl(argv); + if (!rawUrl) { + return; + } + if (!deps.handleAnilistSetupProtocolUrl(rawUrl)) { + deps.logUnhandledSecondInstanceUrl(rawUrl); + } + }); +} diff --git a/src/main/runtime/subtitle-position.test.ts b/src/main/runtime/subtitle-position.test.ts new file mode 100644 index 0000000..95f4736 --- /dev/null +++ b/src/main/runtime/subtitle-position.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createLoadSubtitlePositionHandler, + createSaveSubtitlePositionHandler, +} from './subtitle-position'; + +test('createLoadSubtitlePositionHandler stores loaded value', () => { + let stored: unknown = null; + const position = { x: 10, y: 20 }; + const load = createLoadSubtitlePositionHandler({ + loadSubtitlePositionCore: () => position as unknown as never, + setSubtitlePosition: (value) => { + stored = value; + }, + }); + const result = load(); + assert.equal(result, position); + assert.equal(stored, position); +}); + +test('createSaveSubtitlePositionHandler stores then persists value', () => { + const calls: string[] = []; + const position = { x: 5, y: 7 } as unknown as never; + const save = createSaveSubtitlePositionHandler({ + saveSubtitlePositionCore: () => { + calls.push('persist'); + }, + setSubtitlePosition: () => { + calls.push('store'); + }, + }); + save(position); + assert.deepEqual(calls, ['store', 'persist']); +}); diff --git a/src/main/runtime/subtitle-position.ts b/src/main/runtime/subtitle-position.ts new file mode 100644 index 0000000..e7f324e --- /dev/null +++ b/src/main/runtime/subtitle-position.ts @@ -0,0 +1,22 @@ +import type { SubtitlePosition } from '../../types'; + +export function createLoadSubtitlePositionHandler(deps: { + loadSubtitlePositionCore: () => SubtitlePosition | null; + setSubtitlePosition: (position: SubtitlePosition | null) => void; +}) { + return (): SubtitlePosition | null => { + const position = deps.loadSubtitlePositionCore(); + deps.setSubtitlePosition(position); + return position; + }; +} + +export function createSaveSubtitlePositionHandler(deps: { + saveSubtitlePositionCore: (position: SubtitlePosition) => void; + setSubtitlePosition: (position: SubtitlePosition) => void; +}) { + return (position: SubtitlePosition): void => { + deps.setSubtitlePosition(position); + deps.saveSubtitlePositionCore(position); + }; +}