mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(stats): stop counting duplicate typeset subtitle lines (#191)
This commit is contained in:
@@ -267,3 +267,123 @@ test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos re
|
||||
|
||||
assert.deepEqual(recorded, [42]);
|
||||
});
|
||||
|
||||
test('media and subtitle-track transitions reset live subtitle-line deduplication', () => {
|
||||
const recordedStarts: number[] = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: null,
|
||||
immersionTracker: {
|
||||
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
|
||||
},
|
||||
subtitleTimingTracker: null,
|
||||
activeParsedSubtitleCues: null,
|
||||
currentMediaPath: '/video-a.mkv',
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
handlers.recordImmersionSubtitleLine('待って', index * 0.04, (index + 1) * 0.04);
|
||||
}
|
||||
assert.equal(recordedStarts.length, 4);
|
||||
|
||||
handlers.updateCurrentMediaPath('/video-b.mkv');
|
||||
handlers.recordImmersionSubtitleLine('待って', 0.32, 0.36);
|
||||
assert.equal(recordedStarts.length, 5);
|
||||
|
||||
for (let index = 9; index < 16; index += 1) {
|
||||
handlers.recordImmersionSubtitleLine('待って', index * 0.04, (index + 1) * 0.04);
|
||||
}
|
||||
assert.equal(recordedStarts.length, 8);
|
||||
|
||||
assert.equal(typeof handlers.onSubtitleTrackChange, 'function');
|
||||
handlers.onSubtitleTrackChange?.(2);
|
||||
handlers.recordImmersionSubtitleLine('待って', 0.64, 0.68);
|
||||
assert.equal(recordedStarts.length, 9);
|
||||
});
|
||||
|
||||
test('subtitle-track transitions ignore stale parsed cues until replacement cues arrive', () => {
|
||||
const recordedStarts: number[] = [];
|
||||
const appState = {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: null,
|
||||
immersionTracker: {
|
||||
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
|
||||
},
|
||||
subtitleTimingTracker: null,
|
||||
activeParsedSubtitleCues: [{ startTime: 10, endTime: 14, text: '飛び上がる' }],
|
||||
currentMediaPath: '/video-a.mkv',
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
};
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState,
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
handlers.recordImmersionSubtitleLine('飛び上がる', 10, 10.04);
|
||||
handlers.onSubtitleTrackChange?.(2);
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
handlers.recordImmersionSubtitleLine('飛び上がる', 10 + index * 0.04, 10 + (index + 1) * 0.04);
|
||||
}
|
||||
assert.equal(recordedStarts.length, 5);
|
||||
|
||||
appState.activeParsedSubtitleCues = [{ startTime: 20, endTime: 24, text: '飛び上がる' }];
|
||||
handlers.recordImmersionSubtitleLine('飛び上がる', 20, 20.04);
|
||||
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
|
||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MergedToken, SubtitleData } from '../../types';
|
||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
@@ -34,6 +35,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||
} | null;
|
||||
activeParsedSubtitleCues?: SubtitleCue[] | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
@@ -86,6 +88,11 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
|
||||
};
|
||||
// mpv reports every animation frame of a typeset line as its own subtitle event, so
|
||||
// stats have to collapse bursts the same way the parsed cue list already does.
|
||||
const immersionLineDedupGate = createSubtitleLineDedupGate({
|
||||
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
|
||||
Boolean(
|
||||
deps.appState.initialArgs?.managedPlayback ||
|
||||
@@ -110,6 +117,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
if (!tracker?.recordSubtitleLine) {
|
||||
return;
|
||||
}
|
||||
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
||||
return;
|
||||
}
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
||||
const cachedTokens =
|
||||
deps.appState.currentSubtitleData?.text === text
|
||||
@@ -159,9 +169,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
|
||||
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
||||
: undefined,
|
||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
onSubtitleTrackChange: (sid: number | null) => {
|
||||
immersionLineDedupGate.reset();
|
||||
deps.onSubtitleTrackChange?.(sid);
|
||||
},
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||
: undefined,
|
||||
@@ -173,7 +184,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
||||
updateCurrentMediaPath: (path: string) => {
|
||||
immersionLineDedupGate.reset();
|
||||
deps.updateCurrentMediaPath(path);
|
||||
},
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||
|
||||
@@ -200,6 +200,59 @@ test('stats cli command fails when immersion tracking is disabled', async () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command runs a duplicate-line cleanup preview without touching the dashboard', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getImmersionTracker: () => ({
|
||||
cleanupDuplicateSubtitleLines: async (options: {
|
||||
dryRun?: boolean;
|
||||
lookbackDays?: number | null;
|
||||
}) => ({
|
||||
dryRun: options.dryRun === true,
|
||||
lookbackDays: options.lookbackDays ?? null,
|
||||
scannedLines: 900,
|
||||
burstGroups: 2,
|
||||
removedLines: 180,
|
||||
removedWordOccurrences: 540,
|
||||
removedKanjiOccurrences: 120,
|
||||
samples: [
|
||||
{
|
||||
videoId: 7,
|
||||
videoTitle: 'Ep 1',
|
||||
text: '飛び上がる',
|
||||
frames: 90,
|
||||
removedLines: 89,
|
||||
startMs: 1000,
|
||||
endMs: 5000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsCleanup: true,
|
||||
statsCleanupDuplicateLines: true,
|
||||
statsCleanupDryRun: true,
|
||||
statsCleanupLookbackDays: 30,
|
||||
},
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'info:Stats duplicate-line cleanup preview (last 30d): scanned=900 bursts=2 removedLines=180 removedWordCounts=540 removedKanjiCounts=120',
|
||||
'info: Ep 1: "飛び上がる" x90',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command runs vocab cleanup instead of opening dashboard when cleanup mode is requested', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getImmersionTracker: () => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||
import type { DuplicateSubtitleLineCleanupSummary } from '../../core/services/immersion-tracker/duplicate-line-cleanup';
|
||||
import type {
|
||||
LifetimeRebuildSummary,
|
||||
VocabularyCleanupSummary,
|
||||
@@ -50,6 +51,10 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
||||
getImmersionTracker: () => {
|
||||
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
|
||||
cleanupDuplicateSubtitleLines?: (options: {
|
||||
dryRun?: boolean;
|
||||
lookbackDays?: number | null;
|
||||
}) => Promise<DuplicateSubtitleLineCleanupSummary>;
|
||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
||||
} | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
@@ -83,6 +88,9 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
| 'statsCleanup'
|
||||
| 'statsCleanupVocab'
|
||||
| 'statsCleanupLifetime'
|
||||
| 'statsCleanupDuplicateLines'
|
||||
| 'statsCleanupDryRun'
|
||||
| 'statsCleanupLookbackDays'
|
||||
>,
|
||||
source: CliCommandSource,
|
||||
): Promise<void> => {
|
||||
@@ -126,6 +134,7 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
const cleanupModes = [
|
||||
args.statsCleanupVocab ? 'vocab' : null,
|
||||
args.statsCleanupLifetime ? 'lifetime' : null,
|
||||
args.statsCleanupDuplicateLines ? 'duplicate-lines' : null,
|
||||
].filter(Boolean);
|
||||
if (cleanupModes.length !== 1) {
|
||||
throw new Error('Choose exactly one stats cleanup mode.');
|
||||
@@ -142,6 +151,27 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (args.statsCleanupDuplicateLines && tracker.cleanupDuplicateSubtitleLines) {
|
||||
const result = await tracker.cleanupDuplicateSubtitleLines({
|
||||
dryRun: args.statsCleanupDryRun === true,
|
||||
lookbackDays: args.statsCleanupLookbackDays ?? null,
|
||||
});
|
||||
const window =
|
||||
result.lookbackDays === null ? 'all history' : `last ${result.lookbackDays}d`;
|
||||
deps.logInfo(
|
||||
`Stats duplicate-line cleanup ${result.dryRun ? 'preview' : 'complete'} (${window}): ` +
|
||||
`scanned=${result.scannedLines} bursts=${result.burstGroups} ` +
|
||||
`removedLines=${result.removedLines} removedWordCounts=${result.removedWordOccurrences} ` +
|
||||
`removedKanjiCounts=${result.removedKanjiOccurrences}`,
|
||||
);
|
||||
for (const sample of result.samples.slice(0, 5)) {
|
||||
deps.logInfo(
|
||||
` ${sample.videoTitle ?? `video ${sample.videoId}`}: "${sample.text}" x${sample.frames}`,
|
||||
);
|
||||
}
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (!args.statsCleanupLifetime || !tracker.rebuildLifetimeSummaries) {
|
||||
throw new Error('Stats cleanup mode is not available.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user