mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
1 parent
848a6c4021
commit
fa3051f9cb
28 files changed
+740
-11
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -8,6 +8,7 @@ const HOT_RELOAD_ROOTS = [
|
||||
'shortcuts',
|
||||
'subtitleSidebar',
|
||||
'subtitleSelection',
|
||||
'subtitleFilter',
|
||||
] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
|
||||
@@ -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]',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user