Files
SubMiner/src/main/runtime/subtitle-prefetch-init.ts
T
sudacode fa3051f9cb 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.
2026-11-03 00:33:58 -08:00

138 lines
4.8 KiB
TypeScript

import type {
SubtitlePrefetchService,
SubtitlePrefetchServiceDeps,
} from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types';
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
export interface SubtitlePrefetchInitControllerDeps {
getCurrentService: () => SubtitlePrefetchService | null;
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;
hasCachedTokenization?: (text: string) => boolean;
getCacheGeneration?: () => number;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
}
export interface SubtitlePrefetchInitController {
cancelPendingInit: () => void;
initSubtitlePrefetch: (
sourcePath: string,
currentTimePos: number,
sourceKey?: string,
) => Promise<void>;
}
export function createSubtitlePrefetchInitController(
deps: SubtitlePrefetchInitControllerDeps,
): SubtitlePrefetchInitController {
let initRevision = 0;
// 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;
parseKey: string;
cues: SubtitleCue[];
} | null = null;
const stopCurrentService = (): void => {
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
activeSource = null;
};
const cancelPendingInit = (): void => {
initRevision += 1;
stopCurrentService();
deps.onParsedSubtitleCuesChanged?.(null, null);
};
const initSubtitlePrefetch = async (
sourcePath: string,
currentTimePos: number,
sourceKey = sourcePath,
): Promise<void> => {
const revision = ++initRevision;
// The same source may still be current once loaded; keep its service running until then.
if (activeSource?.key !== sourceKey) {
stopCurrentService();
}
try {
const content = await deps.loadSubtitleSourceText(sourcePath);
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.
deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey);
return;
}
stopCurrentService();
const cues = deps.parseSubtitleCues(content, sourcePath);
if (revision !== initRevision || cues.length === 0) {
if (revision === initRevision) {
deps.logWarn(
`[subtitle-prefetch] parsed 0 cues from ${sourcePath}; prefetch disabled for this source`,
);
deps.onParsedSubtitleCuesChanged?.(null, null);
}
return;
}
// Overlap cues only feed the cache; the published cue list stays as authored.
const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort(
(a, b) => a.startTime - b.startTime,
);
const nextService = deps.createSubtitlePrefetchService({
cues: prefetchCues,
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
getCacheGeneration: deps.getCacheGeneration,
});
if (revision !== initRevision) {
return;
}
deps.setCurrentService(nextService);
activeSource = { key: sourceKey, content, parseKey, cues };
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos);
deps.logInfo(
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`,
);
} catch (error) {
if (revision === initRevision) {
stopCurrentService();
deps.onParsedSubtitleCuesChanged?.(null, null);
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
}
}
};
return {
cancelPendingInit,
initSubtitlePrefetch,
};
}