mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor: split main runtime flows into focused modules
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
1074
src/main.ts
1074
src/main.ts
File diff suppressed because it is too large
Load Diff
65
src/main/runtime/anilist-media-guess.test.ts
Normal file
65
src/main/runtime/anilist-media-guess.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
112
src/main/runtime/anilist-media-guess.ts
Normal file
112
src/main/runtime/anilist-media-guess.ts
Normal file
@@ -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<AnilistMediaGuess | null> | null;
|
||||
lastDurationProbeAtMs: number;
|
||||
};
|
||||
|
||||
type GuessAnilistMediaInfo = (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
|
||||
export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
getState: () => AnilistMediaGuessRuntimeState;
|
||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
durationRetryIntervalMs: number;
|
||||
now: () => number;
|
||||
requestMpvDuration: () => Promise<unknown>;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (mediaKey: string): Promise<number | null> => {
|
||||
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<AnilistMediaGuess | null> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
78
src/main/runtime/anilist-post-watch.test.ts
Normal file
78
src/main/runtime/anilist-post-watch.test.ts
Normal file
@@ -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<string>(['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'));
|
||||
});
|
||||
195
src/main/runtime/anilist-post-watch.ts
Normal file
195
src/main/runtime/anilist-post-watch.ts
Normal file
@@ -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<string>,
|
||||
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<string | null>;
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
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<number | null>;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
|
||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||
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<AnilistUpdateResult>;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
minWatchSeconds: number;
|
||||
minWatchRatio: number;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
226
src/main/runtime/anilist-setup-window.test.ts
Normal file
226
src/main/runtime/anilist-setup-window.test.ts
Normal file
@@ -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']);
|
||||
});
|
||||
181
src/main/runtime/anilist-setup-window.ts
Normal file
181
src/main/runtime/anilist-setup-window.ts
Normal file
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
113
src/main/runtime/anilist-token-refresh.test.ts
Normal file
113
src/main/runtime/anilist-token-refresh.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
93
src/main/runtime/anilist-token-refresh.ts
Normal file
93
src/main/runtime/anilist-token-refresh.ts
Normal file
@@ -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<TConfig extends ConfigWithAnilistToken>(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<string | null> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
59
src/main/runtime/cli-command-prechecks.test.ts
Normal file
59
src/main/runtime/cli-command-prechecks.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
21
src/main/runtime/cli-command-prechecks.ts
Normal file
21
src/main/runtime/cli-command-prechecks.ts
Normal file
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
86
src/main/runtime/initial-args-handler.test.ts
Normal file
86
src/main/runtime/initial-args-handler.test.ts
Normal file
@@ -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']);
|
||||
});
|
||||
39
src/main/runtime/initial-args-handler.ts
Normal file
39
src/main/runtime/initial-args-handler.ts
Normal file
@@ -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');
|
||||
};
|
||||
}
|
||||
113
src/main/runtime/jellyfin-cli-auth.test.ts
Normal file
113
src/main/runtime/jellyfin-cli-auth.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
88
src/main/runtime/jellyfin-cli-auth.ts
Normal file
88
src/main/runtime/jellyfin-cli-auth.ts
Normal file
@@ -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<JellyfinSession>;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
args: CliArgs;
|
||||
jellyfinConfig: JellyfinConfig;
|
||||
serverUrl: string;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
}): Promise<boolean> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
176
src/main/runtime/jellyfin-cli-list.test.ts
Normal file
176
src/main/runtime/jellyfin-cli-list.test.ts
Normal file
@@ -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/,
|
||||
);
|
||||
});
|
||||
116
src/main/runtime/jellyfin-cli-list.ts
Normal file
116
src/main/runtime/jellyfin-cli-list.ts
Normal file
@@ -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<Array<{ id: string; name: string; collectionType?: string; type?: string }>>;
|
||||
listJellyfinItems: (
|
||||
session: JellyfinSession,
|
||||
clientInfo: JellyfinClientInfo,
|
||||
params: { libraryId: string; searchTerm?: string; limit: number },
|
||||
) => Promise<Array<{ id: string; title: string; type: string }>>;
|
||||
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<boolean> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
106
src/main/runtime/jellyfin-cli-play.test.ts
Normal file
106
src/main/runtime/jellyfin-cli-play.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
53
src/main/runtime/jellyfin-cli-play.ts
Normal file
53
src/main/runtime/jellyfin-cli-play.ts
Normal file
@@ -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<void>;
|
||||
logWarn: (message: string) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
args: CliArgs;
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
jellyfinConfig: unknown;
|
||||
}): Promise<boolean> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
85
src/main/runtime/jellyfin-cli-remote-announce.test.ts
Normal file
85
src/main/runtime/jellyfin-cli-remote-announce.test.ts
Normal file
@@ -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.',
|
||||
]);
|
||||
});
|
||||
35
src/main/runtime/jellyfin-cli-remote-announce.ts
Normal file
35
src/main/runtime/jellyfin-cli-remote-announce.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
type JellyfinRemoteSession = {
|
||||
advertiseNow: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createHandleJellyfinRemoteAnnounceCommand(deps: {
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
getRemoteSession: () => JellyfinRemoteSession | null;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}) {
|
||||
return async (args: CliArgs): Promise<boolean> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
149
src/main/runtime/jellyfin-remote-session-lifecycle.test.ts
Normal file
149
src/main/runtime/jellyfin-remote-session-lifecycle.test.ts
Normal file
@@ -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<Record<string, unknown>>) {
|
||||
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<boolean> } | 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);
|
||||
});
|
||||
135
src/main/runtime/jellyfin-remote-session-lifecycle.ts
Normal file
135
src/main/runtime/jellyfin-remote-session-lifecycle.ts
Normal file
@@ -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<boolean>;
|
||||
};
|
||||
|
||||
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<void>;
|
||||
handlePlaystate: (payload: JellyfinRemoteEventPayload) => Promise<void>;
|
||||
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
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();
|
||||
};
|
||||
}
|
||||
146
src/main/runtime/jellyfin-setup-window.test.ts
Normal file
146
src/main/runtime/jellyfin-setup-window.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
171
src/main/runtime/jellyfin-setup-window.ts
Normal file
171
src/main/runtime/jellyfin-setup-window.ts
Normal file
@@ -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 `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0b1020; color: #e5e7eb; }
|
||||
main { padding: 20px; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
p { margin: 0 0 14px; color: #cbd5e1; font-size: 13px; line-height: 1.4; }
|
||||
label { display: block; margin: 10px 0 4px; font-size: 13px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #334155; border-radius: 8px; background: #111827; color: #e5e7eb; }
|
||||
button { margin-top: 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; font-weight: 600; cursor: pointer; background: #2563eb; color: #f8fafc; }
|
||||
.hint { margin-top: 12px; font-size: 12px; color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Jellyfin Setup</h1>
|
||||
<p>Login info is used to fetch a token and save Jellyfin config values.</p>
|
||||
<form id="form">
|
||||
<label for="server">Server URL</label>
|
||||
<input id="server" name="server" value="${escapeHtmlAttr(defaultServer)}" required />
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" value="${escapeHtmlAttr(defaultUser)}" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required />
|
||||
<button type="submit">Save and Login</button>
|
||||
<div class="hint">Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...</div>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
const form = document.getElementById("form");
|
||||
form?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
params.set("server", String(data.get("server") || ""));
|
||||
params.set("username", String(data.get("username") || ""));
|
||||
params.set("password", String(data.get("password") || ""));
|
||||
window.location.href = "subminer://jellyfin-setup?" + params.toString();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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<JellyfinSession>;
|
||||
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<boolean> => {
|
||||
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<unknown>;
|
||||
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();
|
||||
};
|
||||
}
|
||||
34
src/main/runtime/protocol-url-handlers.test.ts
Normal file
34
src/main/runtime/protocol-url-handlers.test.ts
Normal file
@@ -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<string, (...args: unknown[]) => 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']);
|
||||
});
|
||||
27
src/main/runtime/protocol-url-handlers.ts
Normal file
27
src/main/runtime/protocol-url-handlers.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
35
src/main/runtime/subtitle-position.test.ts
Normal file
35
src/main/runtime/subtitle-position.test.ts
Normal file
@@ -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']);
|
||||
});
|
||||
22
src/main/runtime/subtitle-position.ts
Normal file
22
src/main/runtime/subtitle-position.ts
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user