refactor(main): introduce explicit AniList runtime transitions

This commit is contained in:
2026-02-21 16:16:30 -08:00
parent 7a869ad291
commit 631e0450b1
10 changed files with 508 additions and 113 deletions

View File

@@ -392,7 +392,6 @@ import {
} from './core/services';
import {
guessAnilistMediaInfo,
type AnilistMediaGuess,
updateAnilistPostWatchProgress,
} from './core/services/anilist/anilist-updater';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
@@ -417,7 +416,21 @@ import {
} from './main/frequency-dictionary-runtime';
import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { type AppState, type StartupState, applyStartupState, createAppState } from './main/state';
import {
type AnilistMediaGuessRuntimeState,
type AppState,
type StartupState,
applyStartupState,
createAppState,
createInitialAnilistMediaGuessRuntimeState,
createInitialAnilistUpdateInFlightState,
transitionAnilistClientSecretState,
transitionAnilistMediaGuessRuntimeState,
transitionAnilistRetryQueueLastAttemptAt,
transitionAnilistRetryQueueLastError,
transitionAnilistRetryQueueState,
transitionAnilistUpdateInFlightState,
} from './main/state';
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
@@ -464,12 +477,9 @@ const JELLYFIN_TOKEN_STORE_FILE = 'jellyfin-token-store.json';
const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json';
const TRAY_TOOLTIP = 'SubMiner';
let anilistCurrentMediaKey: string | null = null;
let anilistCurrentMediaDurationSec: number | null = null;
let anilistCurrentMediaGuess: AnilistMediaGuess | null = null;
let anilistCurrentMediaGuessPromise: Promise<AnilistMediaGuess | null> | null = null;
let anilistLastDurationProbeAtMs = 0;
let anilistUpdateInFlight = false;
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
createInitialAnilistMediaGuessRuntimeState();
let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState();
const anilistAttemptedUpdateKeys = new Set<string>();
let anilistCachedAccessToken: string | null = null;
let jellyfinPlayQuitOnDisconnectArmed = false;
@@ -644,11 +654,17 @@ const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRunti
const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMainDepsHandler({
getClientSecretState: () => appState.anilistClientSecretState,
setClientSecretState: (next) => {
appState.anilistClientSecretState = next;
appState.anilistClientSecretState = transitionAnilistClientSecretState(
appState.anilistClientSecretState,
next,
);
},
getRetryQueueState: () => appState.anilistRetryQueueState,
setRetryQueueState: (next) => {
appState.anilistRetryQueueState = next;
appState.anilistRetryQueueState = transitionAnilistRetryQueueState(
appState.anilistRetryQueueState,
next,
);
},
getUpdateQueueSnapshot: () => anilistUpdateQueue.getSnapshot(),
clearStoredToken: () => anilistTokenStore.clearToken(),
@@ -1563,51 +1579,87 @@ const {
},
resetMediaTrackingMainDeps: {
setMediaKey: (value) => {
anilistCurrentMediaKey = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaKey: value },
);
},
setMediaDurationSec: (value) => {
anilistCurrentMediaDurationSec = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaDurationSec: value },
);
},
setMediaGuess: (value) => {
anilistCurrentMediaGuess = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuess: value },
);
},
setMediaGuessPromise: (value) => {
anilistCurrentMediaGuessPromise = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuessPromise: value },
);
},
setLastDurationProbeAtMs: (value) => {
anilistLastDurationProbeAtMs = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ lastDurationProbeAtMs: value },
);
},
},
getMediaGuessRuntimeStateMainDeps: {
getMediaKey: () => anilistCurrentMediaKey,
getMediaDurationSec: () => anilistCurrentMediaDurationSec,
getMediaGuess: () => anilistCurrentMediaGuess,
getMediaGuessPromise: () => anilistCurrentMediaGuessPromise,
getLastDurationProbeAtMs: () => anilistLastDurationProbeAtMs,
getMediaKey: () => anilistMediaGuessRuntimeState.mediaKey,
getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec,
getMediaGuess: () => anilistMediaGuessRuntimeState.mediaGuess,
getMediaGuessPromise: () => anilistMediaGuessRuntimeState.mediaGuessPromise,
getLastDurationProbeAtMs: () => anilistMediaGuessRuntimeState.lastDurationProbeAtMs,
},
setMediaGuessRuntimeStateMainDeps: {
setMediaKey: (value) => {
anilistCurrentMediaKey = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaKey: value },
);
},
setMediaDurationSec: (value) => {
anilistCurrentMediaDurationSec = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaDurationSec: value },
);
},
setMediaGuess: (value) => {
anilistCurrentMediaGuess = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuess: value },
);
},
setMediaGuessPromise: (value) => {
anilistCurrentMediaGuessPromise = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuessPromise: value },
);
},
setLastDurationProbeAtMs: (value) => {
anilistLastDurationProbeAtMs = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ lastDurationProbeAtMs: value },
);
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
anilistCurrentMediaGuess = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuess: value },
);
},
setMediaGuessPromise: (value) => {
anilistCurrentMediaGuessPromise = value;
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
anilistMediaGuessRuntimeState,
{ mediaGuessPromise: value },
);
},
},
maybeProbeDurationMainDeps: {
@@ -1635,10 +1687,16 @@ const {
nextReady: () => anilistUpdateQueue.nextReady(),
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
setLastAttemptAt: (value) => {
appState.anilistRetryQueueState.lastAttemptAt = value;
appState.anilistRetryQueueState = transitionAnilistRetryQueueLastAttemptAt(
appState.anilistRetryQueueState,
value,
);
},
setLastError: (value) => {
appState.anilistRetryQueueState.lastError = value;
appState.anilistRetryQueueState = transitionAnilistRetryQueueLastError(
appState.anilistRetryQueueState,
value,
);
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
@@ -1656,15 +1714,18 @@ const {
now: () => Date.now(),
},
maybeRunPostWatchUpdateMainDeps: {
getInFlight: () => anilistUpdateInFlight,
getInFlight: () => anilistUpdateInFlightState.inFlight,
setInFlight: (value) => {
anilistUpdateInFlight = value;
anilistUpdateInFlightState = transitionAnilistUpdateInFlightState(
anilistUpdateInFlightState,
value,
);
},
getResolvedConfig: () => getResolvedConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
hasMpvClient: () => Boolean(appState.mpvClient),
getTrackedMediaKey: () => anilistCurrentMediaKey,
getTrackedMediaKey: () => anilistMediaGuessRuntimeState.mediaKey,
resetTrackedMedia: (mediaKey) => {
resetAnilistMediaTracking(mediaKey);
},

View File

@@ -53,6 +53,40 @@ test('reset anilist media tracking clears duration/guess/probe state', () => {
assert.equal(lastDurationProbeAtMs, 0);
});
test('reset anilist media tracking is idempotent', () => {
const state = {
mediaKey: 'old' as string | null,
mediaDurationSec: 123 as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 999,
};
const reset = createResetAnilistMediaTrackingHandler({
setMediaKey: (value) => {
state.mediaKey = value;
},
setMediaDurationSec: (value) => {
state.mediaDurationSec = value;
},
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
state.lastDurationProbeAtMs = value;
},
});
reset('/new/media');
const afterFirstReset = { ...state };
reset('/new/media');
assert.deepEqual(state, afterFirstReset);
});
test('get/set anilist media guess runtime state round-trips fields', () => {
let state = {
mediaKey: null as string | null,
@@ -106,19 +140,27 @@ test('get/set anilist media guess runtime state round-trips fields', () => {
});
test('reset anilist media guess state clears guess and in-flight promise', () => {
let mediaGuess: { title: string } | null = { title: 'guess' };
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
const state = {
mediaKey: '/tmp/video.mkv' as string | null,
mediaDurationSec: 240 as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const resetGuessState = createResetAnilistMediaGuessStateHandler({
setMediaGuess: (value) => {
mediaGuess = value as { title: string } | null;
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
mediaGuessPromise = value;
state.mediaGuessPromise = value;
},
});
resetGuessState();
assert.equal(mediaGuess, null);
assert.equal(mediaGuessPromise, null);
assert.equal(state.mediaGuess, null);
assert.equal(state.mediaGuessPromise, null);
assert.equal(state.mediaKey, '/tmp/video.mkv');
assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321);
});

View File

@@ -34,8 +34,6 @@ function createRuntime() {
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 3000,
lastError: 'boom' as string | null,
}),
clearStoredToken: () => {
clearedStoredToken = true;
@@ -71,7 +69,7 @@ test('setClientSecretState merges partial updates', () => {
});
});
test('refresh/get queue snapshot uses update queue snapshot', () => {
test('queue refresh preserves metadata while syncing counts', () => {
const harness = createRuntime();
const snapshot = harness.runtime.getQueueStatusSnapshot();
@@ -79,14 +77,15 @@ test('refresh/get queue snapshot uses update queue snapshot', () => {
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 3000,
lastError: 'boom',
lastAttemptAt: 2000,
lastError: 'none',
});
assert.deepEqual(harness.getQueueState(), snapshot);
});
test('clearTokenState resets token state and clears caches', () => {
const harness = createRuntime();
const queueBeforeClear = { ...harness.getQueueState() };
harness.runtime.clearTokenState();
assert.equal(harness.getClearedStoredToken(), true);
@@ -98,4 +97,5 @@ test('clearTokenState resets token state and clears caches', () => {
resolvedAt: null,
errorAt: null,
});
assert.deepEqual(harness.getQueueState(), queueBeforeClear);
});

93
src/main/state.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createInitialAnilistMediaGuessRuntimeState,
createInitialAnilistUpdateInFlightState,
transitionAnilistClientSecretState,
transitionAnilistMediaGuessRuntimeState,
transitionAnilistRetryQueueLastAttemptAt,
transitionAnilistRetryQueueLastError,
transitionAnilistUpdateInFlightState,
} from './state';
test('transitionAnilistClientSecretState replaces state object', () => {
const current = {
status: 'not_checked',
source: 'none',
message: null,
resolvedAt: null,
errorAt: null,
} as const;
const next = {
status: 'resolved',
source: 'stored',
message: 'ok',
resolvedAt: 123,
errorAt: null,
} as const;
const transitioned = transitionAnilistClientSecretState(current, next);
assert.deepEqual(transitioned, next);
assert.equal(transitioned, next);
});
test('retry queue metadata transitions preserve queue counts', () => {
const queue = {
pending: 2,
ready: 1,
deadLetter: 4,
lastAttemptAt: null,
lastError: null,
};
const attempted = transitionAnilistRetryQueueLastAttemptAt(queue, 999);
const failed = transitionAnilistRetryQueueLastError(attempted, 'boom');
assert.deepEqual(attempted, {
pending: 2,
ready: 1,
deadLetter: 4,
lastAttemptAt: 999,
lastError: null,
});
assert.deepEqual(failed, {
pending: 2,
ready: 1,
deadLetter: 4,
lastAttemptAt: 999,
lastError: 'boom',
});
assert.notEqual(attempted, queue);
assert.notEqual(failed, attempted);
});
test('transitionAnilistMediaGuessRuntimeState applies partial updates', () => {
const current = createInitialAnilistMediaGuessRuntimeState();
const promise = Promise.resolve(null);
const transitioned = transitionAnilistMediaGuessRuntimeState(current, {
mediaKey: '/tmp/media.mkv',
mediaGuessPromise: promise,
lastDurationProbeAtMs: 500,
});
assert.deepEqual(transitioned, {
mediaKey: '/tmp/media.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: promise,
lastDurationProbeAtMs: 500,
});
assert.notEqual(transitioned, current);
});
test('transitionAnilistUpdateInFlightState updates inFlight only', () => {
const current = createInitialAnilistUpdateInFlightState();
const transitioned = transitionAnilistUpdateInFlightState(current, true);
assert.deepEqual(current, { inFlight: false });
assert.deepEqual(transitioned, { inFlight: true });
assert.notEqual(transitioned, current);
});

View File

@@ -12,13 +12,14 @@ import type {
import type { CliArgs } from '../cli/args';
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import type { AnkiIntegration } from '../anki-integration';
import type { ImmersionTrackerService } from '../core/services';
import type { MpvIpcClient } from '../core/services';
import type { JellyfinRemoteSessionService } from '../core/services';
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services';
import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
import type { MpvIpcClient } from '../core/services/mpv';
import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote';
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics';
import type { RuntimeOptionsManager } from '../runtime-options';
import type { MecabTokenizer } from '../mecab-tokenizer';
import type { BaseWindowTracker } from '../window-trackers';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
export interface AnilistSecretResolutionState {
status: 'not_checked' | 'resolved' | 'error';
@@ -36,6 +37,108 @@ export interface AnilistRetryQueueState {
lastError: string | null;
}
export interface AnilistMediaGuessRuntimeState {
mediaKey: string | null;
mediaDurationSec: number | null;
mediaGuess: AnilistMediaGuess | null;
mediaGuessPromise: Promise<AnilistMediaGuess | null> | null;
lastDurationProbeAtMs: number;
}
export interface AnilistUpdateInFlightState {
inFlight: boolean;
}
export function createInitialAnilistSecretResolutionState(): AnilistSecretResolutionState {
return {
status: 'not_checked',
source: 'none',
message: null,
resolvedAt: null,
errorAt: null,
};
}
export function createInitialAnilistRetryQueueState(): AnilistRetryQueueState {
return {
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
};
}
export function createInitialAnilistMediaGuessRuntimeState(): AnilistMediaGuessRuntimeState {
return {
mediaKey: null,
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
}
export function createInitialAnilistUpdateInFlightState(): AnilistUpdateInFlightState {
return {
inFlight: false,
};
}
export function transitionAnilistClientSecretState(
_current: AnilistSecretResolutionState,
next: AnilistSecretResolutionState,
): AnilistSecretResolutionState {
return next;
}
export function transitionAnilistRetryQueueState(
_current: AnilistRetryQueueState,
next: AnilistRetryQueueState,
): AnilistRetryQueueState {
return next;
}
export function transitionAnilistRetryQueueLastAttemptAt(
current: AnilistRetryQueueState,
lastAttemptAt: number | null,
): AnilistRetryQueueState {
return {
...current,
lastAttemptAt,
};
}
export function transitionAnilistRetryQueueLastError(
current: AnilistRetryQueueState,
lastError: string | null,
): AnilistRetryQueueState {
return {
...current,
lastError,
};
}
export function transitionAnilistMediaGuessRuntimeState(
current: AnilistMediaGuessRuntimeState,
partial: Partial<AnilistMediaGuessRuntimeState>,
): AnilistMediaGuessRuntimeState {
return {
...current,
...partial,
};
}
export function transitionAnilistUpdateInFlightState(
current: AnilistUpdateInFlightState,
inFlight: boolean,
): AnilistUpdateInFlightState {
return {
...current,
inFlight,
};
}
export interface AppState {
yomitanExt: Extension | null;
yomitanSettingsWindow: BrowserWindow | null;
@@ -123,13 +226,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentMediaPath: null,
currentMediaTitle: null,
pendingSubtitlePosition: null,
anilistClientSecretState: {
status: 'not_checked',
source: 'none',
message: null,
resolvedAt: null,
errorAt: null,
},
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
mecabTokenizer: null,
keybindings: [],
subtitleTimingTracker: null,
@@ -159,13 +256,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
jlptLevelLookup: () => null,
frequencyRankLookup: () => null,
anilistSetupPageOpened: false,
anilistRetryQueueState: {
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
},
anilistRetryQueueState: createInitialAnilistRetryQueueState(),
};
}