mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
feat: stabilize startup sync and overlay/runtime paths
This commit is contained in:
@@ -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)',
|
||||
]);
|
||||
});
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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...']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user