mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-12 13:55:51 -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:
@@ -1,5 +1,5 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: stats
|
area: stats
|
||||||
|
|
||||||
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: when the active subtitle file has been parsed, stats record exactly the cues the sidebar shows, and for sources with no parsed cue list a run of identical, contiguous, sub-0.1s lines stops counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
|
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
|
||||||
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
|
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ Karaoke openings and animated signs are authored as one subtitle event per anima
|
|||||||
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
|
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
|
||||||
|
|
||||||
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
|
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
|
||||||
- Otherwise only timing is available, so the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Ordinary repeated dialogue, and lines held for a normal beat, always record.
|
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Ordinary repeated dialogue, and lines held for a normal beat, always record.
|
||||||
|
|
||||||
For stats recorded before this, the Vocabulary tab toolbar has a **Duplicates** button:
|
For stats recorded before this, the Vocabulary tab toolbar has a **Duplicates** button:
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ export interface CliInvocations {
|
|||||||
texthookerOpenBrowser: boolean;
|
texthookerOpenBrowser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `--lookback-days` narrows the duplicate-line cleanup; anything unusable means no limit. */
|
/** `--lookback-days` narrows the duplicate-line cleanup; fractions are floored. */
|
||||||
function parseStatsLookbackDays(value: unknown): number | null {
|
function parseStatsLookbackDays(value: unknown): number | null {
|
||||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||||
const days = Number(value);
|
const days = Number(value);
|
||||||
if (!Number.isFinite(days) || days <= 0) {
|
if (!Number.isFinite(days) || days < 1) {
|
||||||
throw new Error('Stats --lookback-days must be a positive number of days.');
|
throw new Error('Stats --lookback-days must be at least one day.');
|
||||||
}
|
}
|
||||||
return Math.floor(days);
|
return Math.floor(days);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,13 @@ test('parseArgs maps duplicate-line stats cleanup flags', () => {
|
|||||||
assert.equal(parsed.statsCleanupDuplicateLines, true);
|
assert.equal(parsed.statsCleanupDuplicateLines, true);
|
||||||
assert.equal(parsed.statsCleanupDryRun, true);
|
assert.equal(parsed.statsCleanupDryRun, true);
|
||||||
assert.equal(parsed.statsCleanupLookbackDays, 30);
|
assert.equal(parsed.statsCleanupLookbackDays, 30);
|
||||||
|
|
||||||
|
const fractional = parseArgs(
|
||||||
|
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', '1.5'],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(fractional.statsCleanupLookbackDays, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs rejects duplicate-line flags without the duplicate-lines mode', () => {
|
test('parseArgs rejects duplicate-line flags without the duplicate-lines mode', () => {
|
||||||
@@ -265,7 +272,7 @@ test('parseArgs rejects combining lifetime and duplicate-line cleanup modes', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs rejects unusable lookback windows', () => {
|
test('parseArgs rejects unusable lookback windows', () => {
|
||||||
for (const value of ['0', '-5', 'soon']) {
|
for (const value of ['0', '0.5', '-5', 'soon']) {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
parseArgs(
|
parseArgs(
|
||||||
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', value],
|
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', value],
|
||||||
@@ -275,7 +282,7 @@ test('parseArgs rejects unusable lookback windows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(error.code, 1);
|
assert.equal(error.code, 1);
|
||||||
assert.match(error.stderr, /--lookback-days must be a positive number of days/);
|
assert.match(error.stderr, /--lookback-days must be at least one day/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,28 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
||||||
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
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']);
|
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||||
|
|||||||
+10
-4
@@ -112,6 +112,14 @@ export interface CliArgs {
|
|||||||
|
|
||||||
export type CliCommandSource = 'initial' | 'second-instance';
|
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 {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const args: CliArgs = {
|
const args: CliArgs = {
|
||||||
background: false,
|
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-duplicate-lines') args.statsCleanupDuplicateLines = true;
|
||||||
else if (arg === '--stats-cleanup-dry-run') args.statsCleanupDryRun = true;
|
else if (arg === '--stats-cleanup-dry-run') args.statsCleanupDryRun = true;
|
||||||
else if (arg.startsWith('--stats-cleanup-lookback-days=')) {
|
else if (arg.startsWith('--stats-cleanup-lookback-days=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(arg.split('=', 2)[1]);
|
||||||
if (Number.isFinite(value) && value > 0) args.statsCleanupLookbackDays = Math.floor(value);
|
|
||||||
} else if (arg === '--stats-cleanup-lookback-days') {
|
} else if (arg === '--stats-cleanup-lookback-days') {
|
||||||
const value = Number(readValue(argv[i + 1]));
|
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(readValue(argv[i + 1]));
|
||||||
if (Number.isFinite(value) && value > 0) args.statsCleanupLookbackDays = Math.floor(value);
|
|
||||||
} else if (arg.startsWith('--stats-response-path=')) {
|
} else if (arg.startsWith('--stats-response-path=')) {
|
||||||
const value = arg.split('=', 2)[1];
|
const value = arg.split('=', 2)[1];
|
||||||
if (value) args.statsResponsePath = value;
|
if (value) args.statsResponsePath = value;
|
||||||
|
|||||||
@@ -1064,12 +1064,12 @@ describe('stats server API routes', () => {
|
|||||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
|
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/stats/maintenance/duplicate-lines ignores a window shorter than a day', async () => {
|
it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
|
||||||
let seenOptions: unknown = null;
|
let cleanupCalls = 0;
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
createMockTracker({
|
createMockTracker({
|
||||||
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
cleanupDuplicateSubtitleLines: async () => {
|
||||||
seenOptions = options;
|
cleanupCalls += 1;
|
||||||
return {
|
return {
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
lookbackDays: null,
|
lookbackDays: null,
|
||||||
@@ -1090,12 +1090,41 @@ describe('stats server API routes', () => {
|
|||||||
body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
|
body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 400);
|
||||||
// Half a day must not floor to a zero-day window; it means no limit.
|
assert.equal(cleanupCalls, 0);
|
||||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
let seenOptions: unknown = null;
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
createMockTracker({
|
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.equal(res.status, 200);
|
||||||
assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null });
|
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 () => {
|
it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
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', () => {
|
test('a run of frames longer than the animation bound survives', () => {
|
||||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
|
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
* contiguous, short-lived lines inside a single session.
|
* 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
|
* 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
|
* its short frames may be as long as the animation-frame bound rather than the much
|
||||||
* timing-only one. Five or more repeats of the same text, each ending where the next
|
* 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
|
* 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
|
* 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
|
* 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) {
|
if (run.length < bounds.minRunLength) {
|
||||||
return false;
|
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 {
|
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.
|
// the same scan without writing, so the confirmation the user sees is the real cost.
|
||||||
app.post('/api/stats/maintenance/duplicate-lines', async (c) => {
|
app.post('/api/stats/maintenance/duplicate-lines', async (c) => {
|
||||||
const body = await c.req.json().catch(() => null);
|
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 });
|
const result = await tracker.cleanupDuplicateSubtitleLines({ dryRun, lookbackDays });
|
||||||
return c.json(statsJson('duplicateLineCleanup', result));
|
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
|
* Read a duplicate-line cleanup request. An explicit object with no lookback scans all
|
||||||
* history, which is what the CLI does; only a positive number narrows the window.
|
* history. Invalid bodies and invalid windows are rejected instead of broadening scope.
|
||||||
*/
|
*/
|
||||||
export function parseDuplicateLineCleanupBody(body: unknown): {
|
export function parseDuplicateLineCleanupBody(body: unknown): {
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
lookbackDays: number | null;
|
lookbackDays: number | null;
|
||||||
} {
|
} | null {
|
||||||
const source = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
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;
|
const rawLookback = source.lookbackDays;
|
||||||
// Floor before the bounds check, or a fraction of a day arrives as a zero-day window.
|
if (
|
||||||
const wholeDays =
|
rawLookback !== undefined &&
|
||||||
typeof rawLookback === 'number' && Number.isFinite(rawLookback)
|
rawLookback !== null &&
|
||||||
? Math.floor(rawLookback)
|
(typeof rawLookback !== 'number' || !Number.isFinite(rawLookback) || rawLookback < 1)
|
||||||
: null;
|
) {
|
||||||
const lookbackDays = wholeDays !== null && wholeDays >= 1 ? wholeDays : null;
|
return null;
|
||||||
return { dryRun: source.dryRun === true, lookbackDays };
|
}
|
||||||
|
return {
|
||||||
|
dryRun: source.dryRun === true,
|
||||||
|
lookbackDays: typeof rawLookback === 'number' ? Math.floor(rawLookback) : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | 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 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),
|
gate.shouldRecord(sample),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,6 +60,21 @@ test('parsed cues outrank the streaming heuristic for short repeated cues', () =
|
|||||||
assert.equal(recorded.length, 8);
|
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', () => {
|
test('a line whose timing does not match any cue still records', () => {
|
||||||
// A shifted track, an embedded sub nobody parsed: no match, no drop.
|
// A shifted track, an embedded sub nobody parsed: no match, no drop.
|
||||||
const cues: SubtitleCue[] = [{ startTime: 10, endTime: 14, text: '飛び上がる' }];
|
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);
|
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', () => {
|
test('without parsed cues a long run of identical short frames stops recording', () => {
|
||||||
const gate = createSubtitleLineDedupGate({ getParsedCues: () => null });
|
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);
|
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 {
|
export interface SubtitleLineDedupGate {
|
||||||
/** False when this line is an animation frame of a line already recorded. */
|
/** False when this line is an animation frame of a line already recorded. */
|
||||||
shouldRecord: (sample: SubtitleLineSample) => boolean;
|
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;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ interface StreamingRunState {
|
|||||||
frames: number;
|
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 {
|
function normalizeLineText(text: string): string {
|
||||||
return normalizePlainSubtitleText(text, { collapseLineBreaks: true });
|
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
|
* 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.
|
* 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(
|
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) {
|
if (startsOwnCue) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return spans.some(
|
return coveringSpans.some(
|
||||||
(span) =>
|
(span) =>
|
||||||
startSec > span.startTime + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS &&
|
startSec > span.startTime + CUE_START_IDENTITY_TOLERANCE_SECONDS &&
|
||||||
startSec <= span.endTime + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS,
|
startSec <= span.endTime + CUE_START_IDENTITY_TOLERANCE_SECONDS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubtitleLineDedupGate(
|
export function createSubtitleLineDedupGate(
|
||||||
deps: SubtitleLineDedupGateDeps,
|
deps: SubtitleLineDedupGateDeps,
|
||||||
): SubtitleLineDedupGate {
|
): 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 spansByText: Map<string, CueSpan[]> = new Map();
|
||||||
let run: StreamingRunState | null = null;
|
let run: StreamingRunState | null = null;
|
||||||
|
|
||||||
const lookupSpans = (text: string): CueSpan[] | null => {
|
const lookupSpans = (text: string): CueSpan[] | null => {
|
||||||
const cues = deps.getParsedCues();
|
const cues = deps.getParsedCues() ?? null;
|
||||||
if (!cues?.length) {
|
if (ignoredCuesAfterReset !== undefined) {
|
||||||
indexedCues = null;
|
if (cues === ignoredCuesAfterReset) {
|
||||||
spansByText = new Map();
|
return null;
|
||||||
return null;
|
}
|
||||||
|
ignoredCuesAfterReset = undefined;
|
||||||
}
|
}
|
||||||
if (cues !== indexedCues) {
|
if (cues !== indexedCues) {
|
||||||
indexedCues = cues;
|
indexedCues = cues;
|
||||||
spansByText = buildSpansByText(cues);
|
spansByText = cues?.length ? buildSpansByText(cues) : new Map();
|
||||||
|
run = null;
|
||||||
}
|
}
|
||||||
return spansByText.get(text) ?? null;
|
return spansByText.get(text) ?? null;
|
||||||
};
|
};
|
||||||
@@ -175,14 +189,20 @@ export function createSubtitleLineDedupGate(
|
|||||||
// between sidebar and stats this gate exists to prevent.
|
// between sidebar and stats this gate exists to prevent.
|
||||||
const spans = lookupSpans(text);
|
const spans = lookupSpans(text);
|
||||||
if (spans) {
|
if (spans) {
|
||||||
run = null;
|
const mergedAway = isMergedAwayFrame(spans, sample.startSec);
|
||||||
return !isMergedAwayFrame(spans, sample.startSec);
|
if (mergedAway !== null) {
|
||||||
|
run = null;
|
||||||
|
return !mergedAway;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return advanceStreamingRun(text, sample);
|
return advanceStreamingRun(text, sample);
|
||||||
},
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
run = null;
|
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]);
|
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
|
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
|
||||||
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
||||||
: undefined,
|
: undefined,
|
||||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
onSubtitleTrackChange: (sid: number | null) => {
|
||||||
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
immersionLineDedupGate.reset();
|
||||||
: undefined,
|
deps.onSubtitleTrackChange?.(sid);
|
||||||
|
},
|
||||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -183,7 +184,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
||||||
broadcastSecondarySubtitle: (text: string) =>
|
broadcastSecondarySubtitle: (text: string) =>
|
||||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||||
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
updateCurrentMediaPath: (path: string) => {
|
||||||
|
immersionLineDedupGate.reset();
|
||||||
|
deps.updateCurrentMediaPath(path);
|
||||||
|
},
|
||||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||||
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
||||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||||
|
|||||||
Reference in New Issue
Block a user