mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
fix(stats): harden duplicate-line cleanup and tracking
- Reject malformed requests and sub-day cleanup windows - Reset deduplication across media and subtitle-track changes - Handle karaoke bursts ending with a long hold frame
This commit is contained in:
@@ -399,6 +399,28 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
||||
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
parseArgs([
|
||||
'--stats',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-duplicate-lines',
|
||||
'--stats-cleanup-lookback-days',
|
||||
'0.5',
|
||||
]),
|
||||
/at least one day/,
|
||||
);
|
||||
assert.equal(
|
||||
parseArgs([
|
||||
'--stats',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-duplicate-lines',
|
||||
'--stats-cleanup-lookback-days',
|
||||
'1.5',
|
||||
]).statsCleanupLookbackDays,
|
||||
1,
|
||||
);
|
||||
|
||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||
|
||||
+10
-4
@@ -112,6 +112,14 @@ export interface CliArgs {
|
||||
|
||||
export type CliCommandSource = 'initial' | 'second-instance';
|
||||
|
||||
function parseStatsCleanupLookbackDays(value: string | undefined): number {
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days < 1) {
|
||||
throw new Error('Stats --lookback-days must be at least one day.');
|
||||
}
|
||||
return Math.floor(days);
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
background: false,
|
||||
@@ -376,11 +384,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--stats-cleanup-duplicate-lines') args.statsCleanupDuplicateLines = true;
|
||||
else if (arg === '--stats-cleanup-dry-run') args.statsCleanupDryRun = true;
|
||||
else if (arg.startsWith('--stats-cleanup-lookback-days=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isFinite(value) && value > 0) args.statsCleanupLookbackDays = Math.floor(value);
|
||||
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(arg.split('=', 2)[1]);
|
||||
} else if (arg === '--stats-cleanup-lookback-days') {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (Number.isFinite(value) && value > 0) args.statsCleanupLookbackDays = Math.floor(value);
|
||||
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(readValue(argv[i + 1]));
|
||||
} else if (arg.startsWith('--stats-response-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.statsResponsePath = value;
|
||||
|
||||
@@ -1064,12 +1064,12 @@ describe('stats server API routes', () => {
|
||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines ignores a window shorter than a day', async () => {
|
||||
let seenOptions: unknown = null;
|
||||
it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
|
||||
let cleanupCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||
seenOptions = options;
|
||||
cleanupDuplicateSubtitleLines: async () => {
|
||||
cleanupCalls += 1;
|
||||
return {
|
||||
dryRun: true,
|
||||
lookbackDays: null,
|
||||
@@ -1090,12 +1090,41 @@ describe('stats server API routes', () => {
|
||||
body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
// Half a day must not floor to a zero-day window; it means no limit.
|
||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: null });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(cleanupCalls, 0);
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines treats a missing body as an apply over all history', async () => {
|
||||
it('POST /api/stats/maintenance/duplicate-lines floors a fractional multi-day window', async () => {
|
||||
let seenOptions: unknown = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||
seenOptions = options;
|
||||
return {
|
||||
dryRun: true,
|
||||
lookbackDays: 1,
|
||||
scannedLines: 0,
|
||||
burstGroups: 0,
|
||||
removedLines: 0,
|
||||
removedWordOccurrences: 0,
|
||||
removedKanjiOccurrences: 0,
|
||||
samples: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dryRun: true, lookbackDays: 1.5 }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 1 });
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines accepts an explicit empty object for all history', async () => {
|
||||
let seenOptions: unknown = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
@@ -1115,12 +1144,44 @@ describe('stats server API routes', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', { method: 'POST' });
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null });
|
||||
});
|
||||
|
||||
for (const malformed of [
|
||||
{ name: 'a missing body', body: undefined },
|
||||
{ name: 'malformed JSON', body: '{' },
|
||||
{ name: 'JSON null', body: 'null' },
|
||||
{ name: 'a JSON array', body: '[]' },
|
||||
]) {
|
||||
it(`POST /api/stats/maintenance/duplicate-lines rejects ${malformed.name}`, async () => {
|
||||
let cleanupCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async () => {
|
||||
cleanupCalls += 1;
|
||||
throw new Error('cleanup must not run');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: malformed.body,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(cleanupCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
|
||||
|
||||
@@ -193,6 +193,43 @@ test('a long run of quarter-second frames is still a burst', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('a qualifying short-frame burst may end with one long hold frame', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 8, 40),
|
||||
{ session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 },
|
||||
]);
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
|
||||
assert.equal(summary.burstGroups, 1);
|
||||
assert.equal(summary.removedLines, 8);
|
||||
assert.equal(countLines(db), 1);
|
||||
assert.equal(wordFrequency(db), 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a long event before the final frame prevents burst cleanup', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 5, 40),
|
||||
{ session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 },
|
||||
{ session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 },
|
||||
]);
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
|
||||
assert.equal(summary.burstGroups, 0);
|
||||
assert.equal(countLines(db), 7);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a run of frames longer than the animation bound survives', () => {
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* contiguous, short-lived lines inside a single session.
|
||||
*
|
||||
* The run has to be as long as the timing-only rule in `subtitle-cue-dedup` demands, but
|
||||
* each line may be as long as the animation-frame bound rather than the much tighter
|
||||
* timing-only one. Five or more repeats of the same text, each ending where the next
|
||||
* its short frames may be as long as the animation-frame bound rather than the much
|
||||
* tighter timing-only one. A qualifying run may end with one longer hold, which is a
|
||||
* common karaoke shape. Five or more repeats of the same text, each ending where the next
|
||||
* begins, is already conclusive on its own -- no dialogue does that -- and the tighter
|
||||
* bound would walk straight past the heavier typesetting that motivated this, where
|
||||
* frames sit nearer a quarter of a second. Both bounds are options, so a cautious run can
|
||||
@@ -170,7 +171,19 @@ function isBurst(run: StoredSubtitleLineRow[], bounds: ResolvedBounds): boolean
|
||||
if (run.length < bounds.minRunLength) {
|
||||
return false;
|
||||
}
|
||||
return run.every((row) => row.endMs - row.startMs <= bounds.maxFrameMs);
|
||||
const isShortFrame = (row: StoredSubtitleLineRow): boolean =>
|
||||
row.endMs - row.startMs <= bounds.maxFrameMs;
|
||||
if (run.every(isShortFrame)) {
|
||||
return true;
|
||||
}
|
||||
// Karaoke commonly finishes its short animation frames with one long hold. Only the
|
||||
// final event may exceed the frame bound, and the strict short-frame threshold must
|
||||
// already have been met before it.
|
||||
return (
|
||||
run.length - 1 >= bounds.minRunLength &&
|
||||
run.slice(0, -1).every(isShortFrame) &&
|
||||
!isShortFrame(run[run.length - 1]!)
|
||||
);
|
||||
}
|
||||
|
||||
function toBurst(run: StoredSubtitleLineRow[]): DuplicateSubtitleLineBurst {
|
||||
|
||||
@@ -45,7 +45,9 @@ export function registerStatsLibraryRoutes(
|
||||
// the same scan without writing, so the confirmation the user sees is the real cost.
|
||||
app.post('/api/stats/maintenance/duplicate-lines', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const { dryRun, lookbackDays } = parseDuplicateLineCleanupBody(body);
|
||||
const options = parseDuplicateLineCleanupBody(body);
|
||||
if (!options) return c.body(null, 400);
|
||||
const { dryRun, lookbackDays } = options;
|
||||
const result = await tracker.cleanupDuplicateSubtitleLines({ dryRun, lookbackDays });
|
||||
return c.json(statsJson('duplicateLineCleanup', result));
|
||||
});
|
||||
|
||||
@@ -89,22 +89,32 @@ export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | nul
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a duplicate-line cleanup request. An absent or unusable `lookbackDays` scans all
|
||||
* history, which is what the CLI does; only a positive number narrows the window.
|
||||
* Read a duplicate-line cleanup request. An explicit object with no lookback scans all
|
||||
* history. Invalid bodies and invalid windows are rejected instead of broadening scope.
|
||||
*/
|
||||
export function parseDuplicateLineCleanupBody(body: unknown): {
|
||||
dryRun: boolean;
|
||||
lookbackDays: number | null;
|
||||
} {
|
||||
const source = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
} | null {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return null;
|
||||
}
|
||||
const source = body as Record<string, unknown>;
|
||||
if (source.dryRun !== undefined && typeof source.dryRun !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
const rawLookback = source.lookbackDays;
|
||||
// Floor before the bounds check, or a fraction of a day arrives as a zero-day window.
|
||||
const wholeDays =
|
||||
typeof rawLookback === 'number' && Number.isFinite(rawLookback)
|
||||
? Math.floor(rawLookback)
|
||||
: null;
|
||||
const lookbackDays = wholeDays !== null && wholeDays >= 1 ? wholeDays : null;
|
||||
return { dryRun: source.dryRun === true, lookbackDays };
|
||||
if (
|
||||
rawLookback !== undefined &&
|
||||
rawLookback !== null &&
|
||||
(typeof rawLookback !== 'number' || !Number.isFinite(rawLookback) || rawLookback < 1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
dryRun: source.dryRun === true,
|
||||
lookbackDays: typeof rawLookback === 'number' ? Math.floor(rawLookback) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||
|
||||
@@ -19,7 +19,7 @@ test('parsed cues drop the frames the sidebar already collapsed', () => {
|
||||
];
|
||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => cues });
|
||||
|
||||
const recorded = karaokeFrames('飛び上がる', 10, 40, 0.1).filter((sample) =>
|
||||
const recorded = karaokeFrames('飛び上がる', 10, 40, 0.04).filter((sample) =>
|
||||
gate.shouldRecord(sample),
|
||||
);
|
||||
|
||||
@@ -60,6 +60,21 @@ test('parsed cues outrank the streaming heuristic for short repeated cues', () =
|
||||
assert.equal(recorded.length, 8);
|
||||
});
|
||||
|
||||
test('parsed cues preserve legitimately separate cues only 40ms apart', () => {
|
||||
const cues: SubtitleCue[] = Array.from({ length: 8 }, (_, index) => ({
|
||||
startTime: 3 + index * 0.04,
|
||||
endTime: 3 + (index + 1) * 0.04,
|
||||
text: 'えっ',
|
||||
}));
|
||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => cues });
|
||||
|
||||
const recorded = cues.filter((cue) =>
|
||||
gate.shouldRecord({ text: cue.text, startSec: cue.startTime, endSec: cue.endTime }),
|
||||
);
|
||||
|
||||
assert.equal(recorded.length, 8);
|
||||
});
|
||||
|
||||
test('a line whose timing does not match any cue still records', () => {
|
||||
// A shifted track, an embedded sub nobody parsed: no match, no drop.
|
||||
const cues: SubtitleCue[] = [{ startTime: 10, endTime: 14, text: '飛び上がる' }];
|
||||
@@ -68,6 +83,27 @@ test('a line whose timing does not match any cue still records', () => {
|
||||
assert.equal(gate.shouldRecord({ text: '飛び上がる', startSec: 42, endSec: 44 }), true);
|
||||
});
|
||||
|
||||
test('shifted parsed text falls back to streaming burst detection', () => {
|
||||
const cues: SubtitleCue[] = [{ startTime: 10, endTime: 14, text: '飛び上がる' }];
|
||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => cues });
|
||||
|
||||
const recorded = karaokeFrames('飛び上がる', 42, 40, 0.04).filter((sample) =>
|
||||
gate.shouldRecord(sample),
|
||||
);
|
||||
|
||||
assert.equal(recorded.length, 4);
|
||||
});
|
||||
|
||||
test('replacing the parsed cue source forgets a streaming run', () => {
|
||||
let cues: SubtitleCue[] = [];
|
||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => cues });
|
||||
|
||||
karaokeFrames('飛び上がる', 42, 20, 0.04).forEach((sample) => gate.shouldRecord(sample));
|
||||
cues = [];
|
||||
|
||||
assert.equal(gate.shouldRecord({ text: '飛び上がる', startSec: 42.8, endSec: 42.84 }), true);
|
||||
});
|
||||
|
||||
test('without parsed cues a long run of identical short frames stops recording', () => {
|
||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => null });
|
||||
|
||||
@@ -114,3 +150,20 @@ test('reset forgets the streaming run', () => {
|
||||
|
||||
assert.equal(gate.shouldRecord({ text: 'もし', startSec: 0.8, endSec: 0.84 }), true);
|
||||
});
|
||||
|
||||
test('reset ignores stale parsed cues until the source publishes a new cue list', () => {
|
||||
let cues: SubtitleCue[] = [{ startTime: 10, endTime: 14, text: '飛び上がる' }];
|
||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => cues });
|
||||
|
||||
assert.equal(gate.shouldRecord({ text: '飛び上がる', startSec: 10, endSec: 10.04 }), true);
|
||||
gate.reset();
|
||||
|
||||
const recordedWithStaleCues = karaokeFrames('飛び上がる', 10.04, 8, 0.04).filter((sample) =>
|
||||
gate.shouldRecord(sample),
|
||||
);
|
||||
assert.equal(recordedWithStaleCues.length, 4);
|
||||
|
||||
cues = [{ startTime: 20, endTime: 24, text: '飛び上がる' }];
|
||||
assert.equal(gate.shouldRecord({ text: '飛び上がる', startSec: 20, endSec: 20.04 }), true);
|
||||
assert.equal(gate.shouldRecord({ text: '飛び上がる', startSec: 20.04, endSec: 20.08 }), false);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface SubtitleLineDedupGateDeps {
|
||||
export interface SubtitleLineDedupGate {
|
||||
/** False when this line is an animation frame of a line already recorded. */
|
||||
shouldRecord: (sample: SubtitleLineSample) => boolean;
|
||||
/** Forget the streaming run state, e.g. when playback moves to another file. */
|
||||
/** Forget run state and ignore the current cue list until its source is replaced. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ interface StreamingRunState {
|
||||
frames: number;
|
||||
}
|
||||
|
||||
/** Exact cue identity, separate from the looser tolerance used to chain adjacent frames. */
|
||||
const CUE_START_IDENTITY_TOLERANCE_SECONDS = 0.005;
|
||||
|
||||
function normalizeLineText(text: string): string {
|
||||
return normalizePlainSubtitleText(text, { collapseLineBreaks: true });
|
||||
}
|
||||
@@ -87,37 +90,48 @@ function buildSpansByText(cues: readonly SubtitleCue[]): Map<string, CueSpan[]>
|
||||
* starts *at* the merged cue, and a line the parser deliberately kept separate -- three
|
||||
* characters trading `えっ` back to back -- begins exactly where the one before it ends.
|
||||
*/
|
||||
function isMergedAwayFrame(spans: readonly CueSpan[], startSec: number): boolean {
|
||||
function isMergedAwayFrame(spans: readonly CueSpan[], startSec: number): boolean | null {
|
||||
const coveringSpans = spans.filter(
|
||||
(span) =>
|
||||
startSec >= span.startTime - CUE_START_IDENTITY_TOLERANCE_SECONDS &&
|
||||
startSec <= span.endTime + CUE_START_IDENTITY_TOLERANCE_SECONDS,
|
||||
);
|
||||
if (coveringSpans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const startsOwnCue = spans.some(
|
||||
(span) => Math.abs(startSec - span.startTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS,
|
||||
(span) => Math.abs(startSec - span.startTime) <= CUE_START_IDENTITY_TOLERANCE_SECONDS,
|
||||
);
|
||||
if (startsOwnCue) {
|
||||
return false;
|
||||
}
|
||||
return spans.some(
|
||||
return coveringSpans.some(
|
||||
(span) =>
|
||||
startSec > span.startTime + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS &&
|
||||
startSec <= span.endTime + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS,
|
||||
startSec > span.startTime + CUE_START_IDENTITY_TOLERANCE_SECONDS &&
|
||||
startSec <= span.endTime + CUE_START_IDENTITY_TOLERANCE_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
export function createSubtitleLineDedupGate(
|
||||
deps: SubtitleLineDedupGateDeps,
|
||||
): SubtitleLineDedupGate {
|
||||
let indexedCues: readonly SubtitleCue[] | null = null;
|
||||
let indexedCues: readonly SubtitleCue[] | null | undefined;
|
||||
let ignoredCuesAfterReset: readonly SubtitleCue[] | null | undefined;
|
||||
let spansByText: Map<string, CueSpan[]> = new Map();
|
||||
let run: StreamingRunState | null = null;
|
||||
|
||||
const lookupSpans = (text: string): CueSpan[] | null => {
|
||||
const cues = deps.getParsedCues();
|
||||
if (!cues?.length) {
|
||||
indexedCues = null;
|
||||
spansByText = new Map();
|
||||
return null;
|
||||
const cues = deps.getParsedCues() ?? null;
|
||||
if (ignoredCuesAfterReset !== undefined) {
|
||||
if (cues === ignoredCuesAfterReset) {
|
||||
return null;
|
||||
}
|
||||
ignoredCuesAfterReset = undefined;
|
||||
}
|
||||
if (cues !== indexedCues) {
|
||||
indexedCues = cues;
|
||||
spansByText = buildSpansByText(cues);
|
||||
spansByText = cues?.length ? buildSpansByText(cues) : new Map();
|
||||
run = null;
|
||||
}
|
||||
return spansByText.get(text) ?? null;
|
||||
};
|
||||
@@ -175,14 +189,20 @@ export function createSubtitleLineDedupGate(
|
||||
// between sidebar and stats this gate exists to prevent.
|
||||
const spans = lookupSpans(text);
|
||||
if (spans) {
|
||||
run = null;
|
||||
return !isMergedAwayFrame(spans, sample.startSec);
|
||||
const mergedAway = isMergedAwayFrame(spans, sample.startSec);
|
||||
if (mergedAway !== null) {
|
||||
run = null;
|
||||
return !mergedAway;
|
||||
}
|
||||
}
|
||||
|
||||
return advanceStreamingRun(text, sample);
|
||||
},
|
||||
reset: () => {
|
||||
run = null;
|
||||
ignoredCuesAfterReset = deps.getParsedCues() ?? null;
|
||||
indexedCues = undefined;
|
||||
spansByText = new Map();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -169,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,
|
||||
@@ -183,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(),
|
||||
|
||||
Reference in New Issue
Block a user