feat: stabilize startup sync and overlay/runtime paths

This commit is contained in:
2026-03-17 00:48:55 -07:00
parent de574c04bd
commit 11710f20db
69 changed files with 5323 additions and 495 deletions

View File

@@ -7,6 +7,7 @@ import { updateVisibleOverlayVisibility } from '../core/services';
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
@@ -32,6 +33,7 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
forceMousePassthrough: deps.getForceMousePassthrough(),
mainWindow: deps.getMainWindow(),
windowTracker: deps.getWindowTracker(),
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleCharacterDictionaryAutoSyncComplete } from './character-dictionary-auto-sync-completion';
test('character dictionary sync completion skips expensive subtitle refresh when dictionary is unchanged', () => {
const calls: string[] = [];
handleCharacterDictionaryAutoSyncComplete(
{
mediaId: 1,
mediaTitle: 'Frieren',
changed: false,
},
{
hasParserWindow: () => true,
clearParserCaches: () => calls.push('clear-parser'),
invalidateTokenizationCache: () => calls.push('invalidate'),
refreshSubtitlePrefetch: () => calls.push('prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
logInfo: (message) => calls.push(`log:${message}`),
},
);
assert.deepEqual(calls, [
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=no, title=Frieren)',
]);
});
test('character dictionary sync completion refreshes subtitle state when dictionary changed', () => {
const calls: string[] = [];
handleCharacterDictionaryAutoSyncComplete(
{
mediaId: 1,
mediaTitle: 'Frieren',
changed: true,
},
{
hasParserWindow: () => true,
clearParserCaches: () => calls.push('clear-parser'),
invalidateTokenizationCache: () => calls.push('invalidate'),
refreshSubtitlePrefetch: () => calls.push('prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
logInfo: (message) => calls.push(`log:${message}`),
},
);
assert.deepEqual(calls, [
'clear-parser',
'invalidate',
'prefetch',
'refresh-subtitle',
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=yes, title=Frieren)',
]);
});

View File

@@ -0,0 +1,27 @@
export function handleCharacterDictionaryAutoSyncComplete(
completion: {
mediaId: number;
mediaTitle: string;
changed: boolean;
},
deps: {
hasParserWindow: () => boolean;
clearParserCaches: () => void;
invalidateTokenizationCache: () => void;
refreshSubtitlePrefetch: () => void;
refreshCurrentSubtitle: () => void;
logInfo: (message: string) => void;
},
): void {
if (completion.changed) {
if (deps.hasParserWindow()) {
deps.clearParserCaches();
}
deps.invalidateTokenizationCache();
deps.refreshSubtitlePrefetch();
deps.refreshCurrentSubtitle();
}
deps.logInfo(
`[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${completion.mediaId}, changed=${completion.changed ? 'yes' : 'no'}, title=${completion.mediaTitle})`,
);
}

View File

@@ -83,16 +83,16 @@ test('auto sync imports merged dictionary and persists MRU state', async () => {
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
activeMediaIds: string[];
mergedRevision: string | null;
mergedDictionaryTitle: string | null;
};
assert.deepEqual(state.activeMediaIds, [130298]);
assert.deepEqual(state.activeMediaIds, ['130298 - The Eminence in Shadow']);
assert.equal(state.mergedRevision, 'rev-1');
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
assert.deepEqual(logs, [
'[dictionary:auto-sync] syncing current anime snapshot',
'[dictionary:auto-sync] active AniList media set: 130298',
'[dictionary:auto-sync] active AniList media set: 130298 - The Eminence in Shadow',
'[dictionary:auto-sync] rebuilding merged dictionary for active anime set',
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip',
'[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary',
@@ -212,9 +212,9 @@ test('auto sync updates MRU order without rebuilding merged dictionary when memb
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
activeMediaIds: string[];
};
assert.deepEqual(state.activeMediaIds, [1, 2]);
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']);
});
test('auto sync evicts least recently used media from merged set', async () => {
@@ -277,9 +277,9 @@ test('auto sync evicts least recently used media from merged set', async () => {
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
activeMediaIds: string[];
};
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
assert.deepEqual(state.activeMediaIds, ['4 - Title 4', '3 - Title 3', '2 - Title 2']);
});
test('auto sync keeps revisited media retained when a new title is added afterward', async () => {
@@ -344,9 +344,9 @@ test('auto sync keeps revisited media retained when a new title is added afterwa
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
activeMediaIds: number[];
activeMediaIds: string[];
};
assert.deepEqual(state.activeMediaIds, [1, 4, 3]);
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']);
});
test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => {
@@ -404,11 +404,11 @@ test('auto sync persists rebuilt MRU state even if Yomitan import fails afterwar
const state = JSON.parse(
fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'),
) as {
activeMediaIds: number[];
activeMediaIds: string[];
mergedRevision: string | null;
mergedDictionaryTitle: string | null;
};
assert.deepEqual(state.activeMediaIds, [1, 2, 3]);
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2', '3']);
assert.equal(state.mergedRevision, 'rev-1-2-3');
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
});

View File

@@ -7,8 +7,13 @@ import type {
MergedCharacterDictionaryBuildResult,
} from '../character-dictionary-runtime';
type AutoSyncMediaEntry = {
mediaId: number;
label: string;
};
type AutoSyncState = {
activeMediaIds: number[];
activeMediaIds: AutoSyncMediaEntry[];
mergedRevision: string | null;
mergedDictionaryTitle: string | null;
};
@@ -64,16 +69,66 @@ function ensureDir(dirPath: string): void {
}
}
function normalizeMediaId(rawMediaId: number): number | null {
const mediaId = Math.max(1, Math.floor(rawMediaId));
return Number.isFinite(mediaId) ? mediaId : null;
}
function parseActiveMediaEntry(rawEntry: unknown): AutoSyncMediaEntry | null {
if (typeof rawEntry === 'number') {
const mediaId = normalizeMediaId(rawEntry);
if (mediaId === null) {
return null;
}
return { mediaId, label: String(mediaId) };
}
if (typeof rawEntry !== 'string') {
return null;
}
const trimmed = rawEntry.trim();
if (!trimmed) {
return null;
}
const [rawId, ...rawTitleParts] = trimmed.split(' - ');
if (!rawId || !/^\d+$/.test(rawId)) {
return null;
}
const mediaId = normalizeMediaId(Number.parseInt(rawId ?? '', 10));
if (mediaId === null || mediaId <= 0) {
return null;
}
const rawLabel = rawTitleParts.length > 0 ? rawTitleParts.join(' - ').trim() : '';
return { mediaId, label: rawLabel ? `${mediaId} - ${rawLabel}` : String(mediaId) };
}
function buildActiveMediaLabel(mediaId: number, mediaTitle: string | null | undefined): string {
const normalizedId = normalizeMediaId(mediaId);
const trimmedTitle = typeof mediaTitle === 'string' ? mediaTitle.trim() : '';
if (normalizedId === null) {
return trimmedTitle;
}
return trimmedTitle.length > 0 ? `${normalizedId} - ${trimmedTitle}` : String(normalizedId);
}
function readAutoSyncState(statePath: string): AutoSyncState {
try {
const raw = fs.readFileSync(statePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<AutoSyncState>;
const activeMediaIds = Array.isArray(parsed.activeMediaIds)
? parsed.activeMediaIds
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
.map((value) => Math.max(1, Math.floor(value)))
.filter((value, index, all) => all.indexOf(value) === index)
: [];
const activeMediaIds: AutoSyncMediaEntry[] = [];
const activeMediaIdSet = new Set<number>();
if (Array.isArray(parsed.activeMediaIds)) {
for (const value of parsed.activeMediaIds) {
const entry = parseActiveMediaEntry(value);
if (entry && !activeMediaIdSet.has(entry.mediaId)) {
activeMediaIdSet.add(entry.mediaId);
activeMediaIds.push(entry);
}
}
}
return {
activeMediaIds,
mergedRevision:
@@ -96,7 +151,12 @@ function readAutoSyncState(statePath: string): AutoSyncState {
function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
ensureDir(path.dirname(statePath));
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
const persistedState = {
activeMediaIds: state.activeMediaIds.map((entry) => entry.label),
mergedRevision: state.mergedRevision,
mergedDictionaryTitle: state.mergedDictionaryTitle,
};
fs.writeFileSync(statePath, JSON.stringify(persistedState, null, 2), 'utf8');
}
function arraysEqual(left: number[], right: number[]): boolean {
@@ -223,15 +283,22 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
});
const state = readAutoSyncState(statePath);
const nextActiveMediaIds = [
snapshot.mediaId,
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
{
mediaId: snapshot.mediaId,
label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle),
},
...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId),
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId);
deps.logInfo?.(
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds
.map((entry) => entry.label)
.join(', ')}`,
);
const retainedOrderChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
const retainedMembershipChanged = !sameMembership(nextActiveMediaIds, state.activeMediaIds);
const stateMediaIds = state.activeMediaIds.map((entry) => entry.mediaId);
const retainedOrderChanged = !arraysEqual(nextActiveMediaIdValues, stateMediaIds);
const retainedMembershipChanged = !sameMembership(nextActiveMediaIdValues, stateMediaIds);
let merged: MergedCharacterDictionaryBuildResult | null = null;
if (
retainedMembershipChanged ||
@@ -244,9 +311,9 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
message: buildBuildingMessage(snapshot.mediaTitle),
});
});
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
}
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
@@ -293,7 +360,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
);
}
if (merged === null) {
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
}
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
const imported = await withOperationTimeout(

View File

@@ -40,3 +40,19 @@ test('current media tokenization gate returns immediately for ready media', asyn
await gate.waitUntilReady('/tmp/video-1.mkv');
});
test('current media tokenization gate stays ready for later media after first warmup', async () => {
const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
gate.markReady('/tmp/video-1.mkv');
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
let resolved = false;
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
resolved = true;
});
await Promise.resolve();
assert.equal(resolved, true);
await waitPromise;
});

View File

@@ -13,6 +13,7 @@ export function createCurrentMediaTokenizationGate(): {
} {
let currentMediaPath: string | null = null;
let readyMediaPath: string | null = null;
let warmupCompleted = false;
let pendingMediaPath: string | null = null;
let pendingPromise: Promise<void> | null = null;
let resolvePending: (() => void) | null = null;
@@ -43,6 +44,11 @@ export function createCurrentMediaTokenizationGate(): {
return;
}
currentMediaPath = normalizedPath;
if (warmupCompleted) {
readyMediaPath = normalizedPath;
resolvePendingWaiter();
return;
}
readyMediaPath = null;
resolvePendingWaiter();
if (normalizedPath) {
@@ -54,6 +60,7 @@ export function createCurrentMediaTokenizationGate(): {
if (!normalizedPath) {
return;
}
warmupCompleted = true;
readyMediaPath = normalizedPath;
if (pendingMediaPath === normalizedPath) {
resolvePendingWaiter();
@@ -61,7 +68,7 @@ export function createCurrentMediaTokenizationGate(): {
},
waitUntilReady: async (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath) ?? currentMediaPath;
if (!normalizedPath || readyMediaPath === normalizedPath) {
if (warmupCompleted || !normalizedPath || readyMediaPath === normalizedPath) {
return;
}
await ensurePendingPromise(normalizedPath);

View File

@@ -14,6 +14,7 @@ function makeConfig() {
retention: {
eventsDays: 14,
telemetryDays: 30,
sessionsDays: 45,
dailyRollupsDays: 180,
monthlyRollupsDays: 730,
vacuumIntervalDays: 7,
@@ -97,6 +98,7 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
retention: {
eventsDays: 14,
telemetryDays: 30,
sessionsDays: 45,
dailyRollupsDays: 180,
monthlyRollupsDays: 730,
vacuumIntervalDays: 7,

View File

@@ -1,6 +1,7 @@
type ImmersionRetentionPolicy = {
eventsDays: number;
telemetryDays: number;
sessionsDays: number;
dailyRollupsDays: number;
monthlyRollupsDays: number;
vacuumIntervalDays: number;
@@ -77,6 +78,7 @@ export function createImmersionTrackerStartupHandler(
retention: {
eventsDays: policy.retention.eventsDays,
telemetryDays: policy.retention.telemetryDays,
sessionsDays: policy.retention.sessionsDays,
dailyRollupsDays: policy.retention.dailyRollupsDays,
monthlyRollupsDays: policy.retention.monthlyRollupsDays,
vacuumIntervalDays: policy.retention.vacuumIntervalDays,

View File

@@ -29,10 +29,13 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
test('mpv connection handler syncs overlay subtitle suppression on connect', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
const deps: Parameters<typeof createHandleMpvConnectionChangeHandler>[0] & {
scheduleCharacterDictionarySync: () => void;
} = {
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
@@ -41,7 +44,8 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
};
const handler = createHandleMpvConnectionChangeHandler(deps);
handler({ connected: true });

View File

@@ -22,7 +22,6 @@ export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -34,7 +33,6 @@ export function createHandleMpvConnectionChangeHandler(deps: {
deps.refreshDiscordPresence();
if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
deps.scheduleCharacterDictionarySync?.();
return;
}
deps.reportJellyfinRemoteStopped();

View File

@@ -103,16 +103,19 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
]);
});
test('media title change handler clears guess state and syncs immersion', () => {
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaTitleChangeHandler({
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
scheduleCharacterDictionarySync: () => void;
} = {
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
};
const handler = createHandleMpvMediaTitleChangeHandler(deps);
handler({ title: 'Episode 1' });
assert.deepEqual(calls, [
@@ -120,7 +123,6 @@ test('media title change handler clears guess state and syncs immersion', () =>
'reset-guess',
'notify:Episode 1',
'sync',
'dict-sync',
'presence',
]);
});

View File

@@ -70,7 +70,6 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void;
syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ title }: { title: string | null }): void => {
@@ -79,9 +78,6 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate(normalizedTitle);
deps.syncImmersionMediaState();
if (normalizedTitle.trim().length > 0) {
deps.scheduleCharacterDictionarySync?.();
}
deps.refreshDiscordPresence();
};
}

View File

@@ -72,7 +72,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -119,7 +118,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({

View File

@@ -13,6 +13,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getWindowTracker: () => tracker,
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
@@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
deps.setTrackerNotReadyWarningShown(true);
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });

View File

@@ -8,6 +8,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getWindowTracker: () => deps.getWindowTracker(),
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),

View File

@@ -138,7 +138,7 @@ test('startup OSD shows dictionary failure after annotation loading completes',
]);
});
test('startup OSD reset requires the next media to wait for tokenization again', () => {
test('startup OSD reset keeps tokenization ready after first warmup', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
@@ -152,8 +152,5 @@ test('startup OSD reset requires the next media to wait for tokenization again',
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
);
assert.deepEqual(osdMessages, []);
sequencer.markTokenizationReady();
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
});

View File

@@ -11,6 +11,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
} {
let tokenizationReady = false;
let tokenizationWarmupCompleted = false;
let annotationLoadingMessage: string | null = null;
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
@@ -39,13 +40,14 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
return {
reset: () => {
tokenizationReady = false;
tokenizationReady = tokenizationWarmupCompleted;
annotationLoadingMessage = null;
pendingDictionaryProgress = null;
pendingDictionaryFailure = null;
dictionaryProgressShown = false;
},
markTokenizationReady: () => {
tokenizationWarmupCompleted = true;
tokenizationReady = true;
if (annotationLoadingMessage !== null) {
deps.showOsd(annotationLoadingMessage);

View File

@@ -1,5 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRunStatsCliCommandHandler } from './stats-cli-command';
function makeHandler(
@@ -114,3 +117,245 @@ test('stats cli command runs vocab cleanup instead of opening dashboard when cle
},
]);
});
test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requested', async () => {
const { handler, calls, responses } = makeHandler({
ensureVocabularyCleanupTokenizerReady: async () => {
calls.push('ensureVocabularyCleanupTokenizerReady');
},
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
}),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupLifetime: true,
},
'initial',
);
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
async function waitForPendingAnimeMetadata(
tracker: import('../../core/services/immersion-tracker-service').ImmersionTrackerService,
): Promise<void> {
const privateApi = tracker as unknown as {
sessionState: { videoId: number } | null;
pendingAnimeMetadataUpdates?: Map<number, Promise<void>>;
};
const videoId = privateApi.sessionState?.videoId;
if (!videoId) return;
await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
}
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
const dbPath = makeDbPath();
let tracker:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
let tracker2:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
let tracker3:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
const { ImmersionTrackerService } = await import('../../core/services/immersion-tracker-service');
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
try {
tracker = new ImmersionTrackerService({ dbPath });
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.recordCardsMined(2);
tracker.recordSubtitleLine('first line', 0, 1);
tracker.destroy();
tracker = null;
tracker2 = new ImmersionTrackerService({ dbPath });
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
await waitForPendingAnimeMetadata(tracker2);
tracker2.recordCardsMined(1);
tracker2.recordSubtitleLine('second line', 0, 1);
tracker2.destroy();
tracker2 = null;
const beforeDb = new Database(dbPath);
const expectedGlobal = beforeDb
.prepare(
`
SELECT total_sessions, total_cards, episodes_started, active_days
FROM imm_lifetime_global
`,
)
.get() as {
total_sessions: number;
total_cards: number;
episodes_started: number;
active_days: number;
} | null;
const expectedAnimeRows = (
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime').get() as {
total: number;
}
).total;
const expectedMediaRows = (
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media').get() as {
total: number;
}
).total;
const expectedAppliedSessions = (
beforeDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions').get() as {
total: number;
}
).total;
beforeDb.exec(`
DELETE FROM imm_lifetime_anime;
DELETE FROM imm_lifetime_media;
DELETE FROM imm_lifetime_applied_sessions;
UPDATE imm_lifetime_global
SET total_sessions = 999,
total_cards = 999,
episodes_started = 999,
active_days = 999
WHERE global_id = 1;
`);
beforeDb.close();
tracker3 = new ImmersionTrackerService({ dbPath });
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
const rebuiltDb = new Database(dbPath);
const rebuiltGlobal = rebuiltDb
.prepare(
`
SELECT total_sessions, total_cards, episodes_started, active_days
FROM imm_lifetime_global
`,
)
.get() as {
total_sessions: number;
total_cards: number;
episodes_started: number;
active_days: number;
} | null;
const rebuiltAnimeRows = (
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime').get() as {
total: number;
}
).total;
const rebuiltMediaRows = (
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media').get() as {
total: number;
}
).total;
const rebuiltAppliedSessions = (
rebuiltDb.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions').get() as {
total: number;
}
).total;
rebuiltDb.close();
assert.ok(rebuiltGlobal);
assert.ok(expectedGlobal);
assert.equal(rebuiltGlobal?.total_sessions, expectedGlobal?.total_sessions);
assert.equal(rebuiltGlobal?.total_cards, expectedGlobal?.total_cards);
assert.equal(rebuiltGlobal?.episodes_started, expectedGlobal?.episodes_started);
assert.equal(rebuiltGlobal?.active_days, expectedGlobal?.active_days);
assert.equal(rebuiltAnimeRows, expectedAnimeRows);
assert.equal(rebuiltMediaRows, expectedMediaRows);
assert.equal(rebuiltAppliedSessions, expectedAppliedSessions);
assert.equal(firstRebuild.appliedSessions, expectedAppliedSessions);
assert.equal(secondRebuild.appliedSessions, firstRebuild.appliedSessions);
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
} finally {
tracker?.destroy();
tracker2?.destroy();
tracker3?.destroy();
cleanupDbPath(dbPath);
}
});
test('stats cli command runs lifetime rebuild when requested', async () => {
const { handler, calls, responses } = makeHandler({
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
}),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupLifetime: true,
},
'initial',
);
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
]);
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true },
},
]);
});
test('stats cli command rejects cleanup calls without exactly one cleanup mode', async () => {
const { handler, calls, responses } = makeHandler({
getImmersionTracker: () => ({
cleanupVocabularyStats: async () => ({ scanned: 1, kept: 1, deleted: 0, repaired: 0 }),
rebuildLifetimeSummaries: async () => ({ appliedSessions: 0, rebuiltAtMs: 0 }),
}),
});
await handler(
{
statsResponsePath: '/tmp/subminer-stats-response.json',
statsCleanup: true,
statsCleanupVocab: true,
statsCleanupLifetime: true,
},
'initial',
);
assert.ok(calls.includes('error:Stats command failed:Choose exactly one stats cleanup mode.'));
assert.deepEqual(responses, [
{
responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: false, error: 'Choose exactly one stats cleanup mode.' },
},
]);
});

View File

@@ -1,7 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CliArgs, CliCommandSource } from '../../cli/args';
import type { VocabularyCleanupSummary } from '../../core/services/immersion-tracker/types';
import type {
LifetimeRebuildSummary,
VocabularyCleanupSummary,
} from '../../core/services/immersion-tracker/types';
type StatsCliConfig = {
immersionTracking?: {
@@ -33,6 +36,7 @@ export function createRunStatsCliCommandHandler(deps: {
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
getImmersionTracker: () => {
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
} | null;
ensureStatsServerStarted: () => string;
openExternal: (url: string) => Promise<unknown>;
@@ -55,7 +59,10 @@ export function createRunStatsCliCommandHandler(deps: {
};
return async (
args: Pick<CliArgs, 'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab'>,
args: Pick<
CliArgs,
'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab' | 'statsCleanupLifetime'
>,
source: CliCommandSource,
): Promise<void> => {
try {
@@ -71,13 +78,31 @@ export function createRunStatsCliCommandHandler(deps: {
}
if (args.statsCleanup) {
await deps.ensureVocabularyCleanupTokenizerReady?.();
if (!args.statsCleanupVocab || !tracker.cleanupVocabularyStats) {
const cleanupModes = [
args.statsCleanupVocab ? 'vocab' : null,
args.statsCleanupLifetime ? 'lifetime' : null,
].filter(Boolean);
if (cleanupModes.length !== 1) {
throw new Error('Choose exactly one stats cleanup mode.');
}
if (args.statsCleanupVocab) {
await deps.ensureVocabularyCleanupTokenizerReady?.();
}
if (args.statsCleanupVocab && tracker.cleanupVocabularyStats) {
const result = await tracker.cleanupVocabularyStats();
deps.logInfo(
`Stats vocabulary cleanup complete: scanned=${result.scanned} kept=${result.kept} deleted=${result.deleted} repaired=${result.repaired}`,
);
writeResponseSafe(args.statsResponsePath, { ok: true });
return;
}
if (!args.statsCleanupLifetime || !tracker.rebuildLifetimeSummaries) {
throw new Error('Stats cleanup mode is not available.');
}
const result = await tracker.cleanupVocabularyStats();
const result = await tracker.rebuildLifetimeSummaries();
deps.logInfo(
`Stats vocabulary cleanup complete: scanned=${result.scanned} kept=${result.kept} deleted=${result.deleted} repaired=${result.repaired}`,
`Stats lifetime rebuild complete: appliedSessions=${result.appliedSessions} rebuiltAtMs=${result.rebuiltAtMs}`,
);
writeResponseSafe(args.statsResponsePath, { ok: true });
return;

View File

@@ -183,6 +183,7 @@ export interface AppState {
runtimeOptionsManager: RuntimeOptionsManager | null;
trackerNotReadyWarningShown: boolean;
overlayDebugVisualizationEnabled: boolean;
statsOverlayVisible: boolean;
subsyncInProgress: boolean;
initialArgs: CliArgs | null;
mpvSocketPath: string;
@@ -260,6 +261,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
runtimeOptionsManager: null,
trackerNotReadyWarningShown: false,
overlayDebugVisualizationEnabled: false,
statsOverlayVisible: false,
shortcutsRegistered: false,
overlayRuntimeInitialized: false,
fieldGroupingResolver: null,