feat(subtitles): add regex subtitle filters with built-in presets (#318)

- Add `subtitleFilter` config with four presets: parentheses, brackets, speaker prefix and music symbols. Custom regex rules run after the presets.
- Filter primary text where it enters the app (live `sub-text` and parsed cues), so the overlay, sidebar, mining and stats all get the same filtered text.
- Changes hot-reload: the current line is read again and the active track is parsed again, using the filter config as the prefetch parse key.
- Add a custom rule editor in Settings under Behavior → Subtitle Filters that checks patterns as you type.
- Update the config example, the configuration docs and the architecture domains doc, and add a changelog fragment.
This commit is contained in:
sudacode authored and GitHub committed 2026-11-03 00:33:58 -08:00
1 parent 848a6c4021
commit fa3051f9cb
28 files changed
+740 -11

No files matched your search

@@ -20,6 +20,8 @@ type ConfigHotReloadAppliedDeps = {
invalidateTokenizationCache?: () => void;
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
/** Re-reads the live line and re-parses the active cue list through the new filters. */
refreshSubtitleFilter?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
@@ -180,6 +182,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.applyAnkiRuntimeConfigPatch(ankiPatch);
}
if (diff.hotReloadFields.includes('subtitleFilter')) {
deps.refreshSubtitleFilter?.();
}
if (hasAnnotationRuntimeHotReload(diff)) {
deps.invalidateTokenizationCache?.();
deps.refreshSubtitlePrefetch?.();
@@ -252,7 +252,7 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
});
function createTrackingController(loadContent: () => string) {
function createTrackingController(loadContent: () => string, getParseKey?: () => string) {
let currentService: SubtitlePrefetchService | null = null;
const events: string[] = [];
const cueUpdates: Array<string | null> = [];
@@ -265,6 +265,7 @@ function createTrackingController(loadContent: () => string) {
},
loadSubtitleSourceText: async () => loadContent(),
parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }],
getParseKey,
createSubtitlePrefetchService: () => {
const id = ++serviceCount;
return {
@@ -308,6 +309,20 @@ test('re-initializing a source whose content changed restarts the service', asyn
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
});
test('re-initializing an unchanged source re-parses when the parse key changed', async () => {
let parseKey = 'filters-off';
const { controller, events } = createTrackingController(
() => 'content',
() => parseKey,
);
await controller.initSubtitlePrefetch('track-1.srt', 0);
parseKey = 'filters-on';
await controller.initSubtitlePrefetch('track-1.srt', 0);
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
});
test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => {
let prefetchedTexts: string[] = [];
let publishedTexts: string[] = [];
+11 -2
View File
@@ -11,6 +11,8 @@ export interface SubtitlePrefetchInitControllerDeps {
setCurrentService: (service: SubtitlePrefetchService | null) => void;
loadSubtitleSourceText: (source: string) => Promise<string>;
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
/** Changes whenever parseSubtitleCues would produce different cues for the same content. */
getParseKey?: () => string;
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
@@ -37,7 +39,12 @@ export function createSubtitlePrefetchInitController(
// What the running service was built from. Startup fires several inits for one source
// (Jellyfin preload seed, sid change, each track-list update); restarting on each would
// discard the in-flight tokenization and re-queue the priority window behind it.
let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null;
let activeSource: {
key: string;
content: string;
parseKey: string;
cues: SubtitleCue[];
} | null = null;
const stopCurrentService = (): void => {
deps.getCurrentService()?.stop();
@@ -67,9 +74,11 @@ export function createSubtitlePrefetchInitController(
if (revision !== initRevision) {
return;
}
const parseKey = deps.getParseKey?.() ?? '';
if (
activeSource?.key === sourceKey &&
activeSource.content === content &&
activeSource.parseKey === parseKey &&
deps.getCurrentService()
) {
// A track change clears the published cues synchronously, so republish them.
@@ -106,7 +115,7 @@ export function createSubtitlePrefetchInitController(
}
deps.setCurrentService(nextService);
activeSource = { key: sourceKey, content, cues };
activeSource = { key: sourceKey, content, parseKey, cues };
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos);
deps.logInfo(