From a20269e9f51933ec423131eef9566678b32838f7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 31 Aug 2026 23:31:31 -0700 Subject: [PATCH 1/6] fix(jellyfin): stop inferring subtitle delays (#227) --- changes/jellyfin-zero-subtitle-delay.md | 4 + docs-site/jellyfin-integration.md | 2 +- src/core/services/index.ts | 6 - .../services/jellyfin-subtitle-delay.test.ts | 54 ----- src/core/services/jellyfin-subtitle-delay.ts | 66 ------ .../services/subtitle-timing-offset.test.ts | 73 ------- src/core/services/subtitle-timing-offset.ts | 153 -------------- src/main.ts | 23 --- ...ellyfin-subtitle-preload-main-deps.test.ts | 30 +-- .../jellyfin-subtitle-preload-main-deps.ts | 13 -- .../runtime/jellyfin-subtitle-preload.test.ts | 192 +----------------- src/main/runtime/jellyfin-subtitle-preload.ts | 90 +------- 12 files changed, 9 insertions(+), 697 deletions(-) create mode 100644 changes/jellyfin-zero-subtitle-delay.md delete mode 100644 src/core/services/jellyfin-subtitle-delay.test.ts delete mode 100644 src/core/services/jellyfin-subtitle-delay.ts delete mode 100644 src/core/services/subtitle-timing-offset.test.ts delete mode 100644 src/core/services/subtitle-timing-offset.ts diff --git a/changes/jellyfin-zero-subtitle-delay.md b/changes/jellyfin-zero-subtitle-delay.md new file mode 100644 index 00000000..92ae32ff --- /dev/null +++ b/changes/jellyfin-zero-subtitle-delay.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 064cdc18..a07197a2 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -54,7 +54,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y - **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load. - **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin. - **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match. -- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track. +- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay. ## Settings diff --git a/src/core/services/index.ts b/src/core/services/index.ts index b348c2ce..503493ba 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -131,12 +131,6 @@ export { resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime, ticksToSeconds as jellyfinTicksToSecondsRuntime, } from './jellyfin'; -export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay'; -export { - estimateSubtitleTimingOffset, - type SubtitleTimingOffsetOptions, - type SubtitleTimingOffsetResult, -} from './subtitle-timing-offset'; export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote'; export { broadcastRuntimeOptionsChangedRuntime, diff --git a/src/core/services/jellyfin-subtitle-delay.test.ts b/src/core/services/jellyfin-subtitle-delay.test.ts deleted file mode 100644 index 6f844d49..00000000 --- a/src/core/services/jellyfin-subtitle-delay.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import test from 'node:test'; -import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay'; - -function statePath(name: string): string { - return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name); -} - -test('jellyfin subtitle delay store saves and loads delay by item and stream', () => { - const filePath = statePath('delays.json'); - - assert.equal( - saveJellyfinSubtitleDelay({ - filePath, - itemId: 'episode-1', - streamIndex: 3, - delaySeconds: 1.25, - }), - true, - ); - - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25); - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null); -}); - -test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => { - const filePath = statePath('delays.json'); - - saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 }); - saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 }); - saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 }); - - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2); - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5); -}); - -test('jellyfin subtitle delay store ignores invalid files and values', () => { - const filePath = statePath('delays.json'); - fs.writeFileSync(filePath, '{'); - - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null); - assert.equal( - saveJellyfinSubtitleDelay({ - filePath, - itemId: 'episode-1', - streamIndex: 3, - delaySeconds: Number.NaN, - }), - false, - ); -}); diff --git a/src/core/services/jellyfin-subtitle-delay.ts b/src/core/services/jellyfin-subtitle-delay.ts deleted file mode 100644 index 18bf8b3b..00000000 --- a/src/core/services/jellyfin-subtitle-delay.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -type JellyfinSubtitleDelayStore = { - version?: unknown; - delays?: unknown; -}; - -type JellyfinSubtitleDelayParams = { - filePath: string; - itemId: string; - streamIndex: number; -}; - -type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & { - delaySeconds: number; -}; - -function storeKey(itemId: string, streamIndex: number): string { - return JSON.stringify([itemId, streamIndex]); -} - -function readDelayMap(filePath: string): Record { - try { - if (!fs.existsSync(filePath)) return {}; - const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore; - if ( - !parsed || - typeof parsed !== 'object' || - !parsed.delays || - typeof parsed.delays !== 'object' - ) { - return {}; - } - const delays: Record = {}; - for (const [key, value] of Object.entries(parsed.delays as Record)) { - if (typeof value === 'number' && Number.isFinite(value)) { - delays[key] = value; - } - } - return delays; - } catch { - return {}; - } -} - -export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null { - const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)]; - return typeof delay === 'number' && Number.isFinite(delay) ? delay : null; -} - -export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean { - if (!Number.isFinite(params.delaySeconds)) return false; - try { - const delays = readDelayMap(params.filePath); - delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds; - const dir = path.dirname(params.filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2)); - return true; - } catch { - return false; - } -} diff --git a/src/core/services/subtitle-timing-offset.test.ts b/src/core/services/subtitle-timing-offset.test.ts deleted file mode 100644 index 15cad6e6..00000000 --- a/src/core/services/subtitle-timing-offset.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { estimateSubtitleTimingOffset } from './subtitle-timing-offset'; - -function cue(startTime: number) { - return { startTime, endTime: startTime + 1, text: `cue ${startTime}` }; -} - -test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => { - const primary = [ - 34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814, - 87.988, 90.991, 94.094, 97.097, - ].map(cue); - const reference = [ - 3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, - ].map(cue); - - const result = estimateSubtitleTimingOffset(primary, reference); - - assert.ok(result); - assert.ok(result.offsetSeconds > -32); - assert.ok(result.offsetSeconds < -31); - assert.ok(result.matchCount >= 8); - assert.ok(result.meanErrorSeconds <= 0.75); -}); - -test('estimate subtitle timing offset favors the early episode timeline', () => { - const primary = [ - 34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814, - 87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778, - 246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407, - 277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942, - 312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422, - 357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957, - 396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26, - 435.602, 440.44, 442.942, 445.445, 449.783, - ].map(cue); - const reference = [ - 3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81, - 176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65, - 232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01, - 280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82, - 333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43, - 388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31, - 450.57, 457.62, 463.41, 465.85, 473.79, 480.59, - ].map(cue); - - const result = estimateSubtitleTimingOffset(primary, reference); - - assert.ok(result); - assert.ok(result.offsetSeconds > -32); - assert.ok(result.offsetSeconds < -31); -}); - -test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => { - const starts = [1, 5, 9, 14, 20, 25, 31, 38]; - - const result = estimateSubtitleTimingOffset( - starts.map(cue), - starts.map((start) => cue(start + 0.04)), - ); - - assert.equal(result, null); -}); - -test('estimate subtitle timing offset rejects weak timeline matches', () => { - const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue); - const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue); - - const result = estimateSubtitleTimingOffset(primary, reference); - - assert.equal(result, null); -}); diff --git a/src/core/services/subtitle-timing-offset.ts b/src/core/services/subtitle-timing-offset.ts deleted file mode 100644 index e52ec79d..00000000 --- a/src/core/services/subtitle-timing-offset.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { SubtitleCue } from './subtitle-cue-parser'; - -export type SubtitleTimingOffsetResult = { - offsetSeconds: number; - matchCount: number; - meanErrorSeconds: number; - maxErrorSeconds: number; -}; - -export type SubtitleTimingOffsetOptions = { - maxCueCount?: number; - maxOffsetSeconds?: number; - matchThresholdSeconds?: number; - maxMeanErrorSeconds?: number; - minMatchCount?: number; - minMatchRatio?: number; - minUsefulOffsetSeconds?: number; -}; - -type OffsetScore = SubtitleTimingOffsetResult; - -const DEFAULT_MAX_CUE_COUNT = 60; -const DEFAULT_MAX_OFFSET_SECONDS = 180; -const DEFAULT_MATCH_THRESHOLD_SECONDS = 1; -const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75; -const DEFAULT_MIN_MATCH_COUNT = 8; -const DEFAULT_MIN_MATCH_RATIO = 0.25; -const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25; - -function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] { - const starts = cues - .map((cue) => cue.startTime) - .filter((start) => Number.isFinite(start) && start >= 0) - .sort((a, b) => a - b); - const deduped: number[] = []; - for (const start of starts) { - const previous = deduped[deduped.length - 1]; - if (previous === undefined || Math.abs(start - previous) > 0.05) { - deduped.push(start); - } - if (deduped.length >= maxCueCount) { - break; - } - } - return deduped; -} - -function roundToMillis(value: number): number { - return Math.round(value * 1000) / 1000; -} - -function scoreOffset( - primaryStarts: number[], - referenceStarts: number[], - offsetSeconds: number, - matchThresholdSeconds: number, -): OffsetScore { - let primaryIndex = 0; - let referenceIndex = 0; - let matchCount = 0; - let totalErrorSeconds = 0; - let maxErrorSeconds = 0; - - while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) { - const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds; - const reference = referenceStarts[referenceIndex]!; - const errorSeconds = Math.abs(shiftedPrimary - reference); - if (errorSeconds <= matchThresholdSeconds) { - matchCount += 1; - totalErrorSeconds += errorSeconds; - maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds); - primaryIndex += 1; - referenceIndex += 1; - continue; - } - - if (shiftedPrimary < reference) { - primaryIndex += 1; - } else { - referenceIndex += 1; - } - } - - return { - offsetSeconds, - matchCount, - meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY, - maxErrorSeconds, - }; -} - -function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean { - if (current === null) return true; - if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount; - if (next.meanErrorSeconds !== current.meanErrorSeconds) { - return next.meanErrorSeconds < current.meanErrorSeconds; - } - return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds); -} - -export function estimateSubtitleTimingOffset( - primaryCues: SubtitleCue[], - referenceCues: SubtitleCue[], - options: SubtitleTimingOffsetOptions = {}, -): SubtitleTimingOffsetResult | null { - const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT; - const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS; - const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS; - const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS; - const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT; - const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO; - const minUsefulOffsetSeconds = - options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS; - - const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount); - const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount); - const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length); - if (comparableCueCount < minMatchCount) { - return null; - } - - const candidates = new Set(); - for (const primaryStart of primaryStarts) { - for (const referenceStart of referenceStarts) { - const offsetSeconds = roundToMillis(referenceStart - primaryStart); - if (Math.abs(offsetSeconds) <= maxOffsetSeconds) { - candidates.add(offsetSeconds); - } - } - } - - let best: OffsetScore | null = null; - for (const offsetSeconds of candidates) { - if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) { - continue; - } - const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds); - if (score.matchCount < minMatchCount) { - continue; - } - if (score.matchCount / comparableCueCount < minMatchRatio) { - continue; - } - if (score.meanErrorSeconds > maxMeanErrorSeconds) { - continue; - } - if (isBetterScore(score, best)) { - best = score; - } - } - - return best; -} diff --git a/src/main.ts b/src/main.ts index c6929e85..80dc1d77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -302,7 +302,6 @@ import { listJellyfinItemsRuntime, listJellyfinLibrariesRuntime, listJellyfinSubtitleTracksRuntime, - loadJellyfinSubtitleDelay, loadSubtitlePosition as loadSubtitlePositionCore, loadYomitanExtension as loadYomitanExtensionCore, markLastCardAsAudioCard as markLastCardAsAudioCardCore, @@ -315,7 +314,6 @@ import { resolveSanitizedSubtitleSeekCommand, resolveJellyfinPlaybackPlanRuntime, runStartupBootstrapRuntime, - saveJellyfinSubtitleDelay, saveSubtitlePosition as saveSubtitlePositionCore, clearYomitanParserCachesForWindow, getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, @@ -677,7 +675,6 @@ function spawnManagedMpvProcess(args: string[]): ReturnType { } let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; -let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null; let jellyfinRemoteLastProgressAtMs = 0; let jellyfinMpvAutoLaunchInFlight: Promise | null = null; let backgroundWarmupsStarted = false; @@ -2482,7 +2479,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime new Promise((resolve) => setTimeout(resolve, ms)), cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track), cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs), - getSavedSubtitleDelay: (itemId, streamIndex) => - loadJellyfinSubtitleDelay({ - filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, - itemId, - streamIndex, - }), - setActiveSubtitleDelayKey: (key) => { - activeJellyfinSubtitleDelayKey = key; - }, - loadSubtitleSourceText, - saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => - saveJellyfinSubtitleDelay({ - filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, - itemId, - streamIndex, - delaySeconds, - }), initSubtitlePrefetch: (sourcePath) => subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath), logDebug: (message, error) => { @@ -3188,7 +3167,6 @@ const { getActivePlayback: () => activeJellyfinRemotePlayback, clearActivePlayback: () => { activeJellyfinRemotePlayback = null; - activeJellyfinSubtitleDelayKey = null; }, getSession: () => appState.jellyfinRemoteSession, getNow: () => Date.now(), @@ -4545,7 +4523,6 @@ const { appState.activeParsedSubtitleSource = null; appState.activeParsedSubtitleMediaPath = null; } - activeJellyfinSubtitleDelayKey = null; overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts index bb79a4de..f6f4d1b5 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }; }, cleanupCachedSubtitles: () => calls.push('cleanup'), - getSavedSubtitleDelay: (_itemId, streamIndex) => { - calls.push(`load-delay:${streamIndex}`); - return 1.25; - }, - setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`), - loadSubtitleSourceText: async (source) => { - calls.push(`load-source:${source}`); - return 'subtitle'; - }, - saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => { - calls.push(`save-delay:${streamIndex}:${delaySeconds}`); - return true; - }, logDebug: (message) => calls.push(`debug:${message}`), })(); @@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy await deps.wait(1); await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }); deps.cleanupCachedSubtitles(['/tmp/subs']); - assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25); - deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 }); - assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle'); - assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true); deps.logDebug('oops', null); - assert.deepEqual(calls, [ - 'list', - 'send', - 'wait', - 'cache', - 'cleanup', - 'load-delay:3', - 'active-delay:3', - 'load-source:/tmp/sub.srt', - 'save-delay:3:-31.5', - 'debug:oops', - ]); + assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']); }); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts index f5ca73a7..b00f08ff 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( wait: (ms: number) => deps.wait(ms), cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track), cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs), - getSavedSubtitleDelay: deps.getSavedSubtitleDelay - ? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex) - : undefined, - setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey - ? (key) => deps.setActiveSubtitleDelayKey!(key) - : undefined, - loadSubtitleSourceText: deps.loadSubtitleSourceText - ? (source) => deps.loadSubtitleSourceText!(source) - : undefined, - saveSubtitleDelay: deps.saveSubtitleDelay - ? (itemId, streamIndex, delaySeconds) => - deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds) - : undefined, initSubtitlePrefetch: deps.initSubtitlePrefetch ? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath) : undefined, diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index 8477f174..86a405fb 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -32,14 +32,6 @@ function makeDeps(overrides: { cleanupCachedSubtitles?: Parameters< typeof createPreloadJellyfinExternalSubtitlesHandler >[0]['cleanupCachedSubtitles']; - getSavedSubtitleDelay?: Parameters< - typeof createPreloadJellyfinExternalSubtitlesHandler - >[0]['getSavedSubtitleDelay']; - setActiveSubtitleDelayKey?: Parameters< - typeof createPreloadJellyfinExternalSubtitlesHandler - >[0]['setActiveSubtitleDelayKey']; - loadSubtitleSourceText?: (source: string) => Promise; - saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void; initSubtitlePrefetch?: Parameters< typeof createPreloadJellyfinExternalSubtitlesHandler >[0]['initSubtitlePrefetch']; @@ -57,10 +49,6 @@ function makeDeps(overrides: { cleanupDir: '/tmp/subminer-jellyfin-subtitles', })), cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}), - getSavedSubtitleDelay: overrides.getSavedSubtitleDelay, - setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey, - loadSubtitleSourceText: overrides.loadSubtitleSourceText, - saveSubtitleDelay: overrides.saveSubtitleDelay, initSubtitlePrefetch: overrides.initSubtitlePrefetch, logDebug: overrides.logDebug ?? (() => {}), }; @@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => { const commands: Array> = []; - const activeDelayKeys: Array = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( makeDeps({ listJellyfinSubtitleTracks: async () => [ { index: 0, language: 'jpn', title: 'Embedded Japanese' }, ], sendMpvCommand: (command) => commands.push(command), - setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key), }), ); await preload({ session, clientInfo, itemId: 'item-1' }); - assert.deepEqual(activeDelayKeys, [null]); assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]); }); @@ -461,42 +446,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese ]); }); -test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => { - const commands: Array> = []; - const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = []; - const preload = createPreloadJellyfinExternalSubtitlesHandler( - makeDeps({ - listJellyfinSubtitleTracks: async () => [ - { index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, - ], - getMpvClient: () => ({ - requestProperty: async () => [ - { - type: 'sub', - id: 11, - lang: 'jpn', - title: 'Japanese', - external: true, - 'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt', - }, - ], - }), - sendMpvCommand: (command) => commands.push(command), - getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null), - setActiveSubtitleDelayKey: (key) => activeKeys.push(key), - }), - ); - - await preload({ session, clientInfo, itemId: 'item-9' }); - - assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ - ['set_property', 'sub-delay', 1.25], - ['set_property', 'sid', 11], - ]); - assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]); -}); - -test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => { +test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => { const commands: Array> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( makeDeps({ @@ -516,14 +466,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s ], }), sendMpvCommand: (command) => commands.push(command), - getSavedSubtitleDelay: () => 1.25, }), ); await preload({ session, clientInfo, itemId: 'item-9' }); const delayIndex = commands.findIndex( - (command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25, + (command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0, ); const selectedSidIndex = commands.findIndex( (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11, @@ -533,143 +482,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s assert.ok(delayIndex < selectedSidIndex); }); -test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => { - const commands: Array> = []; - const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = []; - const primarySrt = `1 -00:00:34,935 --> 00:00:36,937 -Japanese 1 - -2 -00:00:36,937 --> 00:00:41,441 -Japanese 2 - -3 -00:00:41,441 --> 00:00:45,279 -Japanese 3 - -4 -00:00:45,279 --> 00:00:48,115 -Japanese 4 - -5 -00:00:48,115 --> 00:00:52,286 -Japanese 5 - -6 -00:00:52,286 --> 00:00:54,955 -Japanese 6 - -7 -00:00:54,955 --> 00:00:59,793 -Japanese 7 - -8 -00:00:59,793 --> 00:01:03,630 -Japanese 8 - -9 -00:01:03,630 --> 00:01:07,634 -Japanese 9 - -10 -00:01:07,634 --> 00:01:13,040 -Japanese 10 - -11 -00:01:16,643 --> 00:01:20,814 -Japanese 11 - -12 -00:01:20,814 --> 00:01:23,116 -Japanese 12 - -13 -00:01:27,988 --> 00:01:30,991 -Japanese 13 - -14 -00:01:30,991 --> 00:01:34,094 -Japanese 14 - -15 -00:01:34,094 --> 00:01:37,097 -Japanese 15 - -16 -00:01:37,097 --> 00:01:39,100 -Japanese 16 -`; - const referenceAss = `[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1 -Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2 -Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3 -Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4 -Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5 -Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6 -Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7 -Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8 -Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9 -Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10 -Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11 -Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12 -`; - const preload = createPreloadJellyfinExternalSubtitlesHandler( - makeDeps({ - listJellyfinSubtitleTracks: async () => [ - { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, - { index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' }, - ], - getMpvClient: () => ({ - requestProperty: async () => [ - { - type: 'sub', - id: 10, - lang: 'jpn', - title: 'Japanese', - external: true, - 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', - }, - { - type: 'sub', - id: 12, - lang: 'eng', - title: 'English', - external: true, - 'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass', - }, - ], - }), - sendMpvCommand: (command) => commands.push(command), - cacheSubtitleTrack: async (track) => ({ - path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`, - cleanupDir: '/tmp/subminer-jellyfin-subtitles', - }), - getSavedSubtitleDelay: () => null, - loadSubtitleSourceText: async (source) => - source.endsWith('.ass') ? referenceAss : primarySrt, - saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => { - savedDelays.push({ itemId, streamIndex, delaySeconds }); - }, - }), - ); - - await preload({ session, clientInfo, itemId: 'item-9' }); - - const delayCommand = commands.find( - (command) => command[0] === 'set_property' && command[1] === 'sub-delay', - ); - assert.ok(delayCommand); - const delaySeconds = delayCommand[2]; - if (typeof delaySeconds !== 'number') { - assert.fail('Expected numeric subtitle delay.'); - } - assert.ok(delaySeconds > -32); - assert.ok(delaySeconds < -31); - assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]); -}); - test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => { const commands: Array> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 5843075f..dcd12518 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -1,6 +1,3 @@ -import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; -import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset'; - type JellyfinSession = { serverUrl: string; accessToken: string; @@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & { source: JellyfinSubtitleTrack; }; -type JellyfinSubtitleDelayKey = { - itemId: string; - streamIndex: number; -}; - type MpvSubtitleTrack = { id: number; lang: string; @@ -257,54 +249,6 @@ async function waitForPreferredSubtitleTracks( return subtitleTracks; } -async function estimateSubtitleDelayFromReference( - deps: { - loadSubtitleSourceText?: (source: string) => Promise; - logDebug: (message: string, error: unknown) => void; - }, - primaryTrack: CachedExternalSubtitleTrack | null, - referenceTrack: CachedExternalSubtitleTrack | null, -): Promise { - if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) { - return null; - } - - try { - const [primaryContent, referenceContent] = await Promise.all([ - deps.loadSubtitleSourceText(primaryTrack.path), - deps.loadSubtitleSourceText(referenceTrack.path), - ]); - const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path); - const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path); - return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null; - } catch (error) { - deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error); - return null; - } -} - -function saveEstimatedSubtitleDelay( - deps: { - saveSubtitleDelay?: ( - itemId: string, - streamIndex: number, - delaySeconds: number, - ) => boolean | void; - logDebug: (message: string, error: unknown) => void; - }, - key: JellyfinSubtitleDelayKey, - delaySeconds: number, -): void { - try { - const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds); - if (saved === false) { - deps.logDebug('Failed to save Jellyfin auto subtitle delay', key); - } - } catch (error) { - deps.logDebug('Failed to save Jellyfin auto subtitle delay', error); - } -} - export function createPreloadJellyfinExternalSubtitlesHandler(deps: { listJellyfinSubtitleTracks: ( session: JellyfinSession, @@ -316,10 +260,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { wait: (ms: number) => Promise; cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise; cleanupCachedSubtitles: (dirs: string[]) => void; - getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null; - setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; - loadSubtitleSourceText?: (source: string) => Promise; - saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; initSubtitlePrefetch?: (sourcePath: string) => void | Promise; logDebug: (message: string, error: unknown) => void; }): PreloadJellyfinExternalSubtitlesHandler { @@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { itemId: string; }): Promise => { try { + resetManagedSubtitleDelay(); try { cleanupActiveCache(); } catch (error) { @@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { ); const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl)); if (externalTracks.length === 0) { - deps.setActiveSubtitleDelayKey?.(null); - resetManagedSubtitleDelay(); return; } @@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { japanesePrimaryId, ); if (selectedCachedTrack) { - const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index }; - deps.setActiveSubtitleDelayKey?.(delayKey); - const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex); - if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) { - deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]); - } else { - const referenceCachedTrack = findCachedTrackForMpvTrackId( - resolvedSubtitleTracks, - cachedTracks, - englishSecondaryId, - ); - const estimatedDelay = await estimateSubtitleDelayFromReference( - deps, - selectedCachedTrack, - referenceCachedTrack, - ); - if (estimatedDelay !== null) { - deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]); - saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay); - } else { - resetManagedSubtitleDelay(); - } - } deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path); } else { - deps.setActiveSubtitleDelayKey?.(null); - resetManagedSubtitleDelay(); deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); } } else { deps.sendMpvCommand(['set_property', 'sid', 'no']); - deps.setActiveSubtitleDelayKey?.(null); - resetManagedSubtitleDelay(); } if (englishSecondaryId !== null) { From fc5c49e3657f760902f83f982cddfc1c7a727f18 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 1 Sep 2026 00:49:37 -0700 Subject: [PATCH 2/6] fix(subtitles): keep native secondary subtitles hidden (#232) - Reapply hidden visibility after secondary track changes - Relinquish suppression only after successful restoration --- .../secondary-subtitle-track-visibility.md | 4 ++ src/core/services/mpv-protocol.test.ts | 34 ++++++++++++ src/core/services/mpv-protocol.ts | 9 ++++ src/core/services/mpv.test.ts | 53 ++++++++++++++++++- src/core/services/mpv.ts | 7 ++- 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 changes/secondary-subtitle-track-visibility.md diff --git a/changes/secondary-subtitle-track-visibility.md b/changes/secondary-subtitle-track-visibility.md new file mode 100644 index 00000000..10ffaffd --- /dev/null +++ b/changes/secondary-subtitle-track-visibility.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback. diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index b92d23ac..a1f0a791 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -83,6 +83,7 @@ function createDeps(overrides: Partial = {}): { state.secondarySubText = text; }, resolvePendingRequest: () => false, + shouldEnforceSecondarySubVisibilityHidden: () => true, setSecondarySubVisibility: () => {}, syncCurrentAudioStreamIndex: () => {}, setCurrentAudioTrackId: () => {}, @@ -198,6 +199,21 @@ test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () = assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]); }); +test('dispatchMpvProtocolMessage hides native secondary subtitles after a track change', async () => { + const visibilityChanges: boolean[] = []; + const { deps, state } = createDeps({ + setSecondarySubVisibility: (visible) => visibilityChanges.push(visible), + }); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sid', data: '4' }, + deps, + ); + + assert.deepEqual(visibilityChanges, [false]); + assert.deepEqual(state.events, [{ sid: 4 }]); +}); + test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => { const { deps, state } = createDeps({ isVisibleOverlayVisible: () => true, @@ -239,6 +255,24 @@ test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay i assert.equal(state.commands.length, 0); }); +test('dispatchMpvProtocolMessage corrects native secondary subtitle visibility', async () => { + const visibilityChanges: boolean[] = []; + const { deps } = createDeps({ + setSecondarySubVisibility: (visible) => visibilityChanges.push(visible), + }); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sub-visibility', data: 'yes' }, + deps, + ); + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sub-visibility', data: 'no' }, + deps, + ); + + assert.deepEqual(visibilityChanges, [false]); +}); + test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => { const { deps, state } = createDeps(); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index c892a577..3b9a7ccc 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -72,6 +72,7 @@ export interface MpvProtocolHandleMessageDeps { emitSubtitleMetricsChange: (payload: Partial) => void; setCurrentSecondarySubText: (text: string) => void; resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; + shouldEnforceSecondarySubVisibilityHidden: () => boolean; setSecondarySubVisibility: (visible: boolean) => void; syncCurrentAudioStreamIndex: () => void; setCurrentAudioTrackId: (value: number | null) => void; @@ -285,6 +286,9 @@ export async function dispatchMpvProtocolMessage( : null; deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null }); } else if (msg.name === 'secondary-sid') { + if (deps.shouldEnforceSecondarySubVisibilityHidden()) { + deps.setSecondarySubVisibility(false); + } const sid = typeof msg.data === 'number' ? msg.data @@ -375,6 +379,11 @@ export async function dispatchMpvProtocolMessage( if (deps.isVisibleOverlayVisible() && asBoolean(msg.data, false)) { deps.sendCommand({ command: ['set_property', 'sub-visibility', false] }); } + } else if (msg.name === 'secondary-sub-visibility') { + const visible = parseVisibilityProperty(msg.data); + if (deps.shouldEnforceSecondarySubVisibilityHidden() && visible === true) { + deps.setSecondarySubVisibility(false); + } } else if (msg.name === 'sub-use-margins') { deps.emitSubtitleMetricsChange({ subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins), diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 4afb556f..afe6a5bf 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -652,7 +652,7 @@ test('MpvIpcClient captures and disables secondary subtitle visibility on reques ]); }); -test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => { +test('MpvIpcClient restores secondary subtitle visibility and relinquishes suppression', async () => { const commands: unknown[] = []; const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const previous: boolean[] = []; @@ -671,6 +671,12 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra }); client.restorePreviousSecondarySubVisibility(); + await invokeHandleMessage(client, { + event: 'property-change', + name: 'secondary-sub-visibility', + data: 'yes', + }); + assert.equal(previous[0], true); assert.equal(previous.length, 1); assert.deepEqual(commands, [ @@ -682,8 +688,53 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra }, ]); + await invokeHandleMessage(client, { + event: 'property-change', + name: 'secondary-sub-visibility', + data: 'yes', + }); + assert.equal(commands.length, 2); + client.restorePreviousSecondarySubVisibility(); assert.equal(commands.length, 2); + + const callbacks = (client as any).transport.callbacks; + callbacks.onConnect(); + commands.length = 0; + + await invokeHandleMessage(client, { + event: 'property-change', + name: 'secondary-sub-visibility', + data: 'yes', + }); + assert.deepEqual(commands, [{ command: ['set_property', 'secondary-sub-visibility', 'no'] }]); +}); + +test('MpvIpcClient keeps secondary subtitle suppression when restoration send fails', async () => { + const commands: unknown[] = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + + (client as any).send = (payload: unknown) => { + commands.push(payload); + return false; + }; + + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + data: 'yes', + }); + client.restorePreviousSecondarySubVisibility(); + await invokeHandleMessage(client, { + event: 'property-change', + name: 'secondary-sid', + data: 4, + }); + + assert.deepEqual(commands, [ + { command: ['set_property', 'secondary-sub-visibility', 'no'] }, + { command: ['set_property', 'secondary-sub-visibility', 'yes'] }, + { command: ['set_property', 'secondary-sub-visibility', 'no'] }, + ]); }); test('MpvIpcClient updates current audio stream index from track list', async () => { diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 271e9735..3345733d 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -184,6 +184,7 @@ export class MpvIpcClient implements MpvClient { osdDimensions: null, }; private previousSecondarySubVisibility: boolean | null = null; + private enforceSecondarySubVisibilityHidden = true; private playbackPaused: boolean | null = null; private pauseAtTime: number | null = null; private pendingPauseAtSubEnd = false; @@ -199,6 +200,7 @@ export class MpvIpcClient implements MpvClient { socketFactory: deps.socketFactory, connectTimeoutMs: deps.connectTimeoutMs, onConnect: () => { + this.enforceSecondarySubVisibilityHidden = true; this.connected = true; this.connecting = false; this.socket = this.transport.getSocket(); @@ -476,6 +478,7 @@ export class MpvIpcClient implements MpvClient { }, resolvePendingRequest: (requestId: number, message: MpvMessage) => this.tryResolvePendingRequest(requestId, message), + shouldEnforceSecondarySubVisibilityHidden: () => this.enforceSecondarySubVisibilityHidden, setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible), syncCurrentAudioStreamIndex: () => { this.syncCurrentAudioStreamIndex(); @@ -647,9 +650,11 @@ export class MpvIpcClient implements MpvClient { restorePreviousSecondarySubVisibility(): void { const previous = this.previousSecondarySubVisibility; if (previous === null) return; - this.send({ + const restored = this.send({ command: ['set_property', 'secondary-sub-visibility', previous ? 'yes' : 'no'], }); + if (!restored) return; + this.enforceSecondarySubVisibilityHidden = false; this.previousSecondarySubVisibility = null; } From 87b01155df146d3f3538adb05230d95aae6dceb3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 1 Sep 2026 22:17:41 -0700 Subject: [PATCH 3/6] fix(mining): copy multi-line subtitles backward from current line (#231) --- changes/multiline-copy-timeline.md | 4 ++ docs-site/shortcuts.md | 2 +- src/core/services/mining.test.ts | 45 +++++++++++++ .../runtime/mpv-main-event-main-deps.test.ts | 15 +++-- src/main/runtime/mpv-main-event-main-deps.ts | 13 ++-- src/subtitle-timing-tracker.ts | 66 ++++++++++++++----- 6 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 changes/multiline-copy-timeline.md diff --git a/changes/multiline-copy-timeline.md b/changes/multiline-copy-timeline.md new file mode 100644 index 00000000..91b85be1 --- /dev/null +++ b/changes/multiline-copy-timeline.md @@ -0,0 +1,4 @@ +type: fixed +area: mining + +- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 645cd144..f2e0d079 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -35,7 +35,7 @@ These work when the overlay window has focus. | `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` | | `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | -The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin. +The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select the total number of subtitle lines to combine, ending at the current line and moving backward through the subtitle timeline. The current line counts toward the selected total. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin. ## Overlay Controls diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index a39bca09..5e6ad438 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -244,6 +244,35 @@ test('handleMultiCopyDigit copies available history and reports truncation', () assert.equal(osd.at(-1), 'Only 2 lines available, copied 2'); }); +test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => { + const copied: string[] = []; + const tracker = new SubtitleTimingTracker(); + + try { + tracker.recordSubtitle('A', 1, 2); + tracker.recordSubtitle('B', 3, 4); + tracker.recordSubtitle('C', 5, 6); + tracker.recordSubtitle('B', 3, 4); + + const deps = { + subtitleTimingTracker: tracker, + writeClipboardText: (text: string) => copied.push(text), + showMpvOsd: () => {}, + }; + + handleMultiCopyDigit(1, deps); + handleMultiCopyDigit(2, deps); + + assert.deepEqual(copied, ['B', 'A\n\nB']); + assert.deepEqual(tracker.getRecentEntries(2), [ + { displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined }, + { displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined }, + ]); + } finally { + tracker.destroy(); + } +}); + test('handleMineSentenceDigit reports async create failures', async () => { const osd: string[] = []; const logs: Array<{ message: string; err: unknown }> = []; @@ -344,6 +373,22 @@ test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats } }); +test('subtitle timing history preserves adjacent repeated text with distinct timings', () => { + const tracker = new SubtitleTimingTracker(); + + try { + tracker.recordSubtitle('same', 1, 2); + tracker.recordSubtitle('same', 3, 4); + + assert.deepEqual(tracker.getRecentEntries(2), [ + { displayText: 'same', startTime: 1, endTime: 2, secondaryText: undefined }, + { displayText: 'same', startTime: 3, endTime: 4, secondaryText: undefined }, + ]); + } finally { + tracker.destroy(); + } +}); + test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => { const created: Array<{ sentence: string; secondarySub?: string }> = []; const tracker = new SubtitleTimingTracker(); diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index ba46884a..0cf229f5 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -503,14 +503,21 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); assert.equal(immersion.length, 3); - // A jump of exactly the seek threshold counts as a seek, matching the time-pos - // handler's own `>=` boundary. - handlers.onTimePosUpdate?.(4.5); - handlers.onTimePosUpdate?.(2); + // Jumping back to a brief previous line moves time-pos by less than the general + // seek threshold. It is still a backward seek, so the revisited line records + // again; otherwise multi-line copy would keep treating the later line as current. + handlers.onTimePosUpdate?.(3.9); + handlers.onTimePosUpdate?.(2.9); handlers.recordSubtitleTiming('今', 0.8, 1.5); assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); + // Tiny time-pos jitter is not a seek and must not re-record the line. + handlers.onTimePosUpdate?.(3.0); + handlers.onTimePosUpdate?.(2.9); + handlers.recordSubtitleTiming('今', 0.8, 1.5); + assert.equal(timing.length, 5); + handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12); handlers.recordSubtitleTiming('Maid\nCafe', 10, 12); assert.equal(immersion.length, 3); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 5d87be61..fa89ab2d 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -1,6 +1,5 @@ import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate'; import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; -import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions'; import { resolveCanonicalPrimarySubtitle, resolvePrimarySubtitleText, @@ -115,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { // after the change is dropped instead of landing in the next session. let subtitleSessionEpoch = 0; let lastTimePosForTimingReset: number | null = null; + // Small margin so time-pos jitter is not mistaken for a backward seek. + const BACKWARD_SEEK_TIMING_RESET_SECONDS = 0.25; const canonicalCueKey = (cue: SubtitleCue): string => `${cue.startTime}|${cue.endTime}|${cue.text}`; const resetSubtitleDeduplication = (): void => { @@ -344,13 +345,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { deps.reportJellyfinRemoteProgress(forceImmediate), consumeExplicitSeek: deps.consumeExplicitSeek, onTimePosUpdate: (time: number) => { - // Timing history is a viewing log: after a real backward seek, a rewatched - // canonical line should enter it again. Immersion stats keep their - // once-per-media deduplication and are not reset here. + // Timing history is a viewing log: after any backward seek, a rewatched canonical + // line should enter it again so multi-line copy treats it as the current line. + // Playback never moves time-pos backward on its own, so even a short jump to a + // brief previous line counts. Immersion stats keep their once-per-media + // deduplication and are not reset here. if ( Number.isFinite(time) && lastTimePosForTimingReset !== null && - time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS + time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS ) { recordedTimingCanonicalKeys.clear(); } diff --git a/src/subtitle-timing-tracker.ts b/src/subtitle-timing-tracker.ts index 04d8fe8d..f55c603e 100644 --- a/src/subtitle-timing-tracker.ts +++ b/src/subtitle-timing-tracker.ts @@ -67,8 +67,13 @@ export class SubtitleTimingTracker { // Check for duplicate of most recent entry (deduplicate adjacent repeats) const lastEntry = this.history[this.history.length - 1]; - if (lastEntry && lastEntry.timingKey === timingKey) { - // Update timing to most recent occurrence + if ( + lastEntry && + lastEntry.timingKey === timingKey && + lastEntry.startTime === startTime && + lastEntry.endTime === endTime + ) { + // Refresh metadata for repeated notifications of the same subtitle event. lastEntry.startTime = startTime; lastEntry.endTime = endTime; lastEntry.secondaryText = displaySecondaryText; @@ -107,28 +112,20 @@ export class SubtitleTimingTracker { } /** - * Get recent subtitle blocks in chronological order. - * Returns the last `count` subtitle events (oldest → newest). + * Get recent subtitle blocks in timeline order. + * Returns up to `count` known subtitle events ending at the current event. * Blocks preserve internal line breaks and are joined with blank lines. */ getRecentBlocks(count: number): string[] { - if (count <= 0) return []; - if (count > this.history.length) { - count = this.history.length; - } - return this.history.slice(-count).map((entry) => entry.displayText); + return this.getRecentTimelineEntries(count).map((entry) => entry.displayText); } /** - * Get recent subtitle blocks with their original event timings. - * Returns the last `count` subtitle events (oldest → newest). + * Get recent subtitle blocks with their original event timings in timeline order. + * Returns up to `count` known subtitle events ending at the current event. */ getRecentEntries(count: number): SubtitleTimingBlock[] { - if (count <= 0) return []; - if (count > this.history.length) { - count = this.history.length; - } - return this.history.slice(-count).map((entry) => ({ + return this.getRecentTimelineEntries(count).map((entry) => ({ displayText: entry.displayText, startTime: entry.startTime, endTime: entry.endTime, @@ -144,6 +141,43 @@ export class SubtitleTimingTracker { return lastEntry ? lastEntry.displayText : null; } + private getRecentTimelineEntries(count: number): HistoryEntry[] { + if (count <= 0) return []; + + const currentEntry = this.history[this.history.length - 1]; + if (!currentEntry) return []; + + const timelineEntries: HistoryEntry[] = []; + for (const entry of this.history) { + const existingIndex = timelineEntries.findIndex((candidate) => + this.isSameSubtitleEvent(candidate, entry), + ); + if (existingIndex === -1) { + timelineEntries.push(entry); + } else { + timelineEntries[existingIndex] = entry; + } + } + + timelineEntries.sort( + (left, right) => left.startTime - right.startTime || left.endTime - right.endTime, + ); + const currentIndex = timelineEntries.findIndex((entry) => + this.isSameSubtitleEvent(entry, currentEntry), + ); + if (currentIndex === -1) return []; + + return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1); + } + + private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean { + return ( + left.timingKey === right.timingKey && + left.startTime === right.startTime && + left.endTime === right.endTime + ); + } + private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null { let bestMatch: TimingEntry | null = null; let bestScore = 0; From c0a78ef00838c8ea22ea0fc8ccc1365b6fbcd576 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 1 Sep 2026 22:18:30 -0700 Subject: [PATCH 4/6] feat(anki): support Senren scene-switching field grouping (#230) --- changes/senren-field-grouping.md | 5 + config.example.jsonc | 7 +- docs-site/anki-integration.md | 19 +- docs-site/configuration.md | 6 +- docs-site/mining-workflow.md | 12 +- docs-site/public/config.example.jsonc | 7 +- src/anki-integration.test.ts | 1 + src/anki-integration.ts | 43 +++- .../card-creation-manual-update.test.ts | 12 +- .../card-creation-sentence-media.test.ts | 3 +- src/anki-integration/card-creation.test.ts | 24 +- src/anki-integration/card-creation.ts | 6 +- .../field-grouping-merge.test.ts | 234 +++++++++++++++++- src/anki-integration/field-grouping-merge.ts | 155 +++++++++++- .../field-grouping-workflow.test.ts | 2 +- .../field-grouping-workflow.ts | 4 +- src/anki-integration/field-grouping.test.ts | 32 ++- src/anki-integration/field-grouping.ts | 19 +- .../note-update-workflow.test.ts | 14 +- src/anki-integration/note-update-workflow.ts | 12 +- src/anki-integration/runtime.ts | 8 + src/config/config.test.ts | 42 ++++ .../definitions/defaults-integrations.ts | 5 + .../definitions/options-integrations.ts | 22 ++ src/config/definitions/runtime-options.ts | 17 ++ src/config/definitions/template-sections.ts | 2 +- src/config/resolve/anki-connect.ts | 2 + src/config/resolve/anki-connect/initialize.ts | 6 + src/config/resolve/anki-connect/senren.ts | 34 +++ src/config/settings/registry.test.ts | 9 +- src/config/settings/registry.ts | 12 +- src/core/services/config-hot-reload.ts | 1 + .../runtime/config-hot-reload-handlers.ts | 3 + src/renderer/index.html | 4 +- src/shared/ipc/validators.ts | 1 + src/types/anki.ts | 5 + src/types/config.ts | 5 + src/types/runtime-options.ts | 1 + 38 files changed, 695 insertions(+), 101 deletions(-) create mode 100644 changes/senren-field-grouping.md create mode 100644 src/config/resolve/anki-connect/senren.ts diff --git a/changes/senren-field-grouping.md b/changes/senren-field-grouping.md new file mode 100644 index 00000000..6c9646aa --- /dev/null +++ b/changes/senren-field-grouping.md @@ -0,0 +1,5 @@ +type: added +area: anki + +- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries. +- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time). diff --git a/config.example.jsonc b/config.example.jsonc index 1408eab7..b5056fd3 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -523,7 +523,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -606,6 +606,11 @@ "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false }, // Is kiku setting. + "isSenren": { + "enabled": false, // Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled. Values: true | false + "fieldGrouping": "auto", // Senren duplicate-card field grouping mode (scene switching). Values: auto | manual | disabled + "deleteDuplicateInAuto": true // When Senren field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is senren setting. "lapisKiku": { "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none } // Lapis kiku setting. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 59b39946..5375691f 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -308,9 +308,9 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that `click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag. -## Field Grouping (Kiku) +## Field Grouping (Kiku/Senren) -When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields. +When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching). ```jsonc "ankiConnect": { @@ -322,6 +322,18 @@ When you mine the same word multiple times, SubMiner can merge the cards instead } ``` +For Senren note types, enable `isSenren` instead. Kiku and Senren write incompatible markup into the same fields, so only one can be enabled at a time; if both are enabled, Kiku wins and a config warning is emitted. + +```jsonc +"ankiConnect": { + "isSenren": { + "enabled": true, + "fieldGrouping": "auto", // "auto" (default), "manual", or "disabled" + "deleteDuplicateInAuto": true // delete new card after auto-merge + } +} +``` + ### Modes **Disabled** (`"disabled"`): No duplicate detection. Each card is independent. @@ -337,9 +349,12 @@ When you mine the same word multiple times, SubMiner can merge the cards instead | Sentence | Both cards' sentences kept as grouped entries | | Audio | Both cards' `[sound:...]` entries kept | | Image | Both cards' images kept | +| MiscInfo | Both cards' source info kept as grouped entries | Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate. +The merge markup depends on the note type. Kiku entries are wrapped in `` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries use `group` spans when ordinal order is sufficient and numbered `groupN` spans when they need an absolute scene target. Audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and source `groupN` spans are rebased after the kept note's existing audio scenes. + ### Keyboard Shortcuts in the Modal | Key | Action | diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 6726c5b3..6abfb557 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -148,9 +148,9 @@ The configuration file includes several main sections: - [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing - [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media -- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types +- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis/Senren note types - [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting -- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging +- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Senren duplicate card merging **External Integrations** @@ -1051,6 +1051,7 @@ This example is intentionally compact. The option table below documents availabl | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `
`=newline | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | +| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. | `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. @@ -1080,6 +1081,7 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La - Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits. - When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`. - `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes). +- For [Senren](https://github.com/BrenoAqua/Senren) note types, enable `isSenren` instead of `isKiku`. Duplicate merges then use Senren's scene-switching markup (including grouped `miscInfo` entries), and `isSenren.fieldGrouping` supports the same three modes (default: `auto`). Kiku and Senren are mutually exclusive; if both are enabled, Kiku wins and Senren is turned off with a config warning. - `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled. ### Word Card Type diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 20470598..79d1b332 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -72,17 +72,17 @@ After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+ Audio card marking uses the same `ankiConnect.isLapis.sentenceCardModel` note type as sentence cards. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup. ::: -### Field Grouping (Kiku) +### Field Grouping (Kiku/Senren) -If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields. +If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching). 1. You add a word via Yomitan. 2. SubMiner detects the new card and checks if a card with the same expression already exists. -3. If a duplicate is found (this requires `ankiConnect.isKiku.fieldGrouping` to be set to `"auto"` or `"manual"`; it defaults to `"disabled"`): - - **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted. - - **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. +3. If a duplicate is found (this requires Kiku or Senren to be enabled with a field grouping mode of `"auto"` or `"manual"`): + - **Auto mode**: Merges automatically. Both sentences, audio clips, images, and source info are combined into the existing card. The duplicate is optionally deleted. + - **Manual mode**: A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. -See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts. +See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku-senren) for configuration options, merge behavior, and modal keyboard shortcuts. ## Overlay Model diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 1408eab7..b5056fd3 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -523,7 +523,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -606,6 +606,11 @@ "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false }, // Is kiku setting. + "isSenren": { + "enabled": false, // Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled. Values: true | false + "fieldGrouping": "auto", // Senren duplicate-card field grouping mode (scene switching). Values: auto | manual | disabled + "deleteDuplicateInAuto": true // When Senren field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is senren setting. "lapisKiku": { "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none } // Lapis kiku setting. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 2971fb37..1be74119 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -155,6 +155,7 @@ function createFieldGroupingMergeCollaborator(options?: { getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', + fieldGroupingProvider: 'kiku' as const, }), getCurrentSubtitleText: () => options?.currentSubtitleText, resolveFieldName, diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 9bd83484..c8db8773 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -835,6 +835,19 @@ export class AnkiIntegration { }; } + private getSenrenConfig(): { + enabled: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + } { + const senren = this.config.isSenren; + return { + enabled: senren?.enabled === true, + fieldGrouping: senren?.fieldGrouping, + deleteDuplicateInAuto: senren?.deleteDuplicateInAuto, + }; + } + private getEffectiveSentenceCardConfig(): { model?: string; sentenceField: string; @@ -843,10 +856,27 @@ export class AnkiIntegration { kikuEnabled: boolean; kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuDeleteDuplicateInAuto: boolean; + senrenEnabled: boolean; + fieldGroupingProvider: 'kiku' | 'senren' | null; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; + fieldGroupingDeleteDuplicateInAuto: boolean; wordCardKind: WordCardKind; } { const lapis = this.getLapisConfig(); const kiku = this.getKikuConfig(); + const senren = this.getSenrenConfig(); + + const kikuFieldGrouping = (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled'; + const senrenFieldGrouping = (senren.fieldGrouping || 'auto') as 'auto' | 'manual' | 'disabled'; + // Kiku and Senren are mutually exclusive; config resolution enforces it, and + // Kiku wins here too in case a runtime patch re-enables both. + const fieldGroupingProvider = kiku.enabled ? 'kiku' : senren.enabled ? 'senren' : null; + const fieldGroupingMode = + fieldGroupingProvider === 'kiku' + ? kikuFieldGrouping + : fieldGroupingProvider === 'senren' + ? senrenFieldGrouping + : 'disabled'; return { model: lapis.sentenceCardModel, @@ -854,8 +884,15 @@ export class AnkiIntegration { audioField: 'SentenceAudio', lapisEnabled: lapis.enabled, kikuEnabled: kiku.enabled, - kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled', + kikuFieldGrouping, kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, + senrenEnabled: senren.enabled, + fieldGroupingProvider, + fieldGroupingMode, + fieldGroupingDeleteDuplicateInAuto: + fieldGroupingProvider === 'senren' + ? senren.deleteDuplicateInAuto !== false + : kiku.deleteDuplicateInAuto !== false, wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind), }; } @@ -874,7 +911,7 @@ export class AnkiIntegration { private async processNewCard( noteId: number, - options?: { skipKikuFieldGrouping?: boolean }, + options?: { skipFieldGrouping?: boolean }, ): Promise { await this.noteUpdateWorkflow.execute(noteId, options); } @@ -1504,7 +1541,7 @@ export class AnkiIntegration { trackedDuplicateNoteIdsBeforeCreate: Set, ): boolean { const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); - if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') { + if (sentenceCardConfig.fieldGroupingMode === 'disabled') { return false; } diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index ef572b1c..0c1c5fd4 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -124,8 +124,7 @@ function createManualUpdateService(overrides: Partial = {}): { audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -208,8 +207,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki audioField: 'SentenceAudio', lapisEnabled: true, kikuEnabled: true, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), }); @@ -275,8 +273,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => { audioField: 'SentenceAudio', lapisEnabled: true, kikuEnabled: true, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), }); @@ -338,8 +335,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), setCardTypeFields, }); diff --git a/src/anki-integration/card-creation-sentence-media.test.ts b/src/anki-integration/card-creation-sentence-media.test.ts index d8bb27cc..b8c4604c 100644 --- a/src/anki-integration/card-creation-sentence-media.test.ts +++ b/src/anki-integration/card-creation-sentence-media.test.ts @@ -117,8 +117,7 @@ test('sentence card writes generated audio only to sentence audio field', async audioField: 'SentenceAudio', lapisEnabled: true, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index 19159887..67857d03 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -69,8 +69,7 @@ test('CardCreationService counts locally created sentence cards', async () => { audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -168,8 +167,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -267,8 +265,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws', audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -387,8 +384,7 @@ test('CardCreationService uses stream-open-filename for remote media generation' audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -490,8 +486,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -629,8 +624,7 @@ test('CardCreationService queues YouTube media when required cache is not ready' audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -728,8 +722,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'manual', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -817,8 +810,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'manual', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index e7e58446..50eb8e58 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -132,8 +132,7 @@ interface CardCreationDeps { audioField: string; lapisEnabled: boolean; kikuEnabled: boolean; - kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; - kikuDeleteDuplicateInAuto: boolean; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; wordCardKind?: WordCardKind; }; getFallbackDurationSeconds: () => number; @@ -638,8 +637,7 @@ export class CardCreationService { ).trim(); let duplicateNoteIds: number[] = []; if ( - sentenceCardConfig.kikuEnabled && - sentenceCardConfig.kikuFieldGrouping !== 'disabled' && + sentenceCardConfig.fieldGroupingMode !== 'disabled' && pendingExpressionText && this.deps.findDuplicateNoteIds ) { diff --git a/src/anki-integration/field-grouping-merge.test.ts b/src/anki-integration/field-grouping-merge.test.ts index 1a1494e3..92c04146 100644 --- a/src/anki-integration/field-grouping-merge.test.ts +++ b/src/anki-integration/field-grouping-merge.test.ts @@ -26,6 +26,7 @@ function createCollaborator( miscInfoValue?: string; }; warnings?: Array<{ fieldName: string; reason: string; detail?: string }>; + fieldGroupingProvider?: 'kiku' | 'senren' | null; } = {}, ) { const warnings = options.warnings ?? []; @@ -46,6 +47,8 @@ function createCollaborator( getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', + fieldGroupingProvider: + options.fieldGroupingProvider === undefined ? 'kiku' : options.fieldGroupingProvider, }), getCurrentSubtitleText: () => options.currentSubtitleText, resolveFieldName, @@ -251,7 +254,218 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen assert.equal(withMedia.MiscInfo, 'generated misc'); }); -test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => { +test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + word: '語', + sentence: '', + sentenceAudio: '[sound:original.opus]', + picture: '', + miscInfo: 'Show EP1 (0:01:00)', + }), + makeNote(200, { + word: '語', + sentence: '', + sentenceAudio: '[sound:new.opus]', + picture: '', + miscInfo: 'Show EP2 (0:02:00)', + }), + false, + ); + + assert.equal( + merged.sentence, + '' + + '', + ); + assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]'); + assert.equal(merged.picture, ''); + assert.equal( + merged.miscInfo, + 'Show EP1 (0:01:00)Show EP2 (0:02:00)', + ); +}); + +test('Senren merge warns for invalid source audio when kept audio is empty', async () => { + const warnings: Array<{ fieldName: string; reason: string; detail?: string }> = []; + const { collaborator } = createCollaborator({ + fieldGroupingProvider: 'senren', + warnings, + }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { SentenceAudio: '' }), + makeNote(200, { SentenceAudio: 'invalid audio' }), + false, + ); + + assert.equal(merged.SentenceAudio, 'invalid audio'); + assert.deepEqual(warnings, [ + { + fieldName: 'SentenceAudio', + reason: 'missing-sound-tag', + detail: undefined, + }, + ]); +}); + +test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + sentence: 'plain legacy sentence', + sentenceAudio: '[sound:a.opus][sound:b.opus]', + miscInfo: 'pinned stray text', + }), + makeNote(200, { + sentence: 'new sentence', + sentenceAudio: '[sound:c.opus]', + miscInfo: 'new misc', + }), + false, + ); + + assert.equal( + merged.sentence, + 'plain legacy sentencenew sentence', + ); + assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]'); + assert.equal( + merged.miscInfo, + 'pinnedstray text' + + 'new misc', + ); +}); + +test('Senren merge rebases numbered groups from an appended source note', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]', + miscInfo: 'keep onekeep two', + }), + makeNote(200, { + sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]', + miscInfo: 'source two', + }), + false, + ); + + assert.equal( + merged.sentenceAudio, + '[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]', + ); + assert.equal( + merged.miscInfo, + 'keep onekeep two' + + 'source two', + ); +}); + +test('Senren merge rebases plain source groups after empty and sparse kept fields', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]', + sentence: '', + miscInfo: 'keep first', + }), + makeNote(200, { + sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]', + sentence: 'source first', + miscInfo: 'source firstsource second', + }), + false, + ); + + assert.equal(merged.sentence, 'source first'); + assert.equal( + merged.miscInfo, + 'keep firstsource first' + + 'source second', + ); +}); + +test('Senren merge keeps ungrouped text in place around an existing group span', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + miscInfo: 'leadingmiddletrailing', + sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]', + }), + makeNote(200, { + miscInfo: 'appended', + sentenceAudio: '[sound:d.opus]', + }), + false, + ); + + // Order must follow the source field, and the two ungrouped runs must stay separate. + assert.equal( + merged.miscInfo, + 'leadingmiddle' + + 'trailingappended', + ); + assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]'); +}); + +test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { miscInfo: 'ab' }), + makeNote(200, { miscInfo: 'next' }), + false, + ); + + assert.equal( + merged.miscInfo, + 'abnext', + ); + const openTags = merged.miscInfo!.match(//g)?.length ?? 0; + assert.equal(openTags, closeTags); +}); + +test('Senren merge closes unclosed trailing markup before appending later scenes', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { miscInfo: 'leadingtail' }), + makeNote(200, { miscInfo: 'next' }), + false, + ); + + assert.equal( + merged.miscInfo, + 'leadingtail' + + 'next', + ); +}); + +test('Kiku merge clears SentenceFurigana when either note lacks it', async () => { const { collaborator } = createCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( @@ -268,3 +482,21 @@ test('computeFieldGroupingMergedFields clears SentenceFurigana when either note assert.equal(merged.SentenceFurigana, ''); }); + +test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + SentenceFurigana: '', + }), + makeNote(200, { + SentenceFurigana: 'duplicate furigana', + }), + false, + ); + + assert.equal(merged.SentenceFurigana, 'duplicate furigana'); +}); diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index d7def9ef..00deef62 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -19,6 +19,7 @@ interface FieldGroupingMergeDeps { getEffectiveSentenceCardConfig: () => { sentenceField: string; audioField: string; + fieldGroupingProvider: 'kiku' | 'senren' | null; }; getCurrentSubtitleText: () => string | undefined; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; @@ -78,6 +79,13 @@ export class FieldGroupingMergeCollaborator { const configuredWordField = getConfiguredWordFieldName(config); const groupableFields = this.getGroupableFieldNames(); const keepFieldNames = Object.keys(keepNoteInfo.fields); + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const senrenSourceSceneOffset = + sentenceCardConfig.fieldGroupingProvider === 'senren' + ? this.countSenrenAudioScenes( + this.getResolvedFieldValue(keepNoteInfo, sentenceCardConfig.audioField), + ) + : 0; const sourceFields: Record = {}; const resolvedKeepFieldByPreferred = new Map(); for (const preferredFieldName of groupableFields) { @@ -154,14 +162,18 @@ export class FieldGroupingMergeCollaborator { if (!existingValue.trim() && !newValue.trim()) continue; if (keepFieldNormalized === 'sentencefurigana') { + const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0; + const usesSenrenGrouping = + this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren'; mergedFields[keepFieldName] = - existingValue.trim() && newValue.trim() + hasBothValues || usesSenrenGrouping ? this.applyFieldGrouping( existingValue, newValue, keepNoteId, deleteNoteId, keepFieldName, + senrenSourceSceneOffset, ) : ''; continue; @@ -174,6 +186,7 @@ export class FieldGroupingMergeCollaborator { keepNoteId, deleteNoteId, keepFieldName, + senrenSourceSceneOffset, ); } else if (existingValue.trim() && newValue.trim()) { mergedFields[keepFieldName] = this.applyFieldGrouping( @@ -182,6 +195,7 @@ export class FieldGroupingMergeCollaborator { keepNoteId, deleteNoteId, keepFieldName, + senrenSourceSceneOffset, ); } else { if (!newValue.trim()) continue; @@ -342,13 +356,152 @@ export class FieldGroupingMergeCollaborator { return [...entries].sort((a, b) => b.groupId - a.groupId); } + private isSentenceAudioField(fieldName: string): boolean { + const normalized = fieldName.toLowerCase(); + const audioField = ( + this.deps.getEffectiveSentenceCardConfig().audioField || 'sentenceaudio' + ).toLowerCase(); + return normalized === 'sentenceaudio' || normalized === audioField; + } + + private isSenrenGroupOpenTag(openTag: string): boolean { + const classMatch = + openTag.match(/class\s*=\s*"([^"]*)"/i) || openTag.match(/class\s*=\s*'([^']*)'/i); + if (!classMatch) return false; + // Senren's templates match class tokens case-sensitively (/^group\d*$/). + return classMatch[1]!.split(/\s+/).some((token) => /^group\d*$/.test(token)); + } + + private countSenrenAudioScenes(value: string): number { + const soundEntries = value.match(/\[sound:[^\]]+\]/g)?.length ?? 0; + if (soundEntries > 0) return soundEntries; + return this.parseSenrenSceneEntries(value).length; + } + + private rebaseSenrenGroup(entry: string, sceneOffset: number, sourceEntryIndex: number): string { + if (sceneOffset <= 0) return entry; + + return entry.replace( + /^(\s*]*?\bclass\s*=\s*)(["'])([^"']*)\2/i, + (_match: string, prefix: string, quote: string, rawClasses: string) => { + const classes = rawClasses + .split(/(\s+)/) + .map((classToken) => { + if (classToken === 'group') { + return `group${sceneOffset + sourceEntryIndex + 1}`; + } + const groupMatch = classToken.match(/^group(\d+)$/); + if (!groupMatch) return classToken; + const targetScene = Number(groupMatch[1]); + if (!Number.isSafeInteger(targetScene) || targetScene <= 0) return classToken; + return `group${targetScene + sceneOffset}`; + }) + .join(''); + return `${prefix}${quote}${classes}${quote}`; + }, + ); + } + + /** + * Splits a Senren field into ordered scene entries. Top-level + * ``/`"groupN"` spans are kept verbatim (nested markup like + * `` included); ungrouped runs are wrapped in a group + * span at their original position, because Senren discards anything outside a + * group span once scene switching activates. + */ + private parseSenrenSceneEntries(value: string): string[] { + const tokenRegex = /]*>|<\/span>/gi; + const entries: string[] = []; + const pushUngrouped = (raw: string): void => { + const text = raw.replace(//gi, ' ').trim(); + if (text) entries.push(`${text}`); + }; + let cursor = 0; + let depth = 0; + let entryStart = -1; + let match; + while ((match = tokenRegex.exec(value)) !== null) { + const token = match[0]!; + if (token[1] !== '/') { + if (depth === 0 && this.isSenrenGroupOpenTag(token)) { + pushUngrouped(value.slice(cursor, match.index)); + entryStart = match.index; + cursor = match.index; + } + depth += 1; + } else { + depth = Math.max(0, depth - 1); + if (depth === 0 && entryStart !== -1) { + const end = match.index + token.length; + entries.push(value.slice(entryStart, end)); + entryStart = -1; + cursor = end; + } + } + } + if (entryStart !== -1) { + // Unclosed group span: close every span still open (the group and any nested + // markup) so the following scenes are siblings rather than nested inside it. + entries.push(`${value.slice(entryStart)}${''.repeat(depth)}`); + } else { + pushUngrouped(`${value.slice(cursor)}${''.repeat(depth)}`); + } + return entries; + } + + /** + * Merges two notes' field values in Senren's scene-switching format. Scenes are + * appended in order (existing first, never resorted) so indices stay aligned + * across sentence/picture/miscInfo with the sentenceAudio entries, which alone + * drive Senren's scene count. + */ + private applySenrenFieldGrouping( + existingValue: string, + newValue: string, + fieldName: string, + sourceSceneOffset: number, + ): string { + if (this.isPictureField(fieldName)) { + const tags = [...this.extractImageTags(existingValue), ...this.extractImageTags(newValue)]; + if (tags.length === 0) return existingValue || newValue; + return tags.join(''); + } + + if (this.isSentenceAudioField(fieldName)) { + const existing = existingValue.trim(); + const added = newValue.trim(); + if (added && !/\[sound:[^\]]+\]/.test(added)) { + this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); + } + if (!existing || !added) return existing || added; + return existing + added; + } + + const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry, sourceEntryIndex) => + this.rebaseSenrenGroup(entry, sourceSceneOffset, sourceEntryIndex), + ); + const merged = [...this.parseSenrenSceneEntries(existingValue), ...sourceEntries]; + if (merged.length === 0) return existingValue || newValue; + return merged.join(''); + } + private applyFieldGrouping( existingValue: string, newValue: string, keepGroupId: number, sourceGroupId: number, fieldName: string, + senrenSourceSceneOffset: number, ): string { + if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') { + return this.applySenrenFieldGrouping( + existingValue, + newValue, + fieldName, + senrenSourceSceneOffset, + ); + } + if (this.shouldUseStrictSpanGrouping(fieldName)) { if (this.isPictureField(fieldName)) { const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index 71306374..a01f209c 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -71,7 +71,7 @@ function createWorkflowHarness() { getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', - kikuDeleteDuplicateInAuto: true, + fieldGroupingDeleteDuplicateInAuto: true, }), getCurrentSubtitleText: () => 'subtitle-text', getFieldGroupingCallback: (): FieldGroupingCallback | null => { diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 6c3854f1..a0dfb669 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -24,7 +24,7 @@ export interface FieldGroupingWorkflowDeps { getEffectiveSentenceCardConfig: () => { sentenceField: string; audioField: string; - kikuDeleteDuplicateInAuto: boolean; + fieldGroupingDeleteDuplicateInAuto: boolean; }; getCurrentSubtitleText: () => string | undefined; getFieldGroupingCallback: @@ -75,7 +75,7 @@ export class FieldGroupingWorkflow { originalNoteId, newNoteId, this.getExpression(newNoteInfo), - sentenceCardConfig.kikuDeleteDuplicateInAuto, + sentenceCardConfig.fieldGroupingDeleteDuplicateInAuto, ); } catch (error) { this.deps.logError('Field grouping auto merge failed:', (error as Error).message); diff --git a/src/anki-integration/field-grouping.test.ts b/src/anki-integration/field-grouping.test.ts index 5d44a600..2aa770ad 100644 --- a/src/anki-integration/field-grouping.test.ts +++ b/src/anki-integration/field-grouping.test.ts @@ -21,14 +21,14 @@ function createHarness( manualHandled?: boolean; expression?: string | null; currentSentenceImageField?: string | undefined; - onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void; + onProcessNewCard?: (noteId: number, options?: { skipFieldGrouping?: boolean }) => void; } = {}, ) { const calls: string[] = []; const findNotesQueries: Array<{ query: string; maxRetries?: number }> = []; const noteInfoRequests: number[][] = []; const duplicateRequests: Array<{ expression: string; excludeNoteId: number }> = []; - const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = []; + const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = []; const autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; @@ -46,9 +46,8 @@ function createHarness( sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: options.kikuEnabled ?? true, - kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: (options.kikuEnabled ?? true) ? ('kiku' as const) : null, + fieldGroupingMode: options.kikuFieldGrouping ?? 'auto', }), isUpdateInProgress: () => false, getDeck: options.deck ? () => options.deck : undefined, @@ -134,7 +133,7 @@ test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', as await harness.service.triggerFieldGroupingForLastAddedCard(); - assert.deepEqual(harness.calls, ['osd:Kiku mode is not enabled']); + assert.deepEqual(harness.calls, ['osd:Field grouping requires Kiku or Senren mode']); assert.equal(harness.findNotesQueries.length, 0); }); @@ -143,7 +142,7 @@ test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled await harness.service.triggerFieldGroupingForLastAddedCard(); - assert.deepEqual(harness.calls, ['osd:Kiku field grouping is disabled']); + assert.deepEqual(harness.calls, ['osd:Field grouping is disabled']); assert.equal(harness.findNotesQueries.length, 0); }); @@ -155,9 +154,8 @@ test('triggerFieldGroupingForLastAddedCard stops when an update is already in pr sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: true, - kikuFieldGrouping: 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: 'kiku' as const, + fieldGroupingMode: 'auto' as const, }), isUpdateInProgress: () => true, withUpdateProgress: async () => { @@ -266,7 +264,7 @@ test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids be }); test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => { - const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = []; + const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = []; const harness = createHarness({ noteIds: [11], notesInfo: [ @@ -298,7 +296,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi await harness.service.triggerFieldGroupingForLastAddedCard(); - assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]); + assert.deepEqual(processCalls, [{ noteId: 11, options: { skipFieldGrouping: true } }]); assert.deepEqual(harness.manualCalls, []); }); @@ -352,9 +350,8 @@ test('buildFieldGroupingPreview returns merged compact and full previews', async sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: true, - kikuFieldGrouping: 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: 'kiku' as const, + fieldGroupingMode: 'auto' as const, }), isUpdateInProgress: () => false, withUpdateProgress: async (_message, action) => action(), @@ -417,9 +414,8 @@ test('buildFieldGroupingPreview reports missing notes cleanly', async () => { sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: true, - kikuFieldGrouping: 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: 'kiku' as const, + fieldGroupingMode: 'auto' as const, }), isUpdateInProgress: () => false, withUpdateProgress: async (_message, action) => action(), diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts index b6acb57b..ba491aa2 100644 --- a/src/anki-integration/field-grouping.ts +++ b/src/anki-integration/field-grouping.ts @@ -20,9 +20,8 @@ interface FieldGroupingDeps { sentenceField: string; audioField: string; lapisEnabled: boolean; - kikuEnabled: boolean; - kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; - kikuDeleteDuplicateInAuto: boolean; + fieldGroupingProvider: 'kiku' | 'senren' | null; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; }; isUpdateInProgress: () => boolean; getDeck?: () => string | undefined; @@ -46,7 +45,7 @@ interface FieldGroupingDeps { noteInfo: FieldGroupingNoteInfo, configuredFieldNames: (string | undefined)[], ) => boolean; - processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise; + processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise; getSentenceCardImageFieldName: () => string | undefined; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; computeFieldGroupingMergedFields: ( @@ -76,12 +75,12 @@ export class FieldGroupingService { async triggerFieldGroupingForLastAddedCard(): Promise { const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); - if (!sentenceCardConfig.kikuEnabled) { - this.deps.showOsdNotification('Kiku mode is not enabled'); + if (sentenceCardConfig.fieldGroupingProvider === null) { + this.deps.showOsdNotification('Field grouping requires Kiku or Senren mode'); return; } - if (sentenceCardConfig.kikuFieldGrouping === 'disabled') { - this.deps.showOsdNotification('Kiku field grouping is disabled'); + if (sentenceCardConfig.fieldGroupingMode === 'disabled') { + this.deps.showOsdNotification('Field grouping is disabled'); return; } @@ -134,7 +133,7 @@ export class FieldGroupingService { ]) ) { await this.deps.processNewCard(noteId, { - skipKikuFieldGrouping: true, + skipFieldGrouping: true, }); } @@ -147,7 +146,7 @@ export class FieldGroupingService { const noteInfo = refreshedInfo[0]!; - if (sentenceCardConfig.kikuFieldGrouping === 'auto') { + if (sentenceCardConfig.fieldGroupingMode === 'auto') { await this.deps.handleFieldGroupingAuto( duplicateNoteId, noteId, diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 232a7ee0..be772765 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -58,7 +58,7 @@ function createWorkflowHarness() { sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled' as const, + fieldGroupingMode: 'disabled' as const, }), appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined, extractFields: (fields: Record) => { @@ -136,7 +136,7 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La sentenceField: 'Sentence', lapisEnabled: true, kikuEnabled: true, - kikuFieldGrouping: 'disabled', + fieldGroupingMode: 'disabled', }); harness.deps.client.notesInfo = async () => [ @@ -193,7 +193,7 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', + fieldGroupingMode: 'manual', }); harness.deps.client.notesInfo = async () => [ @@ -226,7 +226,7 @@ test('NoteUpdateWorkflow marks the configured word card kind instead of word-and sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', + fieldGroupingMode: 'manual', wordCardKind: 'click', }); harness.deps.client.notesInfo = async () => @@ -262,7 +262,7 @@ test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', + fieldGroupingMode: 'manual', wordCardKind: 'none', }); harness.deps.client.notesInfo = async () => @@ -317,7 +317,7 @@ test('NoteUpdateWorkflow preserves explicit sentence card type during sentence e sentenceField: 'Sentence', lapisEnabled: true, kikuEnabled: false, - kikuFieldGrouping: 'disabled', + fieldGroupingMode: 'disabled', }); harness.deps.client.notesInfo = async () => [ @@ -360,7 +360,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async ( sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'auto', + fieldGroupingMode: 'auto', }); harness.deps.findDuplicateNote = async () => 99; harness.deps.client.notesInfo = async () => { diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index adb8d354..27f07c16 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -40,7 +40,7 @@ export interface NoteUpdateWorkflowDeps { sentenceField: string; lapisEnabled: boolean; kikuEnabled: boolean; - kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; wordCardKind?: WordCardKind; }; appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void; @@ -160,7 +160,7 @@ export class NoteUpdateWorkflow { return null; } - async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise { + async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise { this.deps.beginUpdateProgress('Updating card'); try { const notesInfoResult = await this.deps.client.notesInfo([noteId]); @@ -187,9 +187,7 @@ export class NoteUpdateWorkflow { const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const shouldRunFieldGrouping = - !options?.skipKikuFieldGrouping && - sentenceCardConfig.kikuEnabled && - sentenceCardConfig.kikuFieldGrouping !== 'disabled'; + !options?.skipFieldGrouping && sentenceCardConfig.fieldGroupingMode !== 'disabled'; let duplicateNoteId: number | null = null; if (shouldRunFieldGrouping && hasExpressionText) { duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); @@ -350,7 +348,7 @@ export class NoteUpdateWorkflow { noteInfoForGrouping = refreshedInfo[0]!; } - if (sentenceCardConfig.kikuFieldGrouping === 'auto') { + if (sentenceCardConfig.fieldGroupingMode === 'auto') { await this.deps.handleFieldGroupingAuto( duplicateNoteId, noteId, @@ -359,7 +357,7 @@ export class NoteUpdateWorkflow { ); return; } - if (sentenceCardConfig.kikuFieldGrouping === 'manual') { + if (sentenceCardConfig.fieldGroupingMode === 'manual') { await this.deps.handleFieldGroupingManual( duplicateNoteId, noteId, diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index 83be62e8..d00d9b48 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, ...(config.isKiku ?? {}), }, + isSenren: { + ...DEFAULT_ANKI_CONNECT_CONFIG.isSenren, + ...(config.isSenren ?? {}), + }, lapisKiku: { ...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku, ...(config.lapisKiku ?? {}), @@ -209,6 +213,10 @@ export class AnkiIntegrationRuntime { patch.isKiku !== undefined ? { ...this.config.isKiku, ...patch.isKiku } : this.config.isKiku, + isSenren: + patch.isSenren !== undefined + ? { ...this.config.isSenren, ...patch.isSenren } + : this.config.isSenren, lapisKiku: patch.lapisKiku !== undefined ? { ...this.config.lapisKiku, ...patch.lapisKiku } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 423cef29..8620984a 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2188,6 +2188,7 @@ test('runtime options registry is centralized', () => { 'subtitle.annotation.frequency', 'anki.nPlusOneMatchMode', 'anki.kikuFieldGrouping', + 'anki.senrenFieldGrouping', ]); }); @@ -2775,6 +2776,47 @@ test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => { ); }); +test('forces Senren off when Kiku is also enabled and validates Senren fieldGrouping', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "isKiku": { "enabled": true }, + "isSenren": { "enabled": true } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + assert.equal(service.getConfig().ankiConnect.isKiku.enabled, true); + assert.equal(service.getConfig().ankiConnect.isSenren.enabled, false); + assert.ok( + service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'), + ); + + const senrenOnlyDir = makeTempDir(); + fs.writeFileSync( + path.join(senrenOnlyDir, 'config.jsonc'), + `{ + "ankiConnect": { + "isSenren": { "enabled": true, "fieldGrouping": "sometimes" } + } + }`, + 'utf-8', + ); + + const senrenOnlyService = new ConfigService(senrenOnlyDir); + assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.enabled, true); + assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.fieldGrouping, 'auto'); + assert.ok( + senrenOnlyService + .getWarnings() + .some((warning) => warning.path === 'ankiConnect.isSenren.fieldGrouping'), + ); +}); + test('accepts valid ankiConnect knownWords deck object', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 788564be..ab8f6e67 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -91,6 +91,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< fieldGrouping: 'disabled', deleteDuplicateInAuto: true, }, + isSenren: { + enabled: false, + fieldGrouping: 'auto', + deleteDuplicateInAuto: true, + }, lapisKiku: { wordCardKind: 'word-and-sentence', }, diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 9f8c3e32..c8d883eb 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -363,6 +363,28 @@ export function buildIntegrationConfigOptionRegistry( description: 'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.', }, + { + path: 'ankiConnect.isSenren.fieldGrouping', + kind: 'enum', + enumValues: ['auto', 'manual', 'disabled'], + defaultValue: defaultConfig.ankiConnect.isSenren.fieldGrouping, + description: 'Senren duplicate-card field grouping mode (scene switching).', + runtime: runtimeOptionById.get('anki.senrenFieldGrouping'), + }, + { + path: 'ankiConnect.isSenren.enabled', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isSenren.enabled, + description: + 'Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled.', + }, + { + path: 'ankiConnect.isSenren.deleteDuplicateInAuto', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isSenren.deleteDuplicateInAuto, + description: + 'When Senren field grouping is "auto", delete the duplicate source card after grouping completes.', + }, { path: 'ankiConnect.isLapis.enabled', kind: 'boolean', diff --git a/src/config/definitions/runtime-options.ts b/src/config/definitions/runtime-options.ts index 12a6ceb6..06335930 100644 --- a/src/config/definitions/runtime-options.ts +++ b/src/config/definitions/runtime-options.ts @@ -124,5 +124,22 @@ export function buildRuntimeOptionRegistry( }, }), }, + { + id: 'anki.senrenFieldGrouping', + path: 'ankiConnect.isSenren.fieldGrouping', + label: 'Senren Field Grouping', + scope: 'ankiConnect', + valueType: 'enum', + allowedValues: ['auto', 'manual', 'disabled'], + defaultValue: 'auto', + requiresRestart: false, + formatValueForOsd: (value) => String(value), + toAnkiPatch: (value) => ({ + isSenren: { + fieldGrouping: + value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'auto', + }, + }), + }, ]; } diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 43b22c60..405e8d8b 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'AnkiConnect Integration', description: ['Automatic Anki updates and media generation options.'], notes: [ - 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.', + 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.', 'Most other AnkiConnect settings still require restart.', ], diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 8ec0da31..4e216344 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -1,6 +1,7 @@ import type { ResolveContext } from './context'; import { initializeAnkiConnectResolution } from './anki-connect/initialize'; import { applyAnkiKikuResolution } from './anki-connect/kiku'; +import { applyAnkiSenrenResolution } from './anki-connect/senren'; import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku'; import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import { applyAnkiLegacyResolution } from './anki-connect/legacy'; @@ -23,5 +24,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiKnownWordsResolution(context, ankiConnect, behavior); applyAnkiKikuResolution(context); + applyAnkiSenrenResolution(context); applyAnkiLapisKikuResolution(context, ankiConnect); } diff --git a/src/config/resolve/anki-connect/initialize.ts b/src/config/resolve/anki-connect/initialize.ts index b7571b7f..2d1a8a2d 100644 --- a/src/config/resolve/anki-connect/initialize.ts +++ b/src/config/resolve/anki-connect/initialize.ts @@ -77,6 +77,12 @@ export function initializeAnkiConnectResolution( ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) : {}), }, + isSenren: { + ...context.resolved.ankiConnect.isSenren, + ...(isObject(ankiConnect.isSenren) + ? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren']) + : {}), + }, lapisKiku: { ...context.resolved.ankiConnect.lapisKiku, }, diff --git a/src/config/resolve/anki-connect/senren.ts b/src/config/resolve/anki-connect/senren.ts new file mode 100644 index 00000000..950f1c58 --- /dev/null +++ b/src/config/resolve/anki-connect/senren.ts @@ -0,0 +1,34 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; + +export function applyAnkiSenrenResolution(context: ResolveContext): void { + if ( + context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' && + context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' && + context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled' + ) { + context.warn( + 'ankiConnect.isSenren.fieldGrouping', + context.resolved.ankiConnect.isSenren.fieldGrouping, + DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping, + 'Expected auto, manual, or disabled.', + ); + context.resolved.ankiConnect.isSenren.fieldGrouping = + DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping; + } + + // Kiku and Senren field grouping write incompatible markup into the same note + // fields, so only one may be active; Kiku wins to preserve pre-existing setups. + if ( + context.resolved.ankiConnect.isSenren.enabled === true && + context.resolved.ankiConnect.isKiku.enabled === true + ) { + context.warn( + 'ankiConnect.isSenren.enabled', + true, + false, + 'Kiku and Senren are mutually exclusive; disable isKiku.enabled to use Senren field grouping.', + ); + context.resolved.ankiConnect.isSenren.enabled = false; + } +} diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index f965e8e3..bc8d70f0 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -298,10 +298,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti ]; assert.equal(miningSections[0], 'AnkiConnect'); - const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features'); + const kikuLapis = fields.filter( + (candidate) => candidate.section === 'Kiku/Lapis/Senren Features', + ); assert.deepEqual( - kikuLapis.slice(0, 2).map((candidate) => candidate.configPath), - ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'], + kikuLapis.slice(0, 3).map((candidate) => candidate.configPath), + ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'], ); }); @@ -366,6 +368,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => { 'ankiConnect.fields.miscInfo', 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.fieldGrouping', ]) { assert.equal(field(path).restartBehavior, 'hot-reload', path); } diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 9230da0c..2e5b2740 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -131,7 +131,7 @@ const SECTION_ORDER = new Map( 'AnkiConnect', 'Note Fields', 'Media Capture', - 'Kiku/Lapis Features', + 'Kiku/Lapis/Senren Features', 'Anki AI', 'AnkiConnect Proxy', 'Jimaku', @@ -163,6 +163,7 @@ const PATH_ORDER = new Map( 'ankiConnect.proxy.enabled', 'ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', + 'ankiConnect.isSenren.enabled', 'subtitleStyle.knownWordColor', 'ankiConnect.knownWords.matureThresholdDays', 'subtitleStyle.knownWordMaturityColors.new', @@ -221,6 +222,7 @@ const LABEL_OVERRIDES: Record = { 'ankiConnect.nPlusOne.enabled': 'Enabled', 'ankiConnect.isLapis.enabled': 'Enable Lapis Features', 'ankiConnect.isKiku.enabled': 'Enable Kiku Features', + 'ankiConnect.isSenren.enabled': 'Enable Senren Features', 'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type', 'stats.toggleKey': 'Toggle Stats Overlay', 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', @@ -251,7 +253,9 @@ const DESCRIPTION_OVERRIDES: Record = { 'ankiConnect.pollingRate': 'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.', 'ankiConnect.isKiku.enabled': - 'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.', + 'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping. Mutually exclusive with Senren.', + 'ankiConnect.isSenren.enabled': + 'Enable Senren-specific duplicate handling: field grouping merges duplicates into Senren scene-switching markup (including miscInfo grouping). Mutually exclusive with Kiku; only one can be enabled at a time.', 'ankiConnect.isLapis.enabled': 'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.', 'ankiConnect.isLapis.sentenceCardModel': @@ -407,9 +411,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if ( path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.') || + path.startsWith('ankiConnect.isSenren.') || path.startsWith('ankiConnect.lapisKiku.') ) { - return { category: 'mining-anki', section: 'Kiku/Lapis Features' }; + return { category: 'mining-anki', section: 'Kiku/Lapis/Senren Features' }; } if (path.startsWith('ankiConnect.ai.')) { return { category: 'mining-anki', section: 'Anki AI' }; @@ -709,6 +714,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { path === 'ankiConnect.fields.miscInfo' || path === 'ankiConnect.isLapis.sentenceCardModel' || path === 'ankiConnect.isKiku.fieldGrouping' || + path === 'ankiConnect.isSenren.fieldGrouping' || path === 'ankiConnect.lapisKiku.wordCardKind' || path === 'mpv.aniskipEnabled' || path === 'mpv.aniskipButtonKey' || diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 4da9ca27..aef02596 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ 'ankiConnect.fields.miscInfo', 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.fieldGrouping', 'ankiConnect.lapisKiku.wordCardKind', ] as const; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 1a74e9d1..c07e7dca 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch( if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) { patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping }; } + if (diff.hotReloadFields.includes('ankiConnect.isSenren.fieldGrouping')) { + patch.isSenren = { fieldGrouping: config.ankiConnect.isSenren.fieldGrouping }; + } if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) { patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind }; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 49c09027..f2279ad1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -205,8 +205,8 @@
A card with the same expression already exists. Select which card to keep. The other - card's content will be merged using Kiku field grouping. You can choose whether to - delete the duplicate. + card's content will be merged using field grouping. You can choose whether to delete + the duplicate.
diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 6487e3a3..e9a8601b 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -59,6 +59,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ 'subtitle.annotation.jlpt', 'subtitle.annotation.frequency', 'anki.kikuFieldGrouping', + 'anki.senrenFieldGrouping', 'anki.nPlusOneMatchMode', ]; diff --git a/src/types/anki.ts b/src/types/anki.ts index 22adea92..8c49e2d7 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -124,6 +124,11 @@ export interface AnkiConnectConfig { fieldGrouping?: 'auto' | 'manual' | 'disabled'; deleteDuplicateInAuto?: boolean; }; + isSenren?: { + enabled?: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + }; lapisKiku?: { wordCardKind?: WordCardKind; }; diff --git a/src/types/config.ts b/src/types/config.ts index 406f63e6..3f4cea10 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -285,6 +285,11 @@ export interface ResolvedConfig { fieldGrouping: 'auto' | 'manual' | 'disabled'; deleteDuplicateInAuto: boolean; }; + isSenren: { + enabled: boolean; + fieldGrouping: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto: boolean; + }; lapisKiku: { wordCardKind: WordCardKind; }; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index 9c2b423a..63160e31 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -6,6 +6,7 @@ export type RuntimeOptionId = | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency' | 'anki.kikuFieldGrouping' + | 'anki.senrenFieldGrouping' | 'anki.nPlusOneMatchMode'; export type RuntimeOptionScope = 'ankiConnect' | 'subtitle'; From a0635f4360c2fb48468baba80b66290702b31e82 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 1 Sep 2026 22:49:20 -0700 Subject: [PATCH 5/6] fix(subtitles): drop ASS furigana from recorded cues (#233) --- .../subtitle-recorders-drop-ass-furigana.md | 4 + docs/architecture/subtitle-overlay-priming.md | 4 +- .../runtime/mpv-main-event-main-deps.test.ts | 154 ++++++++++++++++++ src/main/runtime/mpv-main-event-main-deps.ts | 16 +- src/main/runtime/primary-subtitle-text.ts | 33 +++- 5 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 changes/subtitle-recorders-drop-ass-furigana.md diff --git a/changes/subtitle-recorders-drop-ass-furigana.md b/changes/subtitle-recorders-drop-ass-furigana.md new file mode 100644 index 00000000..0518aa58 --- /dev/null +++ b/changes/subtitle-recorders-drop-ass-furigana.md @@ -0,0 +1,4 @@ +type: fixed +area: subtitles + +- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 1ca20a16..1c65d059 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -131,7 +131,9 @@ coming and prefetching would otherwise idle for the rest of the cue. authored source order when no usable position exists. - Half-size kana positioned directly above a same-timed kanji caption is treated as ASS furigana. The parser omits it from published cues but retains hidden matching metadata so - mpv's raw live text can be reconciled without displaying or mining the reading. + mpv's raw live text can be reconciled without displaying or mining the reading. The + timing tracker (clipboard copy, recent-line mining) and immersion recorders run the same + reconciliation on the `sub-start`/`sub-end` sample, so they record what the overlay shows. - Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces survive concatenation. Latin fragment typesetting with no literal spaces also recovers word boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 0cf229f5..3f97c7e3 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { parseAssCues } from '../../core/services/subtitle-cue-parser'; import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps'; test('mpv main event main deps map app state updates and delegate callbacks', async () => { @@ -582,3 +583,156 @@ test('subtitle-track changes stop stale canonical cues from substituting immedia assert.equal(appState.activeParsedSubtitleSource, null); assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある'); }); + +test('subtitle recorders drop ASS furigana events the same way the display does', () => { + // Broadcast-caption ASS (Caption2Ass style): furigana are separate half-scale events + // positioned above their base line, and mpv lists them as their own live lines. + const cues = parseAssCues( + [ + '[Script Info]', + 'PlayResX: 960', + 'PlayResY: 540', + '', + '[V4+ Styles]', + 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', + 'Style: Default,Yu Gothic,46,&H00FFFFFF,&H000000FF,&H00000000,&H7F000000,1,0,0,0,100,100,4,0,1,2,2,1,0,0,0,1', + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(472,443)\\fscx50\\fscy50}あくむ', + 'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(172,497)}こんな短時間で{\\fscx50} {\\fscx100}悪夢{\\fscx50} {\\fscx100}見んなよ{\\fscx50}。', + 'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,407)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}暗記教科は{\\fscx50} {\\fscx100}もう', + 'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(332,443)\\fscx50\\fscy50}かた', + 'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(412,443)\\fscx50\\fscy50}ぱし', + 'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,497)}とにかく片っ端から覚えるんだよ{\\fscx50}。', + ].join('\n'), + ); + assert.deepEqual( + cues.map((cue) => cue.text), + [ + 'こんな短時間で 悪夢 見んなよ。', + '(平)暗記教科は もう', + 'とにかく片っ端から覚えるんだよ。', + ], + ); + + const immersion: string[] = []; + const timing: string[] = []; + const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: { currentTimePos: 299.7 }, + immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) }, + subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) }, + activeParsedSubtitleCues: cues, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: false, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + refreshDiscordPresence: () => {}, + })(); + + const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。'; + const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。'; + assert.equal(handlers.resolveSubtitleText?.(liveText), expected); + handlers.recordImmersionSubtitleLine(liveText, 299.63, 303.54); + handlers.recordSubtitleTiming(liveText, 299.63, 303.54); + + assert.deepEqual(immersion, [expected]); + assert.deepEqual(timing, [expected]); +}); + +test('a resolved line survives recording while a fragment grid is on screen', () => { + // Fragment stripping drops every line it can trace back to a cue, and returns nothing + // at all when a fragment grid is nearby. Text the parsed cues already resolved is a + // complete line, not raw mpv output, so it must not be fed through that path. + const immersion: string[] = []; + const timing: string[] = []; + const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: { currentTimePos: 3.2 }, + immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) }, + subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) }, + activeParsedSubtitleCues: [ + { startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' }, + { + startTime: 3, + endTime: 6, + text: 'MaidCafeMaidCafe', + source: 'reconstructed-ass', + assLayout: { kind: 'fragment-grid', sourceOrder: 2 }, + }, + ], + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: false, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + refreshDiscordPresence: () => {}, + })(); + + // The grid fragment beside the lyric keeps canonical substitution from applying, so + // recording falls to the parsed view -- which is where the whole line is recovered. + const liveText = '飛び越え\nMaid'; + assert.equal(handlers.resolveSubtitleText?.(liveText), '飛び越えてみたくて'); + handlers.recordImmersionSubtitleLine(liveText, 3, 6); + handlers.recordSubtitleTiming(liveText, 3, 6); + + assert.deepEqual(immersion, ['飛び越えてみたくて']); + assert.deepEqual(timing, ['飛び越えてみたくて']); + + // A spacer event left as literal control debris is not a subtitle line. The display + // drops it, so no recorder may keep it either. + assert.equal(handlers.resolveSubtitleText?.('\\'), ''); + handlers.recordImmersionSubtitleLine('\\', 3, 6); + handlers.recordSubtitleTiming('\\', 3, 6); + + assert.equal(immersion.length, 1); + assert.equal(timing.length, 1); +}); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index fa89ab2d..a7bd5d6c 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -3,7 +3,7 @@ import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; import { resolveCanonicalPrimarySubtitle, resolvePrimarySubtitleText, - stripCanonicalFragmentLines, + resolveRecordedPrimarySubtitleText, } from './primary-subtitle-text'; type AnilistPostWatchRunOptions = { @@ -131,10 +131,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { currentTimeSec: startSec, cues: deps.appState.activeParsedSubtitleCues, }); - // When substitution declined because dialogue shares the screen with a song, record - // the dialogue alone rather than the combined dialogue-plus-fragments stack. - const stripFragmentsForRecording = (liveText: string, startSec: number) => - stripCanonicalFragmentLines({ + // Recorders see the same text the overlay displays: mpv's live `sub-text` lists every + // simultaneously active ASS event, including furigana events the parser folded into + // their base line. Cues the caller already resolved canonically are recorded one by + // one above; everything else goes through the shared recording resolution. + const resolveTextForRecording = (liveText: string, startSec: number): string => + resolveRecordedPrimarySubtitleText({ liveText, currentTimeSec: startSec, cues: deps.appState.activeParsedSubtitleCues, @@ -218,7 +220,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { } return; } - text = stripFragmentsForRecording(text, start); + text = resolveTextForRecording(text, start); if (!text.trim()) { return; } @@ -232,7 +234,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined; const canonical = resolveCanonicalSample(text, start); if (!canonical) { - const recordableText = stripFragmentsForRecording(text, start); + const recordableText = resolveTextForRecording(text, start); if (!recordableText.trim()) { return; } diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts index fc588b8f..4d0b7a30 100644 --- a/src/main/runtime/primary-subtitle-text.ts +++ b/src/main/runtime/primary-subtitle-text.ts @@ -26,6 +26,13 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool ); } +function decodedLiveText( + liveText: string, + cues: readonly SubtitleCue[] | null | undefined, +): string { + return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText; +} + function animationSpan(cue: SubtitleCue): { start: number; end: number } { return { start: cue.animationStartTime ?? cue.startTime, @@ -290,14 +297,34 @@ export function stripCanonicalFragmentLines(options: { return removeLiveGlyphFragmentLines(options.liveText); } +/** + * Recording text for a live sample. Callers substitute canonical cues themselves and + * record those cue by cue, so what is resolved here is the parsed view -- the one that + * folds ASS furigana events back into their base line. Its text is already a complete + * line, while fragment stripping takes raw mpv text and would discard a resolved line + * whole while a fragment grid is on screen, so only one of the two ever runs. + */ +export function resolveRecordedPrimarySubtitleText(options: { + liveText: string; + currentTimeSec: number; + cues: readonly SubtitleCue[] | null | undefined; +}): string { + const liveText = decodedLiveText(options.liveText, options.cues); + if (!liveText.trim()) { + return liveText; + } + return ( + resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ?? + stripCanonicalFragmentLines({ ...options, liveText }) + ); +} + export function resolvePrimarySubtitleText(options: { liveText: string; currentTimeSec: number; cues: readonly SubtitleCue[] | null | undefined; }): string { - const liveText = cuesUseAssSyntax(options.cues) - ? removeAssControlDebrisLines(options.liveText) - : options.liveText; + const liveText = decodedLiveText(options.liveText, options.cues); if (!liveText.trim()) { return liveText; } From ec5a147095a6e64196059c6588d93e079b2354fe Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 1 Sep 2026 23:11:38 -0700 Subject: [PATCH 6/6] fix(subtitles): merge wrapped positioned caption rows (#234) --- changes/caption-row-sentence-join.md | 4 + docs/architecture/subtitle-overlay-priming.md | 10 + src/core/services/subtitle-cue-parser.test.ts | 186 +++++++++++++++++- src/core/services/subtitle-cue-parser.ts | 184 ++++++++++++++++- .../runtime/primary-subtitle-text.test.ts | 28 +++ 5 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 changes/caption-row-sentence-join.md diff --git a/changes/caption-row-sentence-join.md b/changes/caption-row-sentence-join.md new file mode 100644 index 00000000..354a605c --- /dev/null +++ b/changes/caption-row-sentence-join.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 1c65d059..4c1a0bdc 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -134,6 +134,16 @@ coming and prefetching would otherwise idle for the rest of the cue. mpv's raw live text can be reconciled without displaying or mining the reading. The timing tracker (clipboard copy, recent-line mining) and immersion recorders run the same reconciliation on the `sub-start`/`sub-end` sample, so they record what the overlay shows. +- Broadcast-caption rows that spell one utterance across several same-timed positioned events + (same style, layer, and vertical band, stacked at most two text rows apart) are joined into + one cue with a single line break, so `preserveLineBreaks` treats them like an authored `\N`, + and the recorders above see the whole sentence. A row continues the one above it when that row + is a bare speaker label, ends without terminal punctuation, or leaves a ≪…≫ / ⸨…⸩ span open; a + lower row that opens its own label or span always starts a new cue, which keeps two speakers + sharing the screen on separate lines. The pass runs only on scripts that read as broadcast + captions (a meaningful share of events carry speaker labels or ≪…≫ / ⸨…⸩ spans) and only on + rows containing Japanese, because fansub typesetting stacks positioned rows for signs, chat + bubbles, and headlines where that punctuation convention does not hold. - Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces survive concatenation. Latin fragment typesetting with no literal spaces also recovers word boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 5163ebfb..fe4ed0be 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -1449,15 +1449,17 @@ test('parseSubtitleCues keeps tall CC-style base dialogue publishable after remo 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(172,437)\\fscx50}({\\fscx100}立希{\\fscx50})', 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(332,443)\\fscx50\\fscy50}ともり', 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?', + // A second labeled turn, so the script reads as broadcast captions. + 'Dialogue: 0,0:00:10.11,0:00:12.00,Default,,0,0,0,,{\\pos(192,497)\\fscx50}({\\fscx100}燈{\\fscx50}){\\fscx100}うん。', ].join('\n'); const cues = parseSubtitleCues(content, 'test.ass'); + // The bare speaker label row joins the dialogue row beneath it as one cue. assert.deepEqual( cues.map((cue) => cue.text), - ['(立希)', 'お前… 燈をバンドに誘ったの?'], + ['(立希)\nお前… 燈をバンドに誘ったの?', '(燈)うん。'], ); - assert.deepEqual(cues[0]?.assFurigana, ['たき']); - assert.deepEqual(cues[1]?.assFurigana, ['ともり']); + assert.deepEqual(cues[0]?.assFurigana, ['たき', 'ともり']); assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned')); }); @@ -1485,14 +1487,184 @@ test('parseSubtitleCues removes half-size positioned furigana from broadcast cap [ '(山田)ごめん 結局 ぬれたな。', '大丈夫。', - '(山田の母)ほんなら', - '隠し貯蔵のミルクまんじゅう➡', + '(山田の母)ほんなら\n隠し貯蔵のミルクまんじゅう➡', '絶対違う', ], ); assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']); - assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']); - assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']); + assert.deepEqual(cues[2]?.assFurigana, ['かく', 'ちょぞう']); + assert.deepEqual(cues[3]?.assFurigana, ['ぜったい ちが']); +}); + +// Broadcast-caption rows from You and I Are Polar Opposites S02E09. Every pair shares +// timing, style, and the bottom band; only the text tells a wrap from a second speaker. +const captionRowsHeader = ['[Script Info]', 'PlayResY: 540', '', ...eventsHeader]; + +function captionRow(start: string, end: string, x: number, y: number, text: string): string { + return `Dialogue: 0,${start},${end},Default,,0,0,0,,{\\pos(${x},${y})}${text}`; +} + +test('parseSubtitleCues joins caption rows that wrap one sentence across two events', () => { + const content = [ + ...captionRowsHeader, + captionRow('0:00:19.08', '0:00:22.66', 172, 437, '⸨ぶっちゃけ'), + captionRow( + '0:00:19.08', + '0:00:22.66', + 172, + 497, + '早く{\\fscx50} {\\fscx100}この勉強生活 終えたいし⸩', + ), + // No bracket at all: the upper row simply has not reached sentence punctuation. + captionRow('0:02:42.33', '0:02:44.43', 232, 437, '(東)≪好きだと'), + captionRow('0:02:42.33', '0:02:44.43', 232, 497, '自覚してしまったものの➡'), + // Rows are centred independently, so a wrap can change x between rows. + captionRow('0:00:42.21', '0:00:45.21', 252, 407, '≪ちょっとしたことで'), + captionRow('0:00:42.21', '0:00:45.21', 292, 497, '勝手に落ち込んだり➡'), + // A quote closed with 」 inside a still-open ≪…≫ span is not the end of the line. + captionRow('0:19:02.84', '0:19:05.00', 212, 437, '≪「つきあえる自信がない」'), + captionRow('0:19:02.84', '0:19:05.00', 452, 497, 'じゃない≫'), + // An in-sentence 「 quote on the lower row is not a new turn. + captionRow('0:18:35.55', '0:18:38.00', 232, 437, '今 「好きだ」と'), + captionRow('0:18:35.55', '0:18:38.00', 192, 497, '「心地いい」と感じてるのも➡'), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass'); + + assert.deepEqual( + cues.map((cue) => cue.text), + [ + '⸨ぶっちゃけ\n早く この勉強生活 終えたいし⸩', + '≪ちょっとしたことで\n勝手に落ち込んだり➡', + '(東)≪好きだと\n自覚してしまったものの➡', + '今 「好きだ」と\n「心地いい」と感じてるのも➡', + '≪「つきあえる自信がない」\nじゃない≫', + ], + ); + assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned')); +}); + +test('parseSubtitleCues keeps simultaneous caption rows from two speakers separate', () => { + const content = [ + ...captionRowsHeader, + // Both unlabeled: the upper row finished its sentence. + captionRow('0:03:56.10', '0:04:00.04', 172, 437, 'なあ 車両 変えね?'), + captionRow('0:03:56.10', '0:04:00.04', 632, 497, 'えっ?➡'), + // Lower row opens a labeled turn. + captionRow('0:03:38.48', '0:03:42.05', 592, 437, 'おはよう!'), + captionRow('0:03:38.48', '0:03:42.05', 272, 497, '(平)あっ 声 でかっ。'), + // A closed monologue span above a sound effect. + captionRow('0:08:16.83', '0:08:19.50', 372, 437, '≪落ち着け 落ち着け≫'), + captionRow('0:08:16.83', '0:08:19.50', 272, 497, 'ドクン ドクン ドクン…'), + // Two labeled speakers. + captionRow('0:09:27.90', '0:09:31.07', 312, 437, '(平)ぐぅ…。'), + captionRow('0:09:27.90', '0:09:31.07', 352, 497, '(東)≪ちくしょう~!≫'), + // A bare label never swallows a differently labeled row. + captionRow('0:11:43.24', '0:11:45.00', 212, 437, '(長谷川)'), + captionRow('0:11:43.24', '0:11:45.00', 412, 497, '(早乙女)ん?'), + // A short sentence-final 。 closes the upper row like any other. + captionRow('0:12:31.55', '0:12:33.55', 172, 437, '⚞(東)平。'), + captionRow('0:12:31.55', '0:12:33.55', 532, 497, 'あっ。'), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass'); + + assert.deepEqual( + cues.map((cue) => cue.text), + [ + 'おはよう!', + '(平)あっ 声 でかっ。', + 'なあ 車両 変えね?', + 'えっ?➡', + '≪落ち着け 落ち着け≫', + 'ドクン ドクン ドクン…', + '(平)ぐぅ…。', + '(東)≪ちくしょう~!≫', + '(長谷川)', + '(早乙女)ん?', + '⚞(東)平。', + 'あっ。', + ], + ); +}); + +test('parseSubtitleCues keeps caption rows apart across styles, bands, and timing', () => { + const content = [ + ...captionRowsHeader, + // Same wording as a wrap, but the rows sit in different vertical bands. + captionRow('0:01:00.00', '0:01:02.00', 172, 77, '≪ちょっとしたことで'), + captionRow('0:01:00.00', '0:01:02.00', 172, 497, '勝手に落ち込んだり➡'), + // Same band, but a sign style beside dialogue. + 'Dialogue: 0,0:01:05.00,0:01:07.00,Sign,,0,0,0,,{\\pos(172,437)}ちょっとしたことで', + captionRow('0:01:05.00', '0:01:07.00', 172, 497, '勝手に落ち込んだり➡'), + // Same rows, but the lower one ends later. + captionRow('0:01:10.00', '0:01:12.00', 172, 437, '≪ちょっとしたことで'), + captionRow('0:01:10.00', '0:01:13.00', 172, 497, '勝手に落ち込んだり➡'), + // Style-aligned rows without \pos are never caption rows. + 'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an8}≪ちょっとしたことで', + 'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an2}勝手に落ち込んだり➡', + // Same height: the events sit side by side, not one above the other. + captionRow('0:01:20.00', '0:01:22.00', 172, 497, '≪ちょっとしたことで'), + captionRow('0:01:20.00', '0:01:22.00', 612, 497, '勝手に落ち込んだり➡'), + // Same bottom band, but further apart than two text rows. + captionRow('0:01:25.00', '0:01:27.00', 172, 367, '≪ちょっとしたことで'), + captionRow('0:01:25.00', '0:01:27.00', 172, 497, '勝手に落ち込んだり➡'), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.equal(cues.length, 12); + assert.ok(cues.every((cue) => !cue.text.includes('\n'))); +}); + +test('parseSubtitleCues leaves typeset rows alone in scripts that are not broadcast captions', () => { + // Fansub typesetting stacks positioned rows for signs, chat bubbles, and headlines. Such + // text carries no caption punctuation, so without the script-level gate every stacked + // pair here would read as an unfinished sentence and merge. + const content = [ + ...captionRowsHeader, + captionRow('0:00:10.00', '0:00:14.00', 640, 200, 'Shocking Statement Leaves'), + captionRow('0:00:10.00', '0:00:14.00', 640, 260, 'Listeners Speechless!'), + captionRow('0:01:00.00', '0:01:04.00', 400, 300, 'shes here AGAIN'), + captionRow('0:01:00.00', '0:01:04.00', 400, 360, 'make sakiko-chan go home'), + // Japanese typesetting in the same script is held back by the same gate. + captionRow('0:02:00.00', '0:02:04.00', 300, 400, '定休日'), + captionRow('0:02:00.00', '0:02:04.00', 300, 460, '毎週水曜日'), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.deepEqual( + cues.map((cue) => cue.text), + [ + 'Shocking Statement Leaves', + 'Listeners Speechless!', + 'shes here AGAIN', + 'make sakiko-chan go home', + '定休日', + '毎週水曜日', + ], + ); +}); + +test('parseSubtitleCues never joins caption rows that carry no Japanese', () => { + // Even inside a caption script, romaji or English rows are not the wrapped Japanese + // sentences this pass targets. + const content = [ + ...captionRowsHeader, + captionRow('0:00:10.00', '0:00:13.00', 172, 437, '(東)≪好きだと'), + captionRow('0:00:10.00', '0:00:13.00', 172, 497, '自覚してしまったものの➡'), + captionRow('0:00:20.00', '0:00:23.00', 172, 437, '(平)ん?'), + captionRow('0:00:30.00', '0:00:34.00', 640, 437, 'NOW LOADING'), + captionRow('0:00:30.00', '0:00:34.00', 640, 497, 'please wait'), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.deepEqual( + cues.map((cue) => cue.text), + ['(東)≪好きだと\n自覚してしまったものの➡', '(平)ん?', 'NOW LOADING', 'please wait'], + ); }); test('parseSubtitleCues scales furigana geometry by PlayResY', () => { diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 2a16ddd6..f0483eb4 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -2342,6 +2342,185 @@ function removeAssFuriganaEvents( }; } +// Broadcast-caption converters give every visual row of one utterance its own positioned +// event, so a sentence that wraps arrives as two simultaneous cues with the same timing, +// style, and vertical band. Captions punctuate every finished utterance, and each turn +// opens with a speaker label or a ≪…≫ / ⸨…⸩ span, which is what tells a wrapped sentence +// apart from two speakers sharing the screen. +const CAPTION_SPEAKER_LABEL_ONLY_PATTERN = /^([^()]*)$/u; +const CAPTION_SPEAKER_LABEL_PATTERN = /^(/u; +const CAPTION_TURN_OPENER_PATTERN = /^[≪⸨(]/u; +const CAPTION_TERMINAL_PATTERN = /[。?!?!…‥~〜➡⁉⁈≫⸩)」』]$/u; +const CAPTION_SPANS: ReadonlyArray = [ + ['≪', '≫'], + ['⸨', '⸩'], +]; +// Rows of one utterance sit one text row apart (about 60 units in the 540-line space the +// furigana geometry is tuned for), or two when a ruby row lies between them. Rows at the +// same height sit side by side, and rows further apart are separate placements. +const MAX_CAPTION_ROW_GAP = 120; +// Only a broadcast-caption script gets rows joined. Typesetters position rows for signs, +// chat bubbles, and lyric stacks too, and there the continuation rule below has no +// convention to read: sign text rarely carries sentence punctuation, so unrelated rows +// would run together. A caption script announces itself by labelling speakers (名) and +// bracketing off-screen speech in ≪…≫ / ⸨…⸩; typeset scripts use those in a handful of +// lines at most. Measured over local tracks, caption scripts sit near 25% and every typeset +// script below 1%, so the threshold has room on both sides. It is deliberately strict: a +// caption script wrongly held back just keeps one sentence on two rows, while a typeset +// script wrongly let through concatenates unrelated signs. +const MIN_CAPTION_EVIDENCE_EVENTS = 2; +const MIN_CAPTION_EVIDENCE_RATIO = 0.05; +const CAPTION_EVIDENCE_PATTERN = /^([^()]{1,14})|[≪⸨]/u; +// Rows that carry no Japanese are not the broadcast captions this pass targets. +const JAPANESE_SCRIPT_PATTERN = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u; + +function hasBroadcastCaptionConventions(cues: readonly AnnotatedSubtitleCue[]): boolean { + let evidence = 0; + let published = 0; + for (const cue of cues) { + if (!cue.text.trim()) continue; + published += 1; + if (CAPTION_EVIDENCE_PATTERN.test(cue.text)) evidence += 1; + } + return ( + evidence >= MIN_CAPTION_EVIDENCE_EVENTS && evidence >= published * MIN_CAPTION_EVIDENCE_RATIO + ); +} + +function captionSpanDepth(text: string, [open, close]: readonly [string, string]): number { + let depth = 0; + for (const char of text) { + if (char === open) depth += 1; + else if (char === close) depth -= 1; + } + return depth; +} + +/** + * Whether `lower` continues the utterance `upper` started, both being simultaneous + * caption rows. A bare speaker label labels the row beneath it. Otherwise the upper row + * must not have finished: it ends without terminal punctuation, or a ≪…≫ / ⸨…⸩ span it + * opened is still open (closing 」 inside such a span is not an ending). A lower row that + * opens its own turn is always a different line. + */ +function isCaptionRowContinuation(upper: string, lower: string): boolean { + if (CAPTION_SPEAKER_LABEL_ONLY_PATTERN.test(upper)) { + return !CAPTION_SPEAKER_LABEL_PATTERN.test(lower); + } + if (CAPTION_TURN_OPENER_PATTERN.test(lower)) { + return false; + } + const spanContinues = CAPTION_SPANS.some( + (span) => captionSpanDepth(upper, span) > 0 || captionSpanDepth(lower, span) < 0, + ); + return spanContinues || !CAPTION_TERMINAL_PATTERN.test(upper); +} + +// A half-height row is ruby or a whispered aside, not a row of the utterance. +function isCaptionRowCandidate(cue: AnnotatedSubtitleCue): boolean { + const scaleY = staticAssScalePercent(cue, 'fscy'); + return ( + cue.source === undefined && + cue.assLayout?.kind === 'positioned' && + cue.effect.trim() === '' && + !cue.text.includes('\n') && + !hasAssTemporalOverride(cue.overrides) && + (scaleY === null || scaleY > MAX_ASS_FURIGANA_SCALE_PERCENT) && + JAPANESE_SCRIPT_PATTERN.test(cue.text) + ); +} + +function captionRowGroupKey(cue: AnnotatedSubtitleCue): string { + return [ + cue.startTime, + cue.endTime, + cue.style, + cue.layer, + cue.name, + cue.assLayout?.verticalBand ?? '', + ].join('\0'); +} + +function mergeCaptionRows(rows: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue { + const [first] = rows; + if (!first) throw new Error('mergeCaptionRows requires at least one row'); + const overrides = rows.flatMap((row) => row.overrides); + const assFurigana = [...new Set(rows.flatMap((row) => row.assFurigana ?? []))]; + return { + ...first, + text: rows.map((row) => row.text).join('\n'), + rawText: rows.map((row) => row.rawText).join('\\N'), + overrides, + overrideSignature: assOverrideSignature(overrides), + ...(assFurigana.length === 0 ? {} : { assFurigana }), + }; +} + +/** + * Join simultaneous caption rows that spell one utterance into a single cue, so the + * overlay can wrap or flatten it like an authored `\N` line and the sidebar and mining + * paths see the whole sentence. Rows stack top to bottom; each row joins the cue above it + * only while `isCaptionRowContinuation` holds, so a second speaker starts a new cue. The + * whole pass is skipped unless the script reads as broadcast captions. + */ +function mergeAssCaptionRows( + cues: AnnotatedSubtitleCue[], + playResY: number | null, +): AnnotatedSubtitleCue[] { + if (!hasBroadcastCaptionConventions(cues)) return cues; + + const groups = new Map(); + for (const cue of cues) { + if (!isCaptionRowCandidate(cue)) continue; + const key = captionRowGroupKey(cue); + const group = groups.get(key); + if (group) group.push(cue); + else groups.set(key, [cue]); + } + + const maxRowGap = MAX_CAPTION_ROW_GAP * assFuriganaGeometryScale(playResY); + const rowY = (cue: AnnotatedSubtitleCue): number => + cue.assLayout?.kind === 'positioned' ? cue.assLayout.y : 0; + const replacements = new Map(); + const removed = new Set(); + for (const group of groups.values()) { + if (group.length < 2) continue; + const rows = [...group].sort((a, b) => rowY(a) - rowY(b) || a.order - b.order); + let run: AnnotatedSubtitleCue[] = []; + const flush = (): void => { + if (run.length < 2) return; + const anchor = run.reduce((lowest, row) => (row.order < lowest.order ? row : lowest)); + replacements.set(anchor, mergeCaptionRows(run)); + for (const row of run) { + if (row !== anchor) removed.add(row); + } + }; + for (const row of rows) { + const previous = run.at(-1); + const gap = previous ? rowY(row) - rowY(previous) : 0; + if ( + previous && + gap > 0 && + gap <= maxRowGap && + previous.text !== row.text && + isCaptionRowContinuation(previous.text, row.text) + ) { + run.push(row); + continue; + } + flush(); + run = [row]; + } + flush(); + } + + if (replacements.size === 0) return cues; + return cues.flatMap((cue) => { + if (removed.has(cue)) return []; + return [replacements.get(cue) ?? cue]; + }); +} + function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents { const cues: AnnotatedSubtitleCue[] = []; const comments: AnnotatedSubtitleCue[] = []; @@ -2476,7 +2655,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)), placement.playResY, ); - return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)); + return mergeAssCaptionRows( + recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)), + placement.playResY, + ); } export function parseAssCues(content: string): SubtitleCue[] { diff --git a/src/main/runtime/primary-subtitle-text.test.ts b/src/main/runtime/primary-subtitle-text.test.ts index 3aa611e1..e42398c3 100644 --- a/src/main/runtime/primary-subtitle-text.test.ts +++ b/src/main/runtime/primary-subtitle-text.test.ts @@ -674,3 +674,31 @@ test('resolvePrimarySubtitleText keeps source order when no cue declares a place 'First line\n\nSecond line', ); }); + +test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue', () => { + // mpv still reports the two source rows (plus the ruby row) as separate live lines, so + // the merged cue must explain all of them and come back as a single-break line the + // display layer may flatten, not as a two-cue boundary. + const ass = [ + '[Script Info]', + 'PlayResY: 540', + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}≪好きだと', + 'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(292,443)\\fscx50\\fscy50}じかく', + 'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,497)}自覚してしまったものの➡', + // A second labeled turn, so the script reads as broadcast captions. + 'Dialogue: 0,0:02:44.43,0:02:47.37,Default,,0,0,0,,{\\pos(212,497)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}どうした?', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass'); + + assert.equal( + resolvePrimarySubtitleText({ + liveText: '(東)≪好きだと\nじかく\n自覚してしまったものの➡', + currentTimeSec: 163, + cues, + }), + '(東)≪好きだと\n自覚してしまったものの➡', + ); +});