refactor: split main runtime flows into focused modules

This commit is contained in:
2026-02-19 16:57:06 -08:00
parent 162be118e1
commit d5d71816ac
31 changed files with 3270 additions and 672 deletions

View 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);
});

View 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;
};
}

View 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'));
});

View 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);
}
};
}

View 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']);
});

View 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();
}
},
};
}

View 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);
});

View 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;
};
}

View 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);
});

View 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();
}
};
}

View 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']);
});

View 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');
};
}

View 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);
});

View 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;
};
}

View 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/,
);
});

View 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;
};
}

View 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);
});

View 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;
};
}

View 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.',
]);
});

View 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;
};
}

View 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);
});

View 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();
};
}

View 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/&quot;x&quot;'));
assert.ok(html.includes('user&quot;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);
});

View 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, '&quot;');
}
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();
};
}

View 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']);
});

View 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);
}
});
}

View 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']);
});

View 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);
};
}