mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(main): modularize runtime and harden anilist setup flow
This commit is contained in:
@@ -889,4 +889,16 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"knownWord": "#a6da95"/);
|
||||
assert.match(output, /"minSentenceWords": 3/);
|
||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||
assert.match(
|
||||
output,
|
||||
/"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -503,7 +503,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
path: 'anilist.accessToken',
|
||||
kind: 'string',
|
||||
defaultValue: DEFAULT_CONFIG.anilist.accessToken,
|
||||
description: 'AniList access token used for post-watch updates.',
|
||||
description:
|
||||
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
import { ResolvedConfig } from '../types';
|
||||
import { CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG, deepCloneConfig } from './definitions';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
DEFAULT_CONFIG,
|
||||
deepCloneConfig,
|
||||
} from './definitions';
|
||||
|
||||
function renderValue(value: unknown, indent = 0): string {
|
||||
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
||||
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
||||
);
|
||||
|
||||
function normalizeCommentText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
||||
}
|
||||
|
||||
function humanizeKey(key: string): string {
|
||||
const spaced = key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase();
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function buildInlineOptionComment(path: string, value: unknown): string {
|
||||
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
|
||||
const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
|
||||
const description =
|
||||
baseDescription && baseDescription.trim().length > 0
|
||||
? normalizeCommentText(baseDescription)
|
||||
: `${humanizeKey(path.split('.').at(-1) ?? path)} setting.`;
|
||||
|
||||
if (registryEntry?.enumValues?.length) {
|
||||
return `${description} Values: ${registryEntry.enumValues.join(' | ')}`;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return `${description} Values: true | false`;
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
function renderValue(value: unknown, indent = 0, path = ''): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const nextPad = ' '.repeat(indent + 2);
|
||||
|
||||
@@ -11,7 +50,7 @@ function renderValue(value: unknown, indent = 0): string {
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '[]';
|
||||
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`);
|
||||
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2, `${path}[]`)}`);
|
||||
return `\n${items.join(',\n')}\n${pad}`.replace(/^/, '[').concat(']');
|
||||
}
|
||||
|
||||
@@ -20,10 +59,18 @@ function renderValue(value: unknown, indent = 0): string {
|
||||
([, child]) => child !== undefined,
|
||||
);
|
||||
if (entries.length === 0) return '{}';
|
||||
const lines = entries.map(
|
||||
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
|
||||
);
|
||||
return `\n${lines.join(',\n')}\n${pad}`.replace(/^/, '{').concat('}');
|
||||
const lines = entries.map(([key, child], index) => {
|
||||
const isLast = index === entries.length - 1;
|
||||
const trailingComma = isLast ? '' : ',';
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
const renderedChild = renderValue(child, indent + 2, childPath);
|
||||
const comment = buildInlineOptionComment(childPath, child);
|
||||
if (renderedChild.startsWith('\n')) {
|
||||
return `${nextPad}${JSON.stringify(key)}: /* ${comment} */ ${renderedChild}${trailingComma}`;
|
||||
}
|
||||
return `${nextPad}${JSON.stringify(key)}: ${renderedChild}${trailingComma} // ${comment}`;
|
||||
});
|
||||
return `\n${lines.join('\n')}\n${pad}`.replace(/^/, '{').concat('}');
|
||||
}
|
||||
|
||||
return 'null';
|
||||
@@ -41,7 +88,17 @@ function renderSection(
|
||||
lines.push(` // ${comment}`);
|
||||
}
|
||||
lines.push(' // ==========================================');
|
||||
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? '' : ','}`);
|
||||
const inlineComment = buildInlineOptionComment(String(key), value);
|
||||
const renderedValue = renderValue(value, 2, String(key));
|
||||
if (renderedValue.startsWith('\n')) {
|
||||
lines.push(
|
||||
` ${JSON.stringify(key)}: /* ${inlineComment} */ ${renderedValue}${isLast ? '' : ','}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
` ${JSON.stringify(key)}: ${renderedValue}${isLast ? '' : ','} // ${inlineComment}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -197,8 +197,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test('handleCliCommand ignores --start for second-instance without actions', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
});
|
||||
const args = makeArgs({ start: true });
|
||||
|
||||
handleCliCommand(args, 'second-instance', deps);
|
||||
@@ -210,6 +212,23 @@ test('handleCliCommand ignores --start for second-instance without actions', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const args = makeArgs({ start: true });
|
||||
|
||||
handleCliCommand(args, 'second-instance', deps);
|
||||
|
||||
assert.equal(
|
||||
calls.some((value) => value === 'log:Ignoring --start because SubMiner is already running.'),
|
||||
false,
|
||||
);
|
||||
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
|
||||
assert.equal(
|
||||
calls.some((value) => value.includes('connectMpvClient')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand runs texthooker flow with browser open', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const args = makeArgs({ texthooker: true });
|
||||
@@ -239,6 +258,7 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps);
|
||||
|
||||
assert.ok(calls.includes('initializeOverlayRuntime'));
|
||||
assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock'));
|
||||
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock'));
|
||||
assert.ok(calls.includes('connectMpvClient'));
|
||||
@@ -304,6 +324,16 @@ test('handleCliCommand still runs non-start actions on second-instance', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand connects MPV for toggle on second-instance', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
|
||||
assert.ok(calls.includes('toggleVisibleOverlay'));
|
||||
assert.equal(
|
||||
calls.some((value) => value === 'connectMpvClient'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand handles visibility and utility command dispatches', () => {
|
||||
const cases: Array<{
|
||||
args: Partial<CliArgs>;
|
||||
|
||||
@@ -275,7 +275,11 @@ export function handleCliCommand(
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.texthooker ||
|
||||
args.help;
|
||||
const ignoreStartOnly = source === 'second-instance' && args.start && !hasNonStartAction;
|
||||
const ignoreStartOnly =
|
||||
source === 'second-instance' &&
|
||||
args.start &&
|
||||
!hasNonStartAction &&
|
||||
deps.isOverlayRuntimeInitialized();
|
||||
if (ignoreStartOnly) {
|
||||
deps.log('Ignoring --start because SubMiner is already running.');
|
||||
return;
|
||||
@@ -283,9 +287,11 @@ export function handleCliCommand(
|
||||
|
||||
const shouldStart =
|
||||
args.start ||
|
||||
(source === 'initial' &&
|
||||
(args.toggle || args.toggleVisibleOverlay || args.toggleInvisibleOverlay));
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
if (args.socketPath !== undefined) {
|
||||
deps.setMpvSocketPath(args.socketPath);
|
||||
@@ -306,7 +312,7 @@ export function handleCliCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
|
||||
if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ test('config hot reload runtime debounces rapid watch events', () => {
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
onValidationWarnings: () => {},
|
||||
};
|
||||
|
||||
const runtime = createConfigHotReloadRuntime(deps);
|
||||
@@ -103,9 +104,59 @@ test('config hot reload runtime reports invalid config and skips apply', () => {
|
||||
onInvalidConfig: (message) => {
|
||||
invalidMessages.push(message);
|
||||
},
|
||||
onValidationWarnings: () => {
|
||||
throw new Error('Validation warnings should not trigger for invalid config.');
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(watchedChangeCallback, null);
|
||||
assert.equal(invalidMessages.length, 1);
|
||||
});
|
||||
|
||||
test('config hot reload runtime reports validation warnings from reload', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const warningCalls: Array<{ path: string; count: number }> = [];
|
||||
|
||||
const runtime = createConfigHotReloadRuntime({
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => ({
|
||||
ok: true,
|
||||
config: deepCloneConfig(DEFAULT_CONFIG),
|
||||
warnings: [
|
||||
{
|
||||
path: 'ankiConnect.openRouter',
|
||||
message: 'Deprecated key; use ankiConnect.ai instead.',
|
||||
value: { enabled: true },
|
||||
fallback: {},
|
||||
},
|
||||
],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 1 as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
debounceMs: 0,
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
onValidationWarnings: (path, warnings) => {
|
||||
warningCalls.push({ path, count: warnings.length });
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(warningCalls.length, 0);
|
||||
if (!watchedChangeCallback) {
|
||||
throw new Error('Expected watch callback to be registered.');
|
||||
}
|
||||
const trigger = watchedChangeCallback as () => void;
|
||||
trigger();
|
||||
assert.deepEqual(warningCalls, [{ path: '/tmp/config.jsonc', count: 1 }]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReloadConfigStrictResult } from '../../config';
|
||||
import type { ConfigValidationWarning } from '../../types';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export interface ConfigHotReloadDiff {
|
||||
@@ -16,6 +17,7 @@ export interface ConfigHotReloadRuntimeDeps {
|
||||
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
|
||||
onRestartRequired: (fields: string[]) => void;
|
||||
onInvalidConfig: (message: string) => void;
|
||||
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadRuntime {
|
||||
@@ -107,6 +109,10 @@ export function createConfigHotReloadRuntime(
|
||||
watchPath(result.path);
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
deps.onValidationWarnings(result.path, result.warnings);
|
||||
}
|
||||
|
||||
const diff = classifyDiff(prev, result.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.onHotReloadApplied(diff, result.config);
|
||||
|
||||
1210
src/main.ts
1210
src/main.ts
File diff suppressed because it is too large
Load Diff
80
src/main/config-validation.test.ts
Normal file
80
src/main/config-validation.test.ts
Normal 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;
|
||||
});
|
||||
74
src/main/config-validation.ts
Normal file
74
src/main/config-validation.ts
Normal 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);
|
||||
}
|
||||
64
src/main/runtime/anilist-setup-protocol.test.ts
Normal file
64
src/main/runtime/anilist-setup-protocol.test.ts
Normal 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']);
|
||||
});
|
||||
91
src/main/runtime/anilist-setup-protocol.ts
Normal file
91
src/main/runtime/anilist-setup-protocol.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
148
src/main/runtime/anilist-setup.test.ts
Normal file
148
src/main/runtime/anilist-setup.test.ts
Normal 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, /<script>alert\(1\)<\/script>/);
|
||||
});
|
||||
|
||||
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, []);
|
||||
});
|
||||
177
src/main/runtime/anilist-setup.ts
Normal file
177
src/main/runtime/anilist-setup.ts
Normal 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, '<').replace(/>/g, '>');
|
||||
const safeAuth = params.authorizeUrl.replace(/"/g, '"');
|
||||
const safeDev = params.developerSettingsUrl.replace(/"/g, '"');
|
||||
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, '"');
|
||||
const safeDev = params.developerSettingsUrl.replace(/"/g, '"');
|
||||
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,
|
||||
});
|
||||
}
|
||||
101
src/main/runtime/anilist-state.test.ts
Normal file
101
src/main/runtime/anilist-state.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
97
src/main/runtime/anilist-state.ts
Normal file
97
src/main/runtime/anilist-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
src/main/runtime/clipboard-queue.test.ts
Normal file
47
src/main/runtime/clipboard-queue.test.ts
Normal 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);
|
||||
});
|
||||
40
src/main/runtime/clipboard-queue.ts
Normal file
40
src/main/runtime/clipboard-queue.ts
Normal 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}` };
|
||||
}
|
||||
64
src/main/runtime/config-derived.ts
Normal file
64
src/main/runtime/config-derived.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
81
src/main/runtime/config-hot-reload-handlers.test.ts
Normal file
81
src/main/runtime/config-hot-reload-handlers.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
73
src/main/runtime/config-hot-reload-handlers.ts
Normal file
73
src/main/runtime/config-hot-reload-handlers.ts
Normal 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(', ')}`;
|
||||
}
|
||||
76
src/main/runtime/immersion-media.test.ts
Normal file
76
src/main/runtime/immersion-media.test.ts
Normal 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' }]);
|
||||
});
|
||||
174
src/main/runtime/immersion-media.ts
Normal file
174
src/main/runtime/immersion-media.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
137
src/main/runtime/immersion-startup.test.ts
Normal file
137
src/main/runtime/immersion-startup.test.ts
Normal 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'),
|
||||
);
|
||||
});
|
||||
99
src/main/runtime/immersion-startup.ts
Normal file
99
src/main/runtime/immersion-startup.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
141
src/main/runtime/jellyfin-remote-commands.test.ts
Normal file
141
src/main/runtime/jellyfin-remote-commands.test.ts
Normal 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')),
|
||||
);
|
||||
});
|
||||
189
src/main/runtime/jellyfin-remote-commands.ts
Normal file
189
src/main/runtime/jellyfin-remote-commands.ts
Normal 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}`);
|
||||
};
|
||||
}
|
||||
102
src/main/runtime/jellyfin-remote-connection.test.ts
Normal file
102
src/main/runtime/jellyfin-remote-connection.test.ts
Normal 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);
|
||||
});
|
||||
108
src/main/runtime/jellyfin-remote-connection.ts
Normal file
108
src/main/runtime/jellyfin-remote-connection.ts
Normal 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>;
|
||||
};
|
||||
}
|
||||
121
src/main/runtime/jellyfin-remote-playback.test.ts
Normal file
121
src/main/runtime/jellyfin-remote-playback.test.ts
Normal 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);
|
||||
});
|
||||
109
src/main/runtime/jellyfin-remote-playback.ts
Normal file
109
src/main/runtime/jellyfin-remote-playback.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
119
src/main/runtime/startup-config.test.ts
Normal file
119
src/main/runtime/startup-config.test.ts
Normal 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;
|
||||
});
|
||||
83
src/main/runtime/startup-config.ts
Normal file
83
src/main/runtime/startup-config.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
37
src/main/runtime/subsync-runtime.ts
Normal file
37
src/main/runtime/subsync-runtime.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user