refactor(main): modularize runtime and harden anilist setup flow

This commit is contained in:
2026-02-19 16:04:59 -08:00
parent 58f28b7b55
commit 162be118e1
73 changed files with 4413 additions and 1251 deletions

View File

@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildConfigWarningNotificationBody,
buildConfigWarningSummary,
failStartupFromConfig,
formatConfigValue,
} from './config-validation';
test('formatConfigValue handles undefined and JSON values', () => {
assert.equal(formatConfigValue(undefined), 'undefined');
assert.equal(formatConfigValue({ x: 1 }), '{"x":1}');
assert.equal(formatConfigValue(['a', 2]), '["a",2]');
});
test('buildConfigWarningSummary includes warnings with formatted values', () => {
const summary = buildConfigWarningSummary('/tmp/config.jsonc', [
{
path: 'ankiConnect.pollingRate',
message: 'must be >= 50',
value: 20,
fallback: 250,
},
]);
assert.match(summary, /Validation found 1 issue\(s\)\. File: \/tmp\/config\.jsonc/);
assert.match(summary, /ankiConnect\.pollingRate: must be >= 50 actual=20 fallback=250/);
});
test('buildConfigWarningNotificationBody includes concise warning details', () => {
const body = buildConfigWarningNotificationBody('/tmp/config.jsonc', [
{
path: 'ankiConnect.openRouter',
message: 'Deprecated key; use ankiConnect.ai instead.',
value: { enabled: true },
fallback: {},
},
{
path: 'ankiConnect.isLapis.sentenceCardSentenceField',
message: 'Deprecated key; sentence-card sentence field is fixed to Sentence.',
value: 'Sentence',
fallback: 'Sentence',
},
]);
assert.match(body, /2 config validation issue\(s\) detected\./);
assert.match(body, /File: \/tmp\/config\.jsonc/);
assert.match(body, /1\. ankiConnect\.openRouter: Deprecated key; use ankiConnect\.ai instead\./);
assert.match(
body,
/2\. ankiConnect\.isLapis\.sentenceCardSentenceField: Deprecated key; sentence-card sentence field is fixed to Sentence\./,
);
});
test('failStartupFromConfig invokes handlers and throws', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
assert.throws(
() =>
failStartupFromConfig('Config Error', 'bad value', {
logError: (details) => {
calls.push(`log:${details}`);
},
showErrorBox: (title, details) => {
calls.push(`dialog:${title}:${details}`);
},
quit: () => {
calls.push('quit');
},
}),
/bad value/,
);
assert.equal(process.exitCode, 1);
assert.deepEqual(calls, ['log:bad value', 'dialog:Config Error:bad value', 'quit']);
process.exitCode = previousExitCode;
});

View File

@@ -0,0 +1,74 @@
import type { ConfigValidationWarning } from '../types';
export type StartupFailureHandlers = {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
export function formatConfigValue(value: unknown): string {
if (value === undefined) {
return 'undefined';
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function buildConfigWarningSummary(
configPath: string,
warnings: ConfigValidationWarning[],
): string {
const lines = [
`[config] Validation found ${warnings.length} issue(s). File: ${configPath}`,
...warnings.map(
(warning, index) =>
`[config] ${index + 1}. ${warning.path}: ${warning.message} actual=${formatConfigValue(warning.value)} fallback=${formatConfigValue(warning.fallback)}`,
),
];
return lines.join('\n');
}
export function buildConfigWarningNotificationBody(
configPath: string,
warnings: ConfigValidationWarning[],
): string {
const maxLines = 3;
const maxPathLength = 48;
const trimPath = (value: string): string =>
value.length > maxPathLength ? `...${value.slice(-(maxPathLength - 3))}` : value;
const clippedPath = trimPath(configPath);
const lines = warnings.slice(0, maxLines).map((warning, index) => {
const message = `${warning.path}: ${warning.message}`;
return `${index + 1}. ${message}`;
});
const overflow = warnings.length - lines.length;
if (overflow > 0) {
lines.push(`+${overflow} more issue(s)`);
}
return [
`${warnings.length} config validation issue(s) detected.`,
'Defaults were applied where possible.',
`File: ${clippedPath}`,
...lines,
].join('\n');
}
export function failStartupFromConfig(
title: string,
details: string,
handlers: StartupFailureHandlers,
): never {
handlers.logError(details);
handlers.showErrorBox(title, details);
process.exitCode = 1;
handlers.quit();
throw new Error(details);
}

View File

@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createConsumeAnilistSetupTokenFromUrlHandler,
createHandleAnilistSetupProtocolUrlHandler,
createNotifyAnilistSetupHandler,
createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol';
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: () => calls.push('desktop'),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, ['osd:AniList login success']);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
saveToken: () => {},
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
});
assert.equal(consume('subminer://anilist-setup?access_token=ok'), true);
assert.equal(consume('subminer://anilist-setup'), false);
});
test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => {
const warnings: string[] = [];
const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({
consumeAnilistSetupTokenFromUrl: () => false,
logWarn: (message) => warnings.push(message),
});
assert.equal(handleProtocolUrl('https://example.com'), false);
assert.equal(handleProtocolUrl('subminer://anilist-setup'), true);
assert.deepEqual(warnings, ['AniList setup protocol URL missing access token']);
});
test('createRegisterSubminerProtocolClientHandler registers default app entry', () => {
const calls: string[] = [];
const register = createRegisterSubminerProtocolClientHandler({
isDefaultApp: () => true,
getArgv: () => ['electron', './entry.js'],
execPath: '/usr/local/bin/electron',
resolvePath: (value) => `/resolved/${value}`,
setAsDefaultProtocolClient: (_scheme, _path, args) => {
calls.push(`register:${String(args?.[0])}`);
return true;
},
logWarn: (message) => calls.push(`warn:${message}`),
});
register();
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
});

View File

@@ -0,0 +1,91 @@
export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
}) => boolean;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
};
export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilistSetupTokenDeps) {
return (rawUrl: string): boolean =>
deps.consumeAnilistSetupCallbackUrl({
rawUrl,
saveToken: deps.saveToken,
setCachedToken: deps.setCachedToken,
setResolvedState: deps.setResolvedState,
setSetupPageOpened: deps.setSetupPageOpened,
onSuccess: deps.onSuccess,
closeWindow: deps.closeWindow,
});
}
export function createNotifyAnilistSetupHandler(deps: {
hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void;
}) {
return (message: string): void => {
if (deps.hasMpvClient()) {
deps.showMpvOsd(message);
return;
}
deps.showDesktopNotification('SubMiner AniList', { body: message });
deps.logInfo(`[AniList setup] ${message}`);
};
}
export function createHandleAnilistSetupProtocolUrlHandler(deps: {
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
logWarn: (message: string, details: unknown) => void;
}) {
return (rawUrl: string): boolean => {
if (!rawUrl.startsWith('subminer://anilist-setup')) {
return false;
}
if (deps.consumeAnilistSetupTokenFromUrl(rawUrl)) {
return true;
}
deps.logWarn('AniList setup protocol URL missing access token', { rawUrl });
return true;
};
}
export function createRegisterSubminerProtocolClientHandler(deps: {
isDefaultApp: () => boolean;
getArgv: () => string[];
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (
scheme: string,
path?: string,
args?: string[],
) => boolean;
logWarn: (message: string, details?: unknown) => void;
}) {
return (): void => {
try {
const defaultAppEntry = deps.isDefaultApp() ? deps.getArgv()[1] : undefined;
const success = defaultAppEntry
? deps.setAsDefaultProtocolClient('subminer', deps.execPath, [
deps.resolvePath(defaultAppEntry),
])
: deps.setAsDefaultProtocolClient('subminer');
if (!success) {
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
}
} catch (error) {
deps.logWarn('Failed to register subminer:// protocol handler', error);
}
};
}

View File

@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildAnilistSetupFallbackHtml,
buildAnilistManualTokenEntryHtml,
buildAnilistSetupUrl,
consumeAnilistSetupCallbackUrl,
extractAnilistAccessTokenFromUrl,
findAnilistSetupDeepLinkArgvUrl,
} from './anilist-setup';
test('buildAnilistSetupUrl includes required query params', () => {
const url = buildAnilistSetupUrl({
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
redirectUri: 'https://anilist.subminer.moe/',
});
assert.match(url, /client_id=36084/);
assert.match(url, /response_type=token/);
assert.match(url, /redirect_uri=https%3A%2F%2Fanilist\.subminer\.moe%2F/);
});
test('buildAnilistSetupUrl omits redirect_uri when unset', () => {
const url = buildAnilistSetupUrl({
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
});
assert.match(url, /client_id=36084/);
assert.match(url, /response_type=token/);
assert.equal(url.includes('redirect_uri='), false);
});
test('buildAnilistSetupFallbackHtml escapes reason content', () => {
const html = buildAnilistSetupFallbackHtml({
reason: '<script>alert(1)</script>',
authorizeUrl: 'https://anilist.example/auth',
developerSettingsUrl: 'https://anilist.example/dev',
});
assert.equal(html.includes('<script>alert(1)</script>'), false);
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
});
test('buildAnilistManualTokenEntryHtml includes access-token submit route only', () => {
const html = buildAnilistManualTokenEntryHtml({
authorizeUrl: 'https://anilist.example/auth',
developerSettingsUrl: 'https://anilist.example/dev',
});
assert.match(html, /subminer:\/\/anilist-setup\?access_token=/);
assert.equal(html.includes('callback_url='), false);
assert.equal(html.includes('subminer://anilist-setup?code='), false);
});
test('extractAnilistAccessTokenFromUrl returns access token from hash fragment', () => {
const token = extractAnilistAccessTokenFromUrl(
'https://anilist.subminer.moe/#access_token=token-from-hash&token_type=Bearer',
);
assert.equal(token, 'token-from-hash');
});
test('extractAnilistAccessTokenFromUrl returns access token from query', () => {
const token = extractAnilistAccessTokenFromUrl(
'https://anilist.subminer.moe/?access_token=token-from-query&token_type=Bearer',
);
assert.equal(token, 'token-from-query');
});
test('findAnilistSetupDeepLinkArgvUrl finds subminer deep link from argv', () => {
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
'--start',
'subminer://anilist-setup?access_token=argv-token',
]);
assert.equal(rawUrl, 'subminer://anilist-setup?access_token=argv-token');
});
test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
'--start',
]);
assert.equal(rawUrl, null);
});
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
});
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
});
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.co/settings/developer',
saveToken: () => events.push('save'),
setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'),
setSetupPageOpened: () => events.push('opened'),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, false);
assert.deepEqual(events, []);
});

View File

@@ -0,0 +1,177 @@
import type { BrowserWindow } from 'electron';
import type { ResolvedConfig } from '../../types';
export type BuildAnilistSetupUrlDeps = {
authorizeUrl: string;
clientId: string;
responseType: string;
redirectUri?: string;
};
export type ConsumeAnilistSetupCallbackUrlDeps = {
rawUrl: string;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
};
export function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean {
return resolved.anilist.enabled;
}
export function buildAnilistSetupUrl(params: BuildAnilistSetupUrlDeps): string {
const authorizeUrl = new URL(params.authorizeUrl);
authorizeUrl.searchParams.set('client_id', params.clientId);
authorizeUrl.searchParams.set('response_type', params.responseType);
if (params.redirectUri && params.redirectUri.trim().length > 0) {
authorizeUrl.searchParams.set('redirect_uri', params.redirectUri);
}
return authorizeUrl.toString();
}
export function extractAnilistAccessTokenFromUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
const fromQuery = parsed.searchParams.get('access_token')?.trim();
if (fromQuery && fromQuery.length > 0) {
return fromQuery;
}
const hash = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
if (hash.length === 0) {
return null;
}
const hashParams = new URLSearchParams(hash);
const fromHash = hashParams.get('access_token')?.trim();
if (fromHash && fromHash.length > 0) {
return fromHash;
}
return null;
} catch {
return null;
}
}
export function findAnilistSetupDeepLinkArgvUrl(argv: readonly string[]): string | null {
for (const value of argv) {
if (value.startsWith('subminer://anilist-setup')) {
return value;
}
}
return null;
}
export function consumeAnilistSetupCallbackUrl(
deps: ConsumeAnilistSetupCallbackUrlDeps,
): boolean {
const token = extractAnilistAccessTokenFromUrl(deps.rawUrl);
if (!token) {
return false;
}
const resolvedAt = Date.now();
deps.saveToken(token);
deps.setCachedToken(token);
deps.setResolvedState(resolvedAt);
deps.setSetupPageOpened(false);
deps.onSuccess();
deps.closeWindow();
return true;
}
export function openAnilistSetupInBrowser(params: {
authorizeUrl: string;
openExternal: (url: string) => Promise<void>;
logError: (message: string, error: unknown) => void;
}): void {
void params.openExternal(params.authorizeUrl).catch((error) => {
params.logError('Failed to open AniList authorize URL in browser', error);
});
}
export function buildAnilistSetupFallbackHtml(params: {
reason: string;
authorizeUrl: string;
developerSettingsUrl: string;
}): string {
const safeReason = params.reason.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeAuth = params.authorizeUrl.replace(/"/g, '&quot;');
const safeDev = params.developerSettingsUrl.replace(/"/g, '&quot;');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
<h1>AniList setup</h1>
<p>Automatic page load failed (${safeReason}).</p>
<p><a href="${safeAuth}">Open AniList authorize page</a></p>
<p><a href="${safeDev}">Open AniList developer settings</a></p>
</body></html>`;
}
export function buildAnilistManualTokenEntryHtml(params: {
authorizeUrl: string;
developerSettingsUrl: string;
}): string {
const safeAuth = params.authorizeUrl.replace(/"/g, '&quot;');
const safeDev = params.developerSettingsUrl.replace(/"/g, '&quot;');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
<h1>AniList setup</h1>
<p>Authorize in browser, then paste the access token below.</p>
<p><a href="${safeAuth}" target="_blank" rel="noreferrer">Open AniList authorize page</a></p>
<p><a href="${safeDev}" target="_blank" rel="noreferrer">Open AniList developer settings</a></p>
<form id="token-form">
<label for="token">Access token</label><br />
<input id="token" style="width: 100%; max-width: 760px; margin: 8px 0; padding: 8px;" autocomplete="off" />
<br />
<button type="submit" style="padding: 8px 12px;">Continue</button>
</form>
<script>
const form = document.getElementById('token-form');
const token = document.getElementById('token');
form?.addEventListener('submit', (event) => {
event.preventDefault();
const rawToken = String(token?.value || '').trim();
if (rawToken) {
window.location.href = 'subminer://anilist-setup?access_token=' + encodeURIComponent(rawToken);
}
});
</script>
</body></html>`;
}
export function loadAnilistSetupFallback(params: {
setupWindow: BrowserWindow;
reason: string;
authorizeUrl: string;
developerSettingsUrl: string;
logWarn: (message: string, data: unknown) => void;
}): void {
const html = buildAnilistSetupFallbackHtml({
reason: params.reason,
authorizeUrl: params.authorizeUrl,
developerSettingsUrl: params.developerSettingsUrl,
});
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
params.logWarn('Loaded AniList setup fallback page', { reason: params.reason });
}
export function loadAnilistManualTokenEntry(params: {
setupWindow: BrowserWindow;
authorizeUrl: string;
developerSettingsUrl: string;
logWarn: (message: string, data: unknown) => void;
}): void {
const html = buildAnilistManualTokenEntryHtml({
authorizeUrl: params.authorizeUrl,
developerSettingsUrl: params.developerSettingsUrl,
});
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
params.logWarn('Loaded AniList manual token entry page', {
authorizeUrl: params.authorizeUrl,
});
}

View File

@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAnilistStateRuntime } from './anilist-state';
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
function createRuntime() {
let clientState: AnilistSecretResolutionState = {
status: 'resolved',
source: 'stored',
message: 'ok' as string | null,
resolvedAt: 1000 as number | null,
errorAt: null as number | null,
};
let queueState: AnilistRetryQueueState = {
pending: 1,
ready: 2,
deadLetter: 3,
lastAttemptAt: 2000 as number | null,
lastError: 'none' as string | null,
};
let clearedStoredToken = false;
let clearedCachedToken = false;
const runtime = createAnilistStateRuntime({
getClientSecretState: () => clientState,
setClientSecretState: (next) => {
clientState = next;
},
getRetryQueueState: () => queueState,
setRetryQueueState: (next) => {
queueState = next;
},
getUpdateQueueSnapshot: () => ({
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 3000,
lastError: 'boom' as string | null,
}),
clearStoredToken: () => {
clearedStoredToken = true;
},
clearCachedAccessToken: () => {
clearedCachedToken = true;
},
});
return {
runtime,
getClientState: () => clientState,
getQueueState: () => queueState,
getClearedStoredToken: () => clearedStoredToken,
getClearedCachedToken: () => clearedCachedToken,
};
}
test('setClientSecretState merges partial updates', () => {
const harness = createRuntime();
harness.runtime.setClientSecretState({
status: 'error',
source: 'none',
errorAt: 4000,
});
assert.deepEqual(harness.getClientState(), {
status: 'error',
source: 'none',
message: 'ok',
resolvedAt: 1000,
errorAt: 4000,
});
});
test('refresh/get queue snapshot uses update queue snapshot', () => {
const harness = createRuntime();
const snapshot = harness.runtime.getQueueStatusSnapshot();
assert.deepEqual(snapshot, {
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 3000,
lastError: 'boom',
});
assert.deepEqual(harness.getQueueState(), snapshot);
});
test('clearTokenState resets token state and clears caches', () => {
const harness = createRuntime();
harness.runtime.clearTokenState();
assert.equal(harness.getClearedStoredToken(), true);
assert.equal(harness.getClearedCachedToken(), true);
assert.deepEqual(harness.getClientState(), {
status: 'not_checked',
source: 'none',
message: 'stored token cleared',
resolvedAt: null,
errorAt: null,
});
});

View File

@@ -0,0 +1,97 @@
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
type AnilistQueueSnapshot = Pick<AnilistRetryQueueState, 'pending' | 'ready' | 'deadLetter'>;
type AnilistStatusSnapshot = {
tokenStatus: AnilistSecretResolutionState['status'];
tokenSource: AnilistSecretResolutionState['source'];
tokenMessage: string | null;
tokenResolvedAt: number | null;
tokenErrorAt: number | null;
queuePending: number;
queueReady: number;
queueDeadLetter: number;
queueLastAttemptAt: number | null;
queueLastError: string | null;
};
export type AnilistStateRuntimeDeps = {
getClientSecretState: () => AnilistSecretResolutionState;
setClientSecretState: (next: AnilistSecretResolutionState) => void;
getRetryQueueState: () => AnilistRetryQueueState;
setRetryQueueState: (next: AnilistRetryQueueState) => void;
getUpdateQueueSnapshot: () => AnilistQueueSnapshot;
clearStoredToken: () => void;
clearCachedAccessToken: () => void;
};
export function createAnilistStateRuntime(deps: AnilistStateRuntimeDeps): {
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
refreshRetryQueueState: () => void;
getStatusSnapshot: () => AnilistStatusSnapshot;
getQueueStatusSnapshot: () => AnilistRetryQueueState;
clearTokenState: () => void;
} {
const setClientSecretState = (partial: Partial<AnilistSecretResolutionState>): void => {
deps.setClientSecretState({
...deps.getClientSecretState(),
...partial,
});
};
const refreshRetryQueueState = (): void => {
deps.setRetryQueueState({
...deps.getRetryQueueState(),
...deps.getUpdateQueueSnapshot(),
});
};
const getStatusSnapshot = (): AnilistStatusSnapshot => {
const client = deps.getClientSecretState();
const queue = deps.getRetryQueueState();
return {
tokenStatus: client.status,
tokenSource: client.source,
tokenMessage: client.message,
tokenResolvedAt: client.resolvedAt,
tokenErrorAt: client.errorAt,
queuePending: queue.pending,
queueReady: queue.ready,
queueDeadLetter: queue.deadLetter,
queueLastAttemptAt: queue.lastAttemptAt,
queueLastError: queue.lastError,
};
};
const getQueueStatusSnapshot = (): AnilistRetryQueueState => {
refreshRetryQueueState();
const queue = deps.getRetryQueueState();
return {
pending: queue.pending,
ready: queue.ready,
deadLetter: queue.deadLetter,
lastAttemptAt: queue.lastAttemptAt,
lastError: queue.lastError,
};
};
const clearTokenState = (): void => {
deps.clearStoredToken();
deps.clearCachedAccessToken();
setClientSecretState({
status: 'not_checked',
source: 'none',
message: 'stored token cleared',
resolvedAt: null,
errorAt: null,
});
};
return {
setClientSecretState,
refreshRetryQueueState,
getStatusSnapshot,
getQueueStatusSnapshot,
clearTokenState,
};
}

View File

@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { appendClipboardVideoToQueueRuntime } from './clipboard-queue';
test('appendClipboardVideoToQueueRuntime returns disconnected when mpv unavailable', () => {
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => null,
readClipboardText: () => '',
showMpvOsd: () => {},
sendMpvCommand: () => {},
});
assert.deepEqual(result, { ok: false, message: 'MPV is not connected.' });
});
test('appendClipboardVideoToQueueRuntime rejects unsupported clipboard path', () => {
const osdMessages: string[] = [];
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => 'not a media path',
showMpvOsd: (text) => osdMessages.push(text),
sendMpvCommand: () => {},
});
assert.equal(result.ok, false);
assert.equal(osdMessages[0], 'Clipboard does not contain a supported video path.');
});
test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
const tempPath = path.join(process.cwd(), 'dist', 'clipboard-queue-test-video.mkv');
fs.writeFileSync(tempPath, 'stub');
const commands: Array<(string | number)[]> = [];
const osdMessages: string[] = [];
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => tempPath,
showMpvOsd: (text) => osdMessages.push(text),
sendMpvCommand: (command) => commands.push(command),
});
assert.equal(result.ok, true);
assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']);
assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`);
fs.unlinkSync(tempPath);
});

View File

@@ -0,0 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseClipboardVideoPath } from '../../core/services';
type MpvClientLike = {
connected: boolean;
};
export type AppendClipboardVideoToQueueRuntimeDeps = {
getMpvClient: () => MpvClientLike | null;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
sendMpvCommand: (command: (string | number)[]) => void;
};
export function appendClipboardVideoToQueueRuntime(
deps: AppendClipboardVideoToQueueRuntimeDeps,
): { ok: boolean; message: string } {
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return { ok: false, message: 'MPV is not connected.' };
}
const clipboardText = deps.readClipboardText();
const parsedPath = parseClipboardVideoPath(clipboardText);
if (!parsedPath) {
deps.showMpvOsd('Clipboard does not contain a supported video path.');
return { ok: false, message: 'Clipboard does not contain a supported video path.' };
}
const resolvedPath = path.resolve(parsedPath);
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
deps.showMpvOsd('Clipboard path is not a readable file.');
return { ok: false, message: 'Clipboard path is not a readable file.' };
}
deps.sendMpvCommand(['loadfile', resolvedPath, 'append']);
deps.showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`);
return { ok: true, message: `Queued ${resolvedPath}` };
}

View File

@@ -0,0 +1,64 @@
import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import {
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
jimakuFetchJson as jimakuFetchJsonCore,
resolveJimakuApiKey as resolveJimakuApiKeyCore,
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
shouldBindVisibleOverlayToMpvSubVisibility as shouldBindVisibleOverlayToMpvSubVisibilityCore,
} from '../../core/services';
export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
platform: NodeJS.Platform;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
};
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
getInitialInvisibleOverlayVisibility: () => boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isAutoUpdateEnabledRuntime: () => boolean;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
getJimakuMaxEntryResults: () => number;
resolveJimakuApiKey: () => Promise<string | null>;
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
} {
return {
getInitialInvisibleOverlayVisibility: () =>
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibilityCore(deps.getResolvedConfig()),
isAutoUpdateEnabledRuntime: () =>
isAutoUpdateEnabledRuntimeCore(deps.getResolvedConfig(), deps.getRuntimeOptionsManager()),
getJimakuLanguagePreference: () =>
getJimakuLanguagePreferenceCore(
() => deps.getResolvedConfig(),
deps.defaultJimakuLanguagePreference,
),
getJimakuMaxEntryResults: () =>
getJimakuMaxEntryResultsCore(() => deps.getResolvedConfig(), deps.defaultJimakuMaxEntryResults),
resolveJimakuApiKey: () => resolveJimakuApiKeyCore(() => deps.getResolvedConfig()),
jimakuFetchJson: <T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined> = {},
): Promise<JimakuApiResponse<T>> =>
jimakuFetchJsonCore<T>(endpoint, query, {
getResolvedConfig: () => deps.getResolvedConfig(),
defaultBaseUrl: deps.defaultJimakuApiBaseUrl,
defaultMaxEntryResults: deps.defaultJimakuMaxEntryResults,
defaultLanguagePreference: deps.defaultJimakuLanguagePreference,
}),
};
}

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
} from './config-hot-reload-handlers';
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const ankiPatches: Array<{ enabled: boolean }> = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
applyAnkiRuntimeConfigPatch: (patch) => {
ankiPatches.push({ enabled: patch.ai.enabled });
},
});
applyHotReload(
{
hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'],
restartRequiredFields: [],
},
config,
);
assert.ok(calls.includes('set:keybindings'));
assert.ok(calls.includes('refresh:shortcuts'));
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
});
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: () => calls.push('set:secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('anki:patch'),
});
applyHotReload(
{
hotReloadFields: [],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(calls, ['set:keybindings']);
});
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {
assert.equal(
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
'Config updated; restart required for: websocket, subtitleStyle',
);
});

View File

@@ -0,0 +1,73 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils';
import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
};
type ConfigHotReloadMessageDeps = {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
};
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
if (!config.subtitleStyle) {
return null;
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};
}
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
secondarySubMode: config.secondarySub.defaultMode,
};
}
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings);
if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts();
}
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
deps.setSecondarySubMode(payload.secondarySubMode);
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai });
}
if (diff.hotReloadFields.length > 0) {
deps.broadcastToOverlayWindows('config:hot-reload', payload);
}
};
}
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
deps.showMpvOsd(message);
deps.showDesktopNotification('SubMiner', { body: message });
};
}
export function buildRestartRequiredConfigMessage(fields: string[]): string {
return `Config updated; restart required for: ${fields.join(', ')}`;
}

View File

@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImmersionMediaRuntime } from './immersion-media';
test('getConfiguredDbPath uses trimmed configured path with fallback', () => {
const runtime = createImmersionMediaRuntime({
getResolvedConfig: () => ({ immersionTracking: { dbPath: ' /tmp/custom.db ' } }),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => null,
getMpvClient: () => null,
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
logDebug: () => {},
logInfo: () => {},
});
assert.equal(runtime.getConfiguredDbPath(), '/tmp/custom.db');
const fallbackRuntime = createImmersionMediaRuntime({
getResolvedConfig: () => ({ immersionTracking: { dbPath: ' ' } }),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => null,
getMpvClient: () => null,
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
logDebug: () => {},
logInfo: () => {},
});
assert.equal(fallbackRuntime.getConfiguredDbPath(), '/tmp/default.db');
});
test('syncFromCurrentMediaState uses current media path directly', () => {
const calls: Array<{ path: string; title: string | null }> = [];
const runtime = createImmersionMediaRuntime({
getResolvedConfig: () => ({}),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => ({
handleMediaChange: (path, title) => calls.push({ path, title }),
}),
getMpvClient: () => ({ connected: true, currentVideoPath: '/tmp/video.mkv' }),
getCurrentMediaPath: () => ' /tmp/current.mkv ',
getCurrentMediaTitle: () => ' Current Title ',
logDebug: () => {},
logInfo: () => {},
});
runtime.syncFromCurrentMediaState();
assert.deepEqual(calls, [{ path: '/tmp/current.mkv', title: 'Current Title' }]);
});
test('seedFromCurrentMedia resolves media path from mpv properties', async () => {
const calls: Array<{ path: string; title: string | null }> = [];
const runtime = createImmersionMediaRuntime({
getResolvedConfig: () => ({}),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => ({
handleMediaChange: (path, title) => calls.push({ path, title }),
}),
getMpvClient: () => ({
connected: true,
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/from-property.mkv';
if (name === 'media-title') return 'Property Title';
return null;
},
}),
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
sleep: async () => {},
seedAttempts: 2,
logDebug: () => {},
logInfo: () => {},
});
await runtime.seedFromCurrentMedia();
assert.deepEqual(calls, [{ path: '/tmp/from-property.mkv', title: 'Property Title' }]);
});

View File

@@ -0,0 +1,174 @@
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
};
};
type ImmersionTrackerLike = {
handleMediaChange: (path: string, title: string | null) => void;
};
type MpvClientLike = {
currentVideoPath?: string | null;
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
};
type ImmersionMediaState = {
path: string | null;
title: string | null;
};
export type ImmersionMediaRuntimeDeps = {
getResolvedConfig: () => ResolvedConfigLike;
defaultImmersionDbPath: string;
getTracker: () => ImmersionTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
getCurrentMediaPath: () => string | null | undefined;
getCurrentMediaTitle: () => string | null | undefined;
sleep?: (ms: number) => Promise<void>;
seedWaitMs?: number;
seedAttempts?: number;
logDebug: (message: string) => void;
logInfo: (message: string) => void;
};
function trimToNull(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
async function readMpvPropertyAsString(
mpvClient: MpvClientLike | null | undefined,
propertyName: string,
): Promise<string | null> {
const requestProperty = mpvClient?.requestProperty;
if (!requestProperty) {
return null;
}
try {
const value = await requestProperty(propertyName);
return typeof value === 'string' ? trimToNull(value) : null;
} catch {
return null;
}
}
export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
getConfiguredDbPath: () => string;
seedFromCurrentMedia: () => Promise<void>;
syncFromCurrentMediaState: () => void;
} {
const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const waitMs = deps.seedWaitMs ?? 250;
const attempts = deps.seedAttempts ?? 120;
let isSeedInProgress = false;
const getConfiguredDbPath = (): string => {
const configuredDbPath = trimToNull(deps.getResolvedConfig().immersionTracking?.dbPath);
return configuredDbPath ?? deps.defaultImmersionDbPath;
};
const getCurrentMpvMediaStateForTracker = async (): Promise<ImmersionMediaState> => {
const statePath = trimToNull(deps.getCurrentMediaPath());
const stateTitle = trimToNull(deps.getCurrentMediaTitle());
if (statePath) {
return {
path: statePath,
title: stateTitle,
};
}
const mpvClient = deps.getMpvClient();
const trackedPath = trimToNull(mpvClient?.currentVideoPath);
if (trackedPath) {
return {
path: trackedPath,
title: stateTitle,
};
}
const [pathFromProperty, filenameFromProperty, titleFromProperty] = await Promise.all([
readMpvPropertyAsString(mpvClient, 'path'),
readMpvPropertyAsString(mpvClient, 'filename'),
readMpvPropertyAsString(mpvClient, 'media-title'),
]);
return {
path: pathFromProperty || filenameFromProperty || null,
title: stateTitle || titleFromProperty || null,
};
};
const seedFromCurrentMedia = async (): Promise<void> => {
const tracker = deps.getTracker();
if (!tracker) {
deps.logDebug('Immersion tracker seeding skipped: tracker not initialized.');
return;
}
if (isSeedInProgress) {
deps.logDebug('Immersion tracker seeding already in progress; skipping duplicate call.');
return;
}
deps.logDebug('Starting immersion tracker media-state seed loop.');
isSeedInProgress = true;
try {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
}
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
await sleep(waitMs);
continue;
}
if (attempt < attempts - 1) {
await sleep(waitMs);
}
}
deps.logInfo(
'Immersion tracker seed failed: media path still unavailable after startup warmup',
);
} finally {
isSeedInProgress = false;
}
};
const syncFromCurrentMediaState = (): void => {
const tracker = deps.getTracker();
if (!tracker) {
deps.logDebug('Immersion tracker sync skipped: tracker not initialized yet.');
return;
}
const pathFromState =
trimToNull(deps.getCurrentMediaPath()) || trimToNull(deps.getMpvClient()?.currentVideoPath);
if (pathFromState) {
deps.logDebug('Immersion tracker sync using path from current media state.');
tracker.handleMediaChange(pathFromState, trimToNull(deps.getCurrentMediaTitle()));
return;
}
if (!isSeedInProgress) {
deps.logDebug('Immersion tracker sync did not find media path; starting seed loop.');
void seedFromCurrentMedia();
} else {
deps.logDebug('Immersion tracker sync found seed loop already running.');
}
};
return {
getConfiguredDbPath,
seedFromCurrentMedia,
syncFromCurrentMediaState,
};
}

View File

@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImmersionTrackerStartupHandler } from './immersion-startup';
function makeConfig() {
return {
immersionTracking: {
enabled: true,
batchSize: 40,
flushIntervalMs: 1500,
queueCap: 500,
payloadCapBytes: 16000,
maintenanceIntervalMs: 3600000,
retention: {
eventsDays: 14,
telemetryDays: 30,
dailyRollupsDays: 180,
monthlyRollupsDays: 730,
vacuumIntervalDays: 7,
},
},
};
}
test('createImmersionTrackerStartupHandler skips when disabled', () => {
const calls: string[] = [];
let tracker: unknown = 'unchanged';
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => ({
immersionTracking: {
...makeConfig().immersionTracking,
enabled: false,
},
}),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => {
calls.push('createTrackerService');
return {};
},
setTracker: (nextTracker) => {
tracker = nextTracker;
},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
});
handler();
assert.ok(calls.includes('info:Immersion tracking disabled in config'));
assert.equal(calls.includes('createTrackerService'), false);
assert.equal(calls.includes('seedTracker'), false);
assert.equal(tracker, 'unchanged');
});
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
const calls: string[] = [];
const trackerInstance = { kind: 'tracker' };
let assignedTracker: unknown = null;
let receivedDbPath = '';
let receivedPolicy: unknown;
let connectCalls = 0;
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: (params) => {
receivedDbPath = params.dbPath;
receivedPolicy = params.policy;
return trackerInstance;
},
setTracker: (nextTracker) => {
assignedTracker = nextTracker;
},
getMpvClient: () => ({
connected: false,
connect: () => {
connectCalls += 1;
},
}),
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
});
handler();
assert.equal(receivedDbPath, '/tmp/subminer.db');
assert.deepEqual(receivedPolicy, {
batchSize: 40,
flushIntervalMs: 1500,
queueCap: 500,
payloadCapBytes: 16000,
maintenanceIntervalMs: 3600000,
retention: {
eventsDays: 14,
telemetryDays: 30,
dailyRollupsDays: 180,
monthlyRollupsDays: 730,
vacuumIntervalDays: 7,
},
});
assert.equal(assignedTracker, trackerInstance);
assert.equal(connectCalls, 1);
assert.ok(calls.includes('seedTracker'));
assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking'));
});
test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
const calls: string[] = [];
let assignedTracker: unknown = 'initial';
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => {
throw new Error('db unavailable');
},
setTracker: (nextTracker) => {
assignedTracker = nextTracker;
},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`),
});
handler();
assert.equal(assignedTracker, null);
assert.equal(calls.includes('seedTracker'), false);
assert.ok(
calls.includes('warn:Immersion tracker startup failed; disabling tracking.:db unavailable'),
);
});

View File

@@ -0,0 +1,99 @@
type ImmersionRetentionPolicy = {
eventsDays: number;
telemetryDays: number;
dailyRollupsDays: number;
monthlyRollupsDays: number;
vacuumIntervalDays: number;
};
type ImmersionTrackingPolicy = {
enabled?: boolean;
batchSize: number;
flushIntervalMs: number;
queueCap: number;
payloadCapBytes: number;
maintenanceIntervalMs: number;
retention: ImmersionRetentionPolicy;
};
type ImmersionTrackingConfig = {
immersionTracking?: ImmersionTrackingPolicy;
};
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
type ImmersionTrackerServiceParams = {
dbPath: string;
policy: ImmersionTrackerPolicy;
};
type MpvClientLike = {
connected: boolean;
connect: () => void;
};
export type ImmersionTrackerStartupDeps = {
getResolvedConfig: () => ImmersionTrackingConfig;
getConfiguredDbPath: () => string;
createTrackerService: (params: ImmersionTrackerServiceParams) => unknown;
setTracker: (tracker: unknown | null) => void;
getMpvClient: () => MpvClientLike | null;
seedTrackerFromCurrentMedia: () => void;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string, details: unknown) => void;
};
export function createImmersionTrackerStartupHandler(
deps: ImmersionTrackerStartupDeps,
): () => void {
return () => {
const config = deps.getResolvedConfig();
if (config.immersionTracking?.enabled === false) {
deps.logInfo('Immersion tracking disabled in config');
return;
}
try {
deps.logDebug('Immersion tracker startup requested: creating tracker service.');
const dbPath = deps.getConfiguredDbPath();
deps.logInfo(`Creating immersion tracker with dbPath=${dbPath}`);
const policy = config.immersionTracking;
if (!policy) {
throw new Error('Immersion tracking policy missing');
}
deps.setTracker(
deps.createTrackerService({
dbPath,
policy: {
batchSize: policy.batchSize,
flushIntervalMs: policy.flushIntervalMs,
queueCap: policy.queueCap,
payloadCapBytes: policy.payloadCapBytes,
maintenanceIntervalMs: policy.maintenanceIntervalMs,
retention: {
eventsDays: policy.retention.eventsDays,
telemetryDays: policy.retention.telemetryDays,
dailyRollupsDays: policy.retention.dailyRollupsDays,
monthlyRollupsDays: policy.retention.monthlyRollupsDays,
vacuumIntervalDays: policy.retention.vacuumIntervalDays,
},
},
}),
);
deps.logDebug('Immersion tracker initialized successfully.');
const mpvClient = deps.getMpvClient();
if (mpvClient && !mpvClient.connected) {
deps.logInfo('Auto-connecting MPV client for immersion tracking');
mpvClient.connect();
}
deps.seedTrackerFromCurrentMedia();
} catch (error) {
deps.logWarn('Immersion tracker startup failed; disabling tracking.', error);
deps.setTracker(null);
}
};
}

View File

@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
getConfiguredJellyfinSession,
type ActiveJellyfinRemotePlaybackState,
} from './jellyfin-remote-commands';
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
assert.equal(
getConfiguredJellyfinSession({
serverUrl: '',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
null,
);
});
test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => {
const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
audio: params.audioStreamIndex,
subtitle: params.subtitleStreamIndex,
start: params.startTimeTicksOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-1'],
AudioStreamIndex: 3,
SubtitleStreamIndex: 7,
StartPositionTicks: 1000,
});
assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]);
});
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
const warnings: string[] = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
playJellyfinItem: async () => {
throw new Error('should not be called');
},
logWarn: (message) => warnings.push(message),
});
await handlePlay({ ItemIds: [] });
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
});
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
const mpvClient = {};
const commands: Array<(string | number)[]> = [];
const calls: string[] = [];
const handlePlaystate = createHandleJellyfinRemotePlaystate({
getMpvClient: () => mpvClient,
sendMpvCommand: (_client, command) => commands.push(command),
reportJellyfinRemoteProgress: async (force) => {
calls.push(`progress:${force}`);
},
reportJellyfinRemoteStopped: async () => {
calls.push('stopped');
},
jellyfinTicksToSeconds: (ticks) => ticks / 10,
});
await handlePlaystate({ Command: 'Pause' });
await handlePlaystate({ Command: 'Seek', SeekPositionTicks: 50 });
await handlePlaystate({ Command: 'Stop' });
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['seek', 5, 'absolute+exact'],
['stop'],
]);
assert.deepEqual(calls, ['progress:true', 'progress:true', 'stopped']);
});
test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices', async () => {
const mpvClient = {};
const commands: Array<(string | number)[]> = [];
const playback: ActiveJellyfinRemotePlaybackState = {
itemId: 'item-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = [];
const handleGeneral = createHandleJellyfinRemoteGeneralCommand({
getMpvClient: () => mpvClient,
sendMpvCommand: (_client, command) => commands.push(command),
getActivePlayback: () => playback,
reportJellyfinRemoteProgress: async (force) => {
calls.push(`progress:${force}`);
},
logDebug: (message) => {
calls.push(`debug:${message}`);
},
});
await handleGeneral({ Name: 'SetAudioStreamIndex', Arguments: { Index: 2 } });
await handleGeneral({ Name: 'SetSubtitleStreamIndex', Arguments: { Index: -1 } });
await handleGeneral({ Name: 'UnsupportedCommand', Arguments: {} });
assert.deepEqual(commands, [
['set_property', 'aid', 2],
['set_property', 'sid', 'no'],
]);
assert.equal(playback.audioStreamIndex, 2);
assert.equal(playback.subtitleStreamIndex, null);
assert.ok(calls.includes('progress:true'));
assert.ok(
calls.some((entry) => entry.includes('Ignoring unsupported Jellyfin GeneralCommand: UnsupportedCommand')),
);
});

View File

@@ -0,0 +1,189 @@
export type ActiveJellyfinRemotePlaybackState = {
itemId: string;
mediaSourceId?: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode';
};
type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
type JellyfinClientInfo = {
clientName: string;
clientVersion: string;
deviceId: string;
};
type JellyfinConfigLike = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
function asInteger(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
return value;
}
export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null {
if (!config.serverUrl || !config.accessToken || !config.userId) {
return null;
}
return {
serverUrl: config.serverUrl,
accessToken: config.accessToken,
userId: config.userId,
username: config.username,
};
}
export type JellyfinRemotePlayHandlerDeps = {
getConfiguredSession: () => JellyfinSession | null;
getClientInfo: () => JellyfinClientInfo;
getJellyfinConfig: () => unknown;
playJellyfinItem: (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
jellyfinConfig: unknown;
itemId: string;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
startTimeTicksOverride?: number;
setQuitOnDisconnectArm?: boolean;
}) => Promise<void>;
logWarn: (message: string) => void;
};
export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDeps) {
return async (payload: unknown): Promise<void> => {
const session = deps.getConfiguredSession();
if (!session) return;
const clientInfo = deps.getClientInfo();
const jellyfinConfig = deps.getJellyfinConfig();
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const itemIds = Array.isArray(data.ItemIds)
? data.ItemIds.filter((entry): entry is string => typeof entry === 'string')
: [];
const itemId = itemIds[0];
if (!itemId) {
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
return;
}
await deps.playJellyfinItem({
session,
clientInfo,
jellyfinConfig,
itemId,
audioStreamIndex: asInteger(data.AudioStreamIndex),
subtitleStreamIndex: asInteger(data.SubtitleStreamIndex),
startTimeTicksOverride: asInteger(data.StartPositionTicks),
setQuitOnDisconnectArm: false,
});
};
}
type MpvClientLike = object;
export type JellyfinRemotePlaystateHandlerDeps = {
getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (client: MpvClientLike, command: (string | number)[]) => void;
reportJellyfinRemoteProgress: (force: boolean) => Promise<void>;
reportJellyfinRemoteStopped: () => Promise<void>;
jellyfinTicksToSeconds: (ticks: number) => number;
};
export function createHandleJellyfinRemotePlaystate(deps: JellyfinRemotePlaystateHandlerDeps) {
return async (payload: unknown): Promise<void> => {
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const command = String(data.Command || '');
const client = deps.getMpvClient();
if (!client) return;
if (command === 'Pause') {
deps.sendMpvCommand(client, ['set_property', 'pause', 'yes']);
await deps.reportJellyfinRemoteProgress(true);
return;
}
if (command === 'Unpause') {
deps.sendMpvCommand(client, ['set_property', 'pause', 'no']);
await deps.reportJellyfinRemoteProgress(true);
return;
}
if (command === 'PlayPause') {
deps.sendMpvCommand(client, ['cycle', 'pause']);
await deps.reportJellyfinRemoteProgress(true);
return;
}
if (command === 'Stop') {
deps.sendMpvCommand(client, ['stop']);
await deps.reportJellyfinRemoteStopped();
return;
}
if (command === 'Seek') {
const seekTicks = asInteger(data.SeekPositionTicks);
if (seekTicks !== undefined) {
deps.sendMpvCommand(client, [
'seek',
deps.jellyfinTicksToSeconds(seekTicks),
'absolute+exact',
]);
await deps.reportJellyfinRemoteProgress(true);
}
}
};
}
export type JellyfinRemoteGeneralCommandHandlerDeps = {
getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (client: MpvClientLike, command: (string | number)[]) => void;
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
reportJellyfinRemoteProgress: (force: boolean) => Promise<void>;
logDebug: (message: string) => void;
};
export function createHandleJellyfinRemoteGeneralCommand(
deps: JellyfinRemoteGeneralCommandHandlerDeps,
) {
return async (payload: unknown): Promise<void> => {
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const command = String(data.Name || '');
const args =
data.Arguments && typeof data.Arguments === 'object'
? (data.Arguments as Record<string, unknown>)
: {};
const client = deps.getMpvClient();
if (!client) return;
if (command === 'SetAudioStreamIndex') {
const index = asInteger(args.Index);
if (index !== undefined) {
deps.sendMpvCommand(client, ['set_property', 'aid', index]);
const playback = deps.getActivePlayback();
if (playback) {
playback.audioStreamIndex = index;
}
await deps.reportJellyfinRemoteProgress(true);
}
return;
}
if (command === 'SetSubtitleStreamIndex') {
const index = asInteger(args.Index);
if (index !== undefined) {
deps.sendMpvCommand(client, ['set_property', 'sid', index < 0 ? 'no' : index]);
const playback = deps.getActivePlayback();
if (playback) {
playback.subtitleStreamIndex = index < 0 ? null : index;
}
await deps.reportJellyfinRemoteProgress(true);
}
return;
}
deps.logDebug(`Ignoring unsupported Jellyfin GeneralCommand: ${command}`);
};
}

View File

@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createLaunchMpvIdleForJellyfinPlaybackHandler,
createWaitForMpvConnectedHandler,
} from './jellyfin-remote-connection';
test('createWaitForMpvConnectedHandler connects and waits for readiness', async () => {
let connected = false;
let nowMs = 0;
const waitForConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => ({
connected,
connect: () => {
connected = true;
},
}),
now: () => nowMs,
sleep: async () => {
nowMs += 100;
},
});
const ready = await waitForConnected(500);
assert.equal(ready, true);
});
test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', () => {
const spawnedArgs: string[][] = [];
const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
platform: 'darwin',
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
defaultMpvLogPath: '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: (message) => logs.push(message),
logInfo: (message) => logs.push(message),
});
launch();
assert.equal(spawnedArgs.length, 1);
assert.ok(spawnedArgs[0].includes('--idle=yes'));
assert.ok(spawnedArgs[0].some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
let autoLaunchInFlight: Promise<boolean> | null = null;
let launchCalls = 0;
let waitCalls = 0;
let mpvClient: { connected: boolean; connect: () => void } | null = null;
let resolveAutoLaunchPromise: (value: boolean) => void = () => {};
const autoLaunchPromise = new Promise<boolean>((resolve) => {
resolveAutoLaunchPromise = resolve;
});
const ensureConnected = createEnsureMpvConnectedForJellyfinPlaybackHandler({
getMpvClient: () => mpvClient,
setMpvClient: (client) => {
mpvClient = client;
},
createMpvClient: () => ({
connected: false,
connect: () => {},
}),
waitForMpvConnected: async (timeoutMs) => {
waitCalls += 1;
if (timeoutMs === 3000) return false;
return await autoLaunchPromise;
},
launchMpvIdleForJellyfinPlayback: () => {
launchCalls += 1;
},
getAutoLaunchInFlight: () => autoLaunchInFlight,
setAutoLaunchInFlight: (promise) => {
autoLaunchInFlight = promise;
},
connectTimeoutMs: 3000,
autoLaunchTimeoutMs: 20000,
});
const firstPromise = ensureConnected();
const secondPromise = ensureConnected();
resolveAutoLaunchPromise(true);
const first = await firstPromise;
const second = await secondPromise;
assert.equal(first, true);
assert.equal(second, true);
assert.equal(launchCalls, 1);
assert.equal(waitCalls >= 2, true);
});

View File

@@ -0,0 +1,108 @@
type MpvClientLike = {
connected: boolean;
connect: () => void;
};
type SpawnedProcessLike = {
on: (event: 'error', listener: (error: unknown) => void) => void;
unref: () => void;
};
export type WaitForMpvConnectedDeps = {
getMpvClient: () => MpvClientLike | null;
now: () => number;
sleep: (delayMs: number) => Promise<void>;
};
export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps) {
return async (timeoutMs = 7000): Promise<boolean> => {
const client = deps.getMpvClient();
if (!client) return false;
if (client.connected) return true;
try {
client.connect();
} catch {}
const startedAt = deps.now();
while (deps.now() - startedAt < timeoutMs) {
if (deps.getMpvClient()?.connected) return true;
await deps.sleep(100);
}
return Boolean(deps.getMpvClient()?.connected);
};
}
export type LaunchMpvForJellyfinDeps = {
getSocketPath: () => string;
platform: NodeJS.Platform;
execPath: string;
defaultMpvLogPath: string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
logWarn: (message: string, error: unknown) => void;
logInfo: (message: string) => void;
};
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
return (): void => {
const socketPath = deps.getSocketPath();
if (deps.platform !== 'win32') {
try {
deps.removeSocketPath(socketPath);
} catch {
// ignore stale socket cleanup errors
}
}
const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`;
const mpvArgs = [
...deps.defaultMpvArgs,
'--idle=yes',
scriptOpts,
`--log-file=${deps.defaultMpvLogPath}`,
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
proc.on('error', (error) => {
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
});
proc.unref();
deps.logInfo(`Launched mpv for Jellyfin playback on socket: ${socketPath}`);
};
}
export type EnsureMpvConnectedDeps = {
getMpvClient: () => MpvClientLike | null;
setMpvClient: (client: MpvClientLike | null) => void;
createMpvClient: () => MpvClientLike;
waitForMpvConnected: (timeoutMs: number) => Promise<boolean>;
launchMpvIdleForJellyfinPlayback: () => void;
getAutoLaunchInFlight: () => Promise<boolean> | null;
setAutoLaunchInFlight: (promise: Promise<boolean> | null) => void;
connectTimeoutMs: number;
autoLaunchTimeoutMs: number;
};
export function createEnsureMpvConnectedForJellyfinPlaybackHandler(deps: EnsureMpvConnectedDeps) {
return async (): Promise<boolean> => {
if (!deps.getMpvClient()) {
deps.setMpvClient(deps.createMpvClient());
}
const connected = await deps.waitForMpvConnected(deps.connectTimeoutMs);
if (connected) return true;
if (!deps.getAutoLaunchInFlight()) {
const inFlight = (async () => {
deps.launchMpvIdleForJellyfinPlayback();
return deps.waitForMpvConnected(deps.autoLaunchTimeoutMs);
})().finally(() => {
deps.setAutoLaunchInFlight(null);
});
deps.setAutoLaunchInFlight(inFlight);
}
return deps.getAutoLaunchInFlight() as Promise<boolean>;
};
}

View File

@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks,
} from './jellyfin-remote-playback';
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
assert.equal(secondsToJellyfinTicks(1.25, 10_000_000), 12_500_000);
assert.equal(secondsToJellyfinTicks(-3, 10_000_000), 0);
assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0);
});
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
let lastProgressAtMs = 0;
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({
itemId: payload.itemId,
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
}),
getNow: () => 5000,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
assert.deepEqual(reportPayloads, [
{
itemId: 'item-1',
positionTicks: 25_000_000,
isPaused: true,
},
]);
assert.equal(lastProgressAtMs, 5000);
});
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {
called = true;
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async () => 1,
}),
getNow: () => 4000,
getLastProgressAtMs: () => 3500,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(false);
assert.equal(called, false);
});
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
let cleared = false;
let stoppedItemId: string | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async (payload) => {
stoppedItemId = payload.itemId;
},
}),
logDebug: () => {},
});
await reportStopped();
assert.equal(stoppedItemId, 'item-2');
assert.equal(cleared, true);
});

View File

@@ -0,0 +1,109 @@
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
type JellyfinRemoteSessionLike = {
isConnected: () => boolean;
reportProgress: (payload: {
itemId: string;
mediaSourceId?: string;
positionTicks: number;
isPaused: boolean;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'timeupdate';
}) => Promise<unknown>;
reportStopped: (payload: {
itemId: string;
mediaSourceId?: string;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'stop';
}) => Promise<unknown>;
};
type MpvClientLike = {
requestProperty: (name: string) => Promise<unknown>;
};
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
if (!Number.isFinite(seconds)) return 0;
return Math.max(0, Math.floor(seconds * ticksPerSecond));
}
export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null;
getMpvClient: () => MpvClientLike | null;
getNow: () => number;
getLastProgressAtMs: () => number;
setLastProgressAtMs: (value: number) => void;
progressIntervalMs: number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
};
export function createReportJellyfinRemoteProgressHandler(deps: JellyfinRemoteProgressReporterDeps) {
return async (force = false): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
if (!session || !session.isConnected()) return;
const now = deps.getNow();
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
return;
}
try {
const mpvClient = deps.getMpvClient();
const position = await mpvClient?.requestProperty('time-pos');
const paused = await mpvClient?.requestProperty('pause');
await session.reportProgress({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
isPaused: paused === true,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'timeupdate',
});
deps.setLastProgressAtMs(now);
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote progress', error);
}
};
}
export type JellyfinRemoteStoppedReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null;
logDebug: (message: string, error: unknown) => void;
};
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
return async (): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
if (!session || !session.isConnected()) {
deps.clearActivePlayback();
return;
}
try {
await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'stop',
});
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
deps.clearActivePlayback();
}
};
}

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCriticalConfigErrorHandler,
createReloadConfigHandler,
} from './startup-config';
test('createReloadConfigHandler runs success flow with warnings', async () => {
const calls: string[] = [];
const refreshCalls: { force: boolean }[] = [];
const reloadConfig = createReloadConfigHandler({
reloadConfigStrict: () => ({
ok: true,
path: '/tmp/config.jsonc',
warnings: [
{
path: 'ankiConnect.pollingRate',
message: 'must be >= 50',
value: 10,
fallback: 250,
},
],
}),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
refreshAnilistClientSecretState: async (options) => {
refreshCalls.push(options);
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
reloadConfig();
await Promise.resolve();
assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')));
assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)')));
assert.ok(
calls.some((entry) =>
entry.includes('notify:SubMiner:1 config validation issue(s) detected.'),
),
);
assert.ok(calls.some((entry) => entry.includes('1. ankiConnect.pollingRate: must be >= 50')));
assert.ok(calls.includes('hotReload:start'));
assert.deepEqual(refreshCalls, [{ force: true }]);
});
test('createReloadConfigHandler fails startup for parse errors', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
const reloadConfig = createReloadConfigHandler({
reloadConfigStrict: () => ({
ok: false,
path: '/tmp/config.jsonc',
error: 'unexpected token',
}),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
refreshAnilistClientSecretState: async () => {
calls.push('refresh');
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
assert.throws(() => reloadConfig(), /Failed to parse config file at:/);
assert.equal(process.exitCode, 1);
assert.ok(calls.some((entry) => entry.startsWith('error:Failed to parse config file at:')));
assert.ok(
calls.some((entry) =>
entry.startsWith('dialog:SubMiner config parse error:Failed to parse config file at:'),
),
);
assert.ok(calls.includes('quit'));
assert.equal(calls.includes('hotReload:start'), false);
process.exitCode = previousExitCode;
});
test('createCriticalConfigErrorHandler formats and fails', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
const handleCriticalErrors = createCriticalConfigErrorHandler({
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
assert.throws(
() => handleCriticalErrors(['foo invalid', 'bar invalid']),
/Critical config validation failed/,
);
assert.equal(process.exitCode, 1);
assert.ok(calls.some((entry) => entry.includes('/tmp/config.jsonc')));
assert.ok(calls.some((entry) => entry.includes('1. foo invalid')));
assert.ok(calls.some((entry) => entry.includes('2. bar invalid')));
assert.ok(calls.includes('quit'));
process.exitCode = previousExitCode;
});

View File

@@ -0,0 +1,83 @@
import type { ConfigValidationWarning } from '../../types';
import {
buildConfigWarningNotificationBody,
buildConfigWarningSummary,
failStartupFromConfig,
} from '../config-validation';
type ReloadConfigFailure = {
ok: false;
path: string;
error: string;
};
type ReloadConfigSuccess = {
ok: true;
path: string;
warnings: ConfigValidationWarning[];
};
type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
export type ReloadConfigRuntimeDeps = {
reloadConfigStrict: () => ReloadConfigStrictResult;
logInfo: (message: string) => void;
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
};
export type CriticalConfigErrorRuntimeDeps = {
getConfigPath: () => string;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
};
export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () => void {
return () => {
const result = deps.reloadConfigStrict();
if (!result.ok) {
failStartupFromConfig(
'SubMiner config parse error',
`Failed to parse config file at:\n${result.path}\n\nError: ${result.error}\n\nFix the config file and restart SubMiner.`,
deps.failHandlers,
);
}
deps.logInfo(`Using config file: ${result.path}`);
if (result.warnings.length > 0) {
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
deps.showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(result.path, result.warnings),
});
}
deps.startConfigHotReload();
void deps.refreshAnilistClientSecretState({ force: true });
};
}
export function createCriticalConfigErrorHandler(
deps: CriticalConfigErrorRuntimeDeps,
): (errors: string[]) => never {
return (errors: string[]) => {
const configPath = deps.getConfigPath();
const details = [
`Critical config validation failed. File: ${configPath}`,
'',
...errors.map((error, index) => `${index + 1}. ${error}`),
'',
'Fix the config file and restart SubMiner.',
].join('\n');
return failStartupFromConfig('SubMiner config validation error', details, deps.failHandlers);
};
}

View File

@@ -0,0 +1,37 @@
import type { MpvIpcClient } from '../../core/services';
import { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from '../../core/services';
import type { SubsyncResult, SubsyncManualPayload, SubsyncManualRunRequest, ResolvedConfig } from '../../types';
import { getSubsyncConfig } from '../../subsync/utils';
import { createSubsyncRuntimeServiceInputFromState } from '../subsync-runtime';
export type MainSubsyncRuntimeDeps = {
getMpvClient: () => MpvIpcClient | null;
getResolvedConfig: () => ResolvedConfig;
getSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
openManualPicker: (payload: SubsyncManualPayload) => void;
};
export function createMainSubsyncRuntime(deps: MainSubsyncRuntimeDeps): {
triggerFromConfig: () => Promise<void>;
runManualFromIpc: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
} {
const getRuntimeServiceParams = () =>
createSubsyncRuntimeServiceInputFromState({
getMpvClient: () => deps.getMpvClient(),
getResolvedSubsyncConfig: () => getSubsyncConfig(deps.getResolvedConfig().subsync),
getSubsyncInProgress: () => deps.getSubsyncInProgress(),
setSubsyncInProgress: (inProgress: boolean) => deps.setSubsyncInProgress(inProgress),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openManualPicker: (payload: SubsyncManualPayload) => deps.openManualPicker(payload),
});
return {
triggerFromConfig: async (): Promise<void> => {
await triggerSubsyncFromConfigRuntime(getRuntimeServiceParams());
},
runManualFromIpc: async (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
runSubsyncManualFromIpcRuntime(request, getRuntimeServiceParams()),
};
}