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

+2 -1
View File
@@ -52,7 +52,7 @@ const {
discordPresence,
ai,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar, subtitleFilter } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
@@ -78,6 +78,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
plugins,
subtitleStyle,
subtitleSidebar,
subtitleFilter,
auto_start_overlay,
jimaku,
tsukihime,
+13 -1
View File
@@ -1,6 +1,9 @@
import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
export const SUBTITLE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'subtitleStyle' | 'subtitleSidebar' | 'subtitleFilter'
> = {
subtitleStyle: {
primaryDefaultMode: 'visible',
css: {},
@@ -94,4 +97,13 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
subtitleFilter: {
presets: {
parentheses: false,
brackets: false,
speakerPrefix: false,
musicSymbols: false,
},
customRules: [],
},
};
@@ -1,6 +1,10 @@
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
import {
SUBTITLE_FILTER_PRESET_IDS,
SUBTITLE_FILTER_PRESETS,
} from '../../shared/subtitle-text-filter';
export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
@@ -340,5 +344,24 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
description: 'Background color for hovered subtitle cues.',
},
{
path: 'subtitleFilter.presets',
kind: 'object',
defaultValue: defaultConfig.subtitleFilter.presets,
description: 'Built-in filters. Each removes its matches and is off by default.',
},
...SUBTITLE_FILTER_PRESET_IDS.map((id) => ({
path: `subtitleFilter.presets.${id}`,
kind: 'boolean' as const,
defaultValue: defaultConfig.subtitleFilter.presets[id],
description: SUBTITLE_FILTER_PRESETS[id].description,
})),
{
path: 'subtitleFilter.customRules',
kind: 'array',
defaultValue: defaultConfig.subtitleFilter.customRules,
description:
'Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.',
},
];
}
@@ -153,6 +153,15 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
key: 'subtitleSidebar',
},
{
title: 'Subtitle Filters',
description: [
'Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.',
'Applies to the overlay, sidebar, mined sentences and stats.',
],
notes: ['Hot-reload: filter changes apply to the current subtitle file immediately.'],
key: 'subtitleFilter',
},
];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+1
View File
@@ -8,6 +8,7 @@ const HOT_RELOAD_ROOTS = [
'shortcuts',
'subtitleSidebar',
'subtitleSelection',
'subtitleFilter',
] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
+2
View File
@@ -7,12 +7,14 @@ import { applyIntegrationConfig } from './resolve/integrations';
import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applySubtitleFilterConfig } from './resolve/subtitle-filter';
import { applyTopLevelConfig } from './resolve/top-level';
const APPLY_RESOLVE_STEPS = [
applyTopLevelConfig,
applyCoreDomainConfig,
applySubtitleDomainConfig,
applySubtitleFilterConfig,
applyIntegrationConfig,
applyImmersionTrackingConfig,
applyStatsConfig,
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveConfig } from '../resolve';
test('subtitleFilter resolves presets and fills custom rule defaults', () => {
const { resolved, warnings } = resolveConfig({
subtitleFilter: {
presets: { parentheses: true },
customRules: [
{ pattern: '\\n' },
{ name: 'tag', pattern: '<i>', replacement: '', enabled: false },
],
},
});
assert.deepEqual(warnings, []);
assert.equal(resolved.subtitleFilter.presets.parentheses, true);
assert.equal(resolved.subtitleFilter.presets.brackets, false);
assert.deepEqual(resolved.subtitleFilter.customRules, [
{ name: '', pattern: '\\n', replacement: '', enabled: true },
{ name: 'tag', pattern: '<i>', replacement: '', enabled: false },
]);
});
test('subtitleFilter keeps empty and invalid patterns, warns on invalid, drops malformed rules', () => {
const { resolved, warnings } = resolveConfig({
subtitleFilter: {
presets: { brackets: 'yes' as unknown as boolean },
customRules: [
{ name: 'broken', pattern: '(' },
{ name: 'placeholder', pattern: '' },
{ name: 'no pattern' },
'oops' as never,
],
},
});
assert.deepEqual(
resolved.subtitleFilter.customRules.map((rule) => rule.name),
['broken', 'placeholder'],
);
assert.equal(resolved.subtitleFilter.presets.brackets, false);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'subtitleFilter.presets.brackets',
'subtitleFilter.customRules[0].pattern',
'subtitleFilter.customRules[2]',
'subtitleFilter.customRules[3]',
],
);
});
+75
View File
@@ -0,0 +1,75 @@
import type { SubtitleFilterRule } from '../../types/subtitle';
import {
SUBTITLE_FILTER_PRESET_IDS,
getSubtitleFilterPatternError,
} from '../../shared/subtitle-text-filter';
import { ResolveContext } from './context';
import { asBoolean, asString, isObject } from './shared';
function resolveCustomRule(
raw: unknown,
index: number,
warn: ResolveContext['warn'],
): SubtitleFilterRule | null {
const path = `subtitleFilter.customRules[${index}]`;
const pattern = isObject(raw) ? asString(raw.pattern) : undefined;
if (!isObject(raw) || pattern === undefined) {
warn(path, raw, null, 'Expected an object with a string pattern.');
return null;
}
// Empty and invalid patterns stay in the config so the settings window can show and
// fix them; the filter skips both at runtime.
const patternError = pattern ? getSubtitleFilterPatternError(pattern) : null;
if (patternError !== null) {
warn(`${path}.pattern`, pattern, null, `Invalid regular expression: ${patternError}`);
}
return {
name: asString(raw.name) ?? '',
pattern,
replacement: asString(raw.replacement) ?? '',
enabled: asBoolean(raw.enabled) ?? true,
};
}
export function applySubtitleFilterConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (src.subtitleFilter === undefined) return;
if (!isObject(src.subtitleFilter)) {
warn('subtitleFilter', src.subtitleFilter, resolved.subtitleFilter, 'Expected object.');
return;
}
const { presets, customRules } = src.subtitleFilter;
if (isObject(presets)) {
for (const id of SUBTITLE_FILTER_PRESET_IDS) {
const enabled = asBoolean(presets[id]);
if (enabled !== undefined) {
resolved.subtitleFilter.presets[id] = enabled;
} else if (presets[id] !== undefined) {
warn(
`subtitleFilter.presets.${id}`,
presets[id],
resolved.subtitleFilter.presets[id],
'Expected boolean.',
);
}
}
} else if (presets !== undefined) {
warn('subtitleFilter.presets', presets, resolved.subtitleFilter.presets, 'Expected object.');
}
if (Array.isArray(customRules)) {
resolved.subtitleFilter.customRules = customRules
.map((rule, index) => resolveCustomRule(rule, index, warn))
.filter((rule): rule is SubtitleFilterRule => rule !== null);
} else if (customRules !== undefined) {
warn(
'subtitleFilter.customRules',
customRules,
resolved.subtitleFilter.customRules,
'Expected an array of rules.',
);
}
}
+18
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsField,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
SUBTITLE_FILTER_PRESET_IDS,
SUBTITLE_FILTER_PRESETS,
} from '../../shared/subtitle-text-filter';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
@@ -127,6 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
'Subtitle Sidebar Appearance',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Filters',
'Subtitle Sidebar Behavior',
'YouTube Playback Settings',
'mpv Playback',
@@ -203,6 +208,7 @@ const PATH_ORDER = new Map<string, number>(
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
...SUBTITLE_FILTER_PRESET_IDS.map((id) => `subtitleFilter.presets.${id}`),
].map((path, index) => [path, index]),
);
@@ -253,6 +259,12 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
'ankiConnect.media.reviewTiming': 'Review Media Timing',
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
...Object.fromEntries(
SUBTITLE_FILTER_PRESET_IDS.map((id) => [
`subtitleFilter.presets.${id}`,
SUBTITLE_FILTER_PRESETS[id].label,
]),
),
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
@@ -280,6 +292,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.',
'subtitleFilter.customRules':
'Your own regex rules, applied in order after the presets. ^ and $ match each line. Leave the replacement empty to remove the match.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -353,6 +367,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('subtitleFilter.')) {
return { category: 'behavior', section: 'Subtitle Filters' };
}
if (
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.decks' ||
@@ -557,6 +574,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (path === 'subtitleFilter.customRules') return 'subtitle-filter-rules';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
if (Array.isArray(value)) return 'string-list';