fix(anki): sync field grouping fixtures with main

This commit is contained in:
2026-08-31 23:43:20 -07:00
213 changed files with 13935 additions and 1405 deletions
+83 -3
View File
@@ -11,10 +11,12 @@ import type { MediaInput } from './media-input';
import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = {
id?: string;
title: string;
body?: string;
image?: string;
variant?: string;
persistent?: boolean;
actions?: Array<{ id: string; label: string; noteId?: number }>;
};
@@ -607,6 +609,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
const integration = new AnkiIntegration(
{
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
},
media: {
@@ -660,7 +663,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
ExpressionAudio: { value: '' },
Picture: { value: '' },
},
})),
@@ -945,7 +948,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
noteInfo: {
noteId: 404,
fields: {
SentenceAudio: { value: '' },
ExpressionAudio: { value: '' },
Picture: { value: '' },
},
},
@@ -957,7 +960,8 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(updatedNotes[0]?.noteId, 404);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2);
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
@@ -1183,6 +1187,82 @@ test('AnkiIntegration embeds generated notification image on overlay mined-card
assert.deepEqual(cleanupPaths, [notificationIconPath]);
});
test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'overlay',
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
};
updateNotifications.beginUpdateProgress('Updating card');
await updateNotifications.showNotification(42, '食べる');
assert.deepEqual(
overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })),
[
{ id: 'anki-update-progress', variant: 'progress', persistent: true },
{ id: 'anki-update-progress', variant: 'success', persistent: false },
],
);
});
test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const dismissedIds: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'overlay',
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload);
},
undefined,
undefined,
undefined,
(id) => {
dismissedIds.push(id);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
endUpdateProgress: () => void;
};
updateNotifications.beginUpdateProgress('Updating card');
updateNotifications.endUpdateProgress();
assert.equal(overlayNotifications[0]?.persistent, true);
assert.deepEqual(dismissedIds, ['anki-update-progress']);
});
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
+14 -13
View File
@@ -218,6 +218,8 @@ export class AnkiIntegration {
null;
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
null;
private overlayNotificationDismissCallback: ((id: string) => void) | null = null;
private overlayUpdateProgressActive = false;
private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>();
@@ -265,6 +267,7 @@ export class AnkiIntegration {
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
overlayNotificationDismissCallback?: (id: string) => void,
) {
this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
@@ -280,6 +283,7 @@ export class AnkiIntegration {
this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
this.overlayNotificationDismissCallback = overlayNotificationDismissCallback ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner();
@@ -379,8 +383,6 @@ export class AnkiIntegration {
getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
mergeFieldValue: (existing, newValue, overwrite) =>
@@ -657,8 +659,6 @@ export class AnkiIntegration {
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
@@ -1240,12 +1240,13 @@ export class AnkiIntegration {
private beginUpdateProgress(initialMessage: string): void {
if (!this.shouldUseOsdNotifications()) {
if (this.shouldUseOverlayNotifications()) {
this.overlayUpdateProgressActive = true;
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
persistent: true,
});
}
return;
@@ -1257,6 +1258,10 @@ export class AnkiIntegration {
private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
if (this.overlayUpdateProgressActive) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationDismissCallback?.('anki-update-progress');
}
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => {
@@ -1280,18 +1285,20 @@ export class AnkiIntegration {
if (!this.shouldUseOsdNotifications()) {
this.updateInProgress = true;
if (this.shouldUseOverlayNotifications()) {
this.overlayUpdateProgressActive = true;
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
persistent: true,
});
}
try {
return await action();
} finally {
this.updateInProgress = false;
this.endUpdateProgress();
}
}
return withUpdateProgress(
@@ -1390,6 +1397,7 @@ export class AnkiIntegration {
: undefined;
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationCallback({
id: 'anki-update-progress',
title: 'Anki Card Updated',
@@ -1592,13 +1600,6 @@ export class AnkiIntegration {
return sentenceCardConfig.audioField || 'SentenceAudio';
}
private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null {
return (
this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) ||
this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio)
);
}
private getConfiguredWordFieldName(): string {
return getConfiguredWordFieldName(this.config);
}
@@ -142,7 +142,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
};
}
test('manual clipboard subtitle update replaces sentence audio without touching expression audio', async () => {
test('manual clipboard subtitle update replaces audio in the configured field', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕');
@@ -150,14 +150,142 @@ test('manual clipboard subtitle update replaces sentence audio without touching
assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
assert.equal('ExpressionAudio' in updatedFields[0]!, false);
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal('SentenceAudio' in updatedFields[0]!, false);
assert.deepEqual(
mergeCalls.map((call) => call.overwrite),
[true],
);
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
fieldGroupingMode: 'disabled',
}),
});
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.match(updatedFields[0]?.ContextAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.deepEqual(Object.keys(updatedFields[0] ?? {}).sort(), ['Context', 'ContextAudio']);
assert.equal(updatedFields[0]?.Context, '字幕');
});
test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentAudioStreamIndex: 0,
currentSubText: '字幕',
currentSubStart: 12,
currentSubEnd: 14,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
fieldGroupingMode: 'disabled',
}),
});
await service.markLastCardAsAudioCard();
assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.match(updatedFields[0]?.SentenceAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal('Context' in (updatedFields[0] ?? {}), false);
assert.equal('ContextAudio' in (updatedFields[0] ?? {}), false);
});
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
@@ -223,7 +351,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
});
});
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
test('manual clipboard subtitle update uses configured audio when SentenceAudio is missing', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
client: {
addNote: async () => 0,
@@ -253,8 +381,9 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
assert.equal(storedMedia.length, 1);
assert.equal(updatedFields.length, 1);
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
assert.equal(mergeCalls.length, 0);
assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.equal(mergeCalls.length, 1);
});
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
+11 -21
View File
@@ -259,9 +259,16 @@ export class CardCreationService {
fields,
this.deps.getConfig(),
);
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
const config = this.deps.getConfig();
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
const sentenceField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
);
const sentence = blocks.join(' ');
const updatedFields: Record<string, string> = {};
@@ -283,7 +290,6 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
);
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
@@ -456,13 +462,13 @@ export class CardCreationService {
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
const sentenceField = this.deps.getConfig().fields?.sentence;
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
if (sentenceField) {
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
updatedFields[sentenceField] = processedSentence;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const audioFieldName = sentenceCardConfig.audioField;
try {
const audioFilename = this.generateAudioFilename();
@@ -804,22 +810,6 @@ export class CardCreationService {
}
}
private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return (
this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio)
);
}
private getResolvedSentenceOnlyAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
);
}
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return {
noteId: -1,
@@ -80,7 +80,6 @@ function createWorkflowHarness() {
const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
},
getResolvedSentenceAudioFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3',
@@ -120,6 +119,49 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
assert.equal(harness.notifications.length, 1);
});
test('NoteUpdateWorkflow uses configured fields for word-card enrichment with Lapis and Kiku enabled', async () => {
const harness = createWorkflowHarness();
harness.deps.getConfig = () => ({
fields: {
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
},
behavior: {},
});
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: true,
fieldGroupingMode: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.generateAudio = async () => Buffer.from('audio');
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Context: 'subtitle-text',
ContextAudio: '[sound:audio_1.mp3]',
});
});
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
@@ -432,6 +474,7 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
@@ -444,7 +487,6 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
});
harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null;
return Buffer.from('audio');
@@ -501,6 +543,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
@@ -511,7 +554,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
},
behavior: {},
});
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.captureSubtitleMediaContext = () => {
captureCalls += 1;
return capturedContext;
+10 -5
View File
@@ -19,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
fields?: {
word?: string;
sentence?: string;
audio?: string;
image?: string;
miscInfo?: string;
};
@@ -75,7 +76,6 @@ export interface NoteUpdateWorkflowDeps {
noteInfo: NoteUpdateWorkflowNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string;
@@ -196,11 +196,13 @@ export class NoteUpdateWorkflow {
const updatedFields: Record<string, string> = {};
let updatePerformed = false;
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
const configuredSentenceField =
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
fields,
sentenceField,
config.fields?.sentence,
sentenceField ?? configuredSentenceField,
configuredSentenceField,
);
// Audio and image generation run sequentially and audio extraction can take tens of
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
@@ -256,7 +258,10 @@ export class NoteUpdateWorkflow {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
@@ -31,7 +31,6 @@ function createDeps(
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0,
@@ -133,7 +132,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
ExpressionAudio: { value: '' },
Picture: { value: '' },
},
})),
@@ -144,13 +143,16 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
storedMedia.push(filename);
},
},
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
getConfig: () =>
({ media: {}, fields: { audio: 'ExpressionAudio', image: 'Picture' } }) as AnkiConnectConfig,
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name && name in noteInfo.fields) ?? null,
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
label: 'demo',
});
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
@@ -158,7 +160,8 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(storedMedia.length, 2);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
});
@@ -39,7 +39,6 @@ export interface PendingYoutubeMediaQueueDeps {
startTime: number;
endTime: number;
};
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
resolveConfiguredFieldName: (
noteInfo: PendingYoutubeMediaNoteInfo,
...preferredNames: (string | undefined)[]
@@ -136,7 +135,7 @@ export class PendingYoutubeMediaQueue {
startTime: mediaRange.startTime,
endTime: mediaRange.endTime,
label: job.label,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName:
this.deps.resolveConfiguredFieldName(
job.noteInfo,
@@ -247,6 +246,14 @@ export class PendingYoutubeMediaQueue {
return matched;
}
private resolveConfiguredAudioFieldName(noteInfo: PendingYoutubeMediaNoteInfo): string | null {
const config = this.deps.getConfig();
return this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
}
private async applyUpdate(
job: PendingYoutubeMediaUpdate,
cachedPath: string,
@@ -283,7 +290,7 @@ export class PendingYoutubeMediaQueue {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioField =
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
if (audioField) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
mediaFields[audioField] = this.deps.mergeFieldValue(
@@ -284,6 +284,22 @@ function createMockTracker(
getSessionTimeline: async () => [],
getSessionEvents: async () => [],
getVocabularyStats: async () => VOCABULARY_STATS,
getVocabularySummary: async () => ({
uniqueWords: 501,
uniqueWordsWithoutNames: 500,
uniqueKanji: 201,
newThisWeek: 7,
newThisWeekWithoutNames: 6,
knownWordCount: 250,
knownWordCountWithoutNames: 249,
}),
getVocabularyChartData: async () => ({
ready: true,
topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
}),
getStatsExcludedWords: async () => [],
replaceStatsExcludedWords: async () => {},
getKanjiStats: async () => KANJI_STATS,
@@ -711,6 +727,38 @@ describe('stats server API routes', () => {
assert.equal(body[0].headword, 'する');
});
it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/vocabulary/summary');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
uniqueWords: 501,
uniqueWordsWithoutNames: 500,
uniqueKanji: 201,
newThisWeek: 7,
newThisWeekWithoutNames: 6,
knownWordCount: 250,
knownWordCountWithoutNames: 249,
});
});
it('GET /api/stats/vocabulary/charts returns complete chart datasets', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/vocabulary/charts');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
ready: true,
topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
});
});
it('GET /api/stats/kanji returns kanji frequency data', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/kanji');
+2
View File
@@ -65,6 +65,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -166,6 +167,7 @@ export function registerAnkiJimakuIpcRuntime(
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl,
options.dismissOverlayNotification,
);
integration.start();
options.setAnkiIntegration(integration);
+40
View File
@@ -10,6 +10,8 @@ import {
isAssTemporalCommand,
normalizePlainSubtitleText,
parseAssEffectField,
removeLiveGlyphFragmentLines,
removeAssControlDebrisLines,
} from './ass-text';
test('assToPlainText drops vector drawing runs', () => {
@@ -74,6 +76,14 @@ test('assToPlainText normalizes CRLF before converting', () => {
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
});
test('removeAssControlDebrisLines drops malformed spacer resets without eating dialogue', () => {
assert.equal(
removeAssControlDebrisLines('Visible line\n\\\n{\\fr0\n\\{\\frz287.5'),
'Visible line',
);
assert.equal(removeAssControlDebrisLines('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
});
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
// A brace reaching this layer is literal text mpv chose to show, not markup.
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
@@ -193,3 +203,33 @@ test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
assert.equal(isAnimatedAssEffectKind('other'), false);
assert.equal(isAnimatedAssEffectKind('none'), false);
});
test('removeLiveGlyphFragmentLines drops a per-glyph typesetting wall and its syllable', () => {
const wall = [...'wansdumretoikhI'].join('\n');
assert.equal(removeLiveGlyphFragmentLines(`${wall}\ntai`), '');
});
test('removeLiveGlyphFragmentLines keeps concurrent dialogue beside a glyph wall', () => {
const wall = [...'wansdumretoikhI'].join('\n');
assert.equal(removeLiveGlyphFragmentLines(`${wall}\nそれよりも ノート…`), 'それよりも ノート…');
});
test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => {
const text = 'え\nはい。\nそうだな';
assert.equal(removeLiveGlyphFragmentLines(text), text);
});
test('normalizePlainSubtitleText folds cue-boundary blank lines for text consumers', () => {
// The display layer splits on the blank line before normalizing; everyone else --
// tokenizer, cache key, dedup gate, mined sentence -- wants the plain line form.
assert.equal(
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'),
'\u4e00\u884c\u76ee\n\u4e8c\u884c\u76ee',
);
assert.equal(
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', {
collapseLineBreaks: true,
}),
'\u4e00\u884c\u76ee \u4e8c\u884c\u76ee',
);
});
+39
View File
@@ -91,6 +91,41 @@ export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): st
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
}
const MALFORMED_ASS_ROTATION_RESET = /^\\?\{\\(?:fr|frx|fry|frz|fax|fay)[-+.0-9]*$/u;
/**
* Drop non-rendering spacer events left as literal text by a malformed, unclosed ASS
* rotation reset. These events otherwise become repeated `\\` or `{\\fr0` subtitle
* lines after mpv-compatible decoding.
*/
export function removeAssControlDebrisLines(text: string): string {
return text
.split('\n')
.filter((line) => {
const compact = line.replace(/\s+/gu, '');
return compact !== '\\' && !MALFORMED_ASS_ROTATION_RESET.test(compact);
})
.join('\n');
}
const MIN_GLYPH_BURST_LINES = 6;
const MAX_GLYPH_BURST_COMPANION_GLYPHS = 3;
/**
* Per-glyph karaoke typesetting flattened into live text becomes a wall of
* single-character lines plus the short syllable currently being typed. No authored
* subtitle stacks this many one-glyph lines at once, so when the wall is present drop
* it and its short companion fragments while keeping any concurrent dialogue line.
*/
export function removeLiveGlyphFragmentLines(text: string): string {
const lines = text.split('\n');
const singleGlyphLines = lines.filter((line) => [...line.trim()].length === 1).length;
if (singleGlyphLines < MIN_GLYPH_BURST_LINES) return text;
return lines
.filter((line) => [...line.trim()].length > MAX_GLYPH_BURST_COMPANION_GLYPHS)
.join('\n');
}
export interface NormalizePlainSubtitleTextOptions {
/** Fold every line break into a single space. */
collapseLineBreaks?: boolean;
@@ -118,6 +153,10 @@ export function normalizePlainSubtitleText(
);
if (collapseLineBreaks) {
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
} else {
// Simultaneous cues reach the display layer separated by a blank line; every other
// consumer wants the plain one-break-per-line form.
normalized = normalized.replace(/\n{2,}/g, '\n');
}
return trim ? normalized.trim() : normalized;
@@ -559,6 +559,241 @@ test('fresh tracker DB creates lifetime summary tables', async () => {
}
});
test('fresh tracker DB skips lexical rollup backfill work', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let backfillRuns = 0;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath }, {
runLexicalRollupBackfillTask: async () => {
backfillRuns += 1;
},
} as never);
assert.equal(backfillRuns, 0);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('tracker starts the injected lexical rollup backfill when it is pending', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let backfillRuns = 0;
try {
const setupDb = new Database(dbPath);
const { ensureSchema } = await import('./immersion-tracker/storage');
ensureSchema(setupDb);
setupDb
.prepare(
`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_version'`,
)
.run();
setupDb.close();
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath }, {
runLexicalRollupBackfillTask: async () => {
backfillRuns += 1;
},
} as never);
assert.equal(backfillRuns, 1);
await waitForCondition(
() => !(tracker as unknown as { writeLock: { locked: boolean } }).writeLock.locked,
);
assert.equal(
(tracker as unknown as { preserveWriteQueueUntilDrained: boolean })
.preserveWriteQueueUntilDrained,
false,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('tracker runs startup session-rollup maintenance before lexical backfill locks writes', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let releaseBackfill = (): void => {};
const heldBackfill = new Promise<void>((resolve) => {
releaseBackfill = resolve;
});
try {
const startedAtMs = trackerNowMs() - 60_000;
const endedAtMs = trackerNowMs();
const setupDb = new Database(dbPath);
const { ensureSchema } = await import('./immersion-tracker/storage');
ensureSchema(setupDb);
setupDb.exec(`
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (1, 'local:/tmp/rollup-recovery.mkv', 'Rollup Recovery', 1, 0, '1', '1');
INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status,
active_watched_ms, lines_seen, tokens_seen, cards_mined, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 'rollup-recovery', 1, '${startedAtMs}', '${endedAtMs}', 2,
60000, 10, 20, 2, '${startedAtMs}', '${endedAtMs}'
);
INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, lines_seen,
tokens_seen, cards_mined, lookup_count, lookup_hits, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, '${endedAtMs}', 60000, 60000, 10, 20, 2, 0, 0,
'${endedAtMs}', '${endedAtMs}'
);
DELETE FROM imm_daily_rollups;
DELETE FROM imm_monthly_rollups;
UPDATE imm_rollup_state SET state_value = '0';
`);
setupDb.close();
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath }, {
runLexicalRollupBackfillTask: async () => heldBackfill,
} as never);
const privateApi = tracker as unknown as {
db: DatabaseSync;
writeLock: { locked: boolean };
};
assert.equal(privateApi.writeLock.locked, true);
assert.equal(
(
privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_daily_rollups').get() as {
total: number;
}
).total,
1,
);
assert.equal(
(
privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_monthly_rollups').get() as {
total: number;
}
).total,
1,
);
} finally {
releaseBackfill();
if (tracker) {
await waitForCondition(
() => !(tracker as unknown as { writeLock: { locked: boolean } }).writeLock.locked,
);
}
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('tracker queues playback writes until lexical rollup backfill settles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let startBackfill = (): void => {};
let releaseBackfill = (): void => {};
let markBackfillStarted = (): void => {};
const backfillStartGate = new Promise<void>((resolve) => {
startBackfill = resolve;
});
const heldBackfill = new Promise<void>((resolve) => {
releaseBackfill = resolve;
});
const backfillStarted = new Promise<void>((resolve) => {
markBackfillStarted = resolve;
});
try {
const setupDb = new Database(dbPath);
const { ensureSchema } = await import('./immersion-tracker/storage');
ensureSchema(setupDb);
setupDb
.prepare(
`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_version'`,
)
.run();
setupDb.close();
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath, policy: { queueCap: 100 } },
{
runLexicalRollupBackfillTask: async (workerDbPath) => {
await backfillStartGate;
const workerDb = new Database(workerDbPath);
try {
workerDb.exec('BEGIN IMMEDIATE');
markBackfillStarted();
await heldBackfill;
workerDb.exec('COMMIT');
} catch (error) {
try {
workerDb.exec('ROLLBACK');
} catch {
// Preserve the original worker failure.
}
throw error;
} finally {
workerDb.close();
}
},
},
);
tracker.handleMediaChange('https://example.com/backfill-test.mp4', 'Backfill Test');
startBackfill();
await backfillStarted;
for (let index = 0; index < 125; index += 1) tracker.recordCardsMined(1);
const privateApi = tracker as unknown as {
db: DatabaseSync;
queue: unknown[];
droppedWriteCount: number;
flushNow: () => void;
writeLock: { locked: boolean };
};
assert.equal(privateApi.writeLock.locked, true);
privateApi.flushNow();
assert.ok(privateApi.queue.length > 100, 'the protected queue may grow past its normal cap');
assert.equal(privateApi.droppedWriteCount, 0, 'backfill must not discard playback writes');
assert.equal(
(
privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as {
total: number;
}
).total,
0,
);
releaseBackfill();
await waitForCondition(() => privateApi.queue.length === 0, 5_000);
assert.equal(
(
privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as {
total: number;
}
).total,
125,
);
} finally {
releaseBackfill();
if (tracker) {
await waitForCondition(
() => !(tracker as unknown as { writeLock: { locked: boolean } }).writeLock.locked,
5_000,
);
}
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -4909,3 +5144,149 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary coalesces concurrent requests into one worker task', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let taskRuns = 0;
let releaseTask: (() => void) | null = null;
const seenKnownWords: Array<ReadonlySet<string> | null> = [];
const summary = {
uniqueWords: 1,
uniqueWordsWithoutNames: 1,
uniqueKanji: 0,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: null,
knownWordCountWithoutNames: null,
};
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath },
{
runVocabularySummaryTask: async (_dbPath, knownWords) => {
taskRuns += 1;
seenKnownWords.push(knownWords);
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
return summary;
},
destroyVocabularySummaryRunner: () => {},
},
);
const knownWordsSnapshot = new Set(['猫']);
const first = tracker.getVocabularySummary(knownWordsSnapshot);
const second = tracker.getVocabularySummary(knownWordsSnapshot);
await waitForCondition(() => releaseTask !== null);
let release = releaseTask as (() => void) | null;
assert.ok(release);
release();
assert.deepEqual(await first, summary);
assert.equal(await second, await first);
assert.equal(taskRuns, 1);
assert.deepEqual(seenKnownWords, [knownWordsSnapshot]);
releaseTask = null;
const third = tracker.getVocabularySummary(null);
await waitForCondition(() => releaseTask !== null);
release = releaseTask as (() => void) | null;
assert.ok(release);
release();
assert.deepEqual(await third, summary);
assert.equal(taskRuns, 2);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary coalesces equivalent known-word snapshots by value', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let taskRuns = 0;
const releases: Array<() => void> = [];
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath },
{
runVocabularySummaryTask: async () => {
taskRuns += 1;
await new Promise<void>((resolve) => releases.push(resolve));
return {
uniqueWords: 2,
uniqueWordsWithoutNames: 2,
uniqueKanji: 2,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: 2,
knownWordCountWithoutNames: 2,
};
},
destroyVocabularySummaryRunner: () => {},
},
);
const first = tracker.getVocabularySummary(new Set(['猫', '犬']));
const second = tracker.getVocabularySummary(new Set(['犬', '猫']));
await waitForCondition(() => releases.length > 0);
const observedTaskRuns = taskRuns;
for (const release of releases) release();
await Promise.all([first, second]);
assert.equal(observedTaskRuns, 1);
const third = tracker.getVocabularySummary(new Set(['猫', '犬']));
await waitForCondition(() => releases.length === 2);
releases[1]!();
await third;
assert.equal(taskRuns, 2, 'a settled snapshot must be evicted from the in-flight map');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary keeps different known-word snapshots independent', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
const releases: Array<() => void> = [];
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath },
{
runVocabularySummaryTask: async (_dbPath, knownWords) => {
await new Promise<void>((resolve) => releases.push(resolve));
return {
uniqueWords: 1,
uniqueWordsWithoutNames: 1,
uniqueKanji: 0,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: knownWords?.size ?? null,
knownWordCountWithoutNames: knownWords?.size ?? null,
};
},
destroyVocabularySummaryRunner: () => {},
},
);
const withoutKnownWords = tracker.getVocabularySummary(null);
const withKnownWords = tracker.getVocabularySummary(new Set(['猫']));
await waitForCondition(() => releases.length === 2);
for (const release of releases) release();
assert.equal((await withoutKnownWords).knownWordCount, null);
assert.equal((await withKnownWords).knownWordCount, 1);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
+106 -6
View File
@@ -58,6 +58,7 @@ import {
getSessionEvents,
getSimilarWords,
getStatsExcludedWords,
getVocabularyChartData,
getVocabularyStats,
replaceStatsExcludedWords,
searchSubtitleSentences,
@@ -96,6 +97,12 @@ import {
DeleteMaintenanceWorkerRuntime,
type RunDeleteMaintenanceTask,
} from './immersion-tracker/delete-maintenance-worker-runtime';
import {
VocabularySummaryWorkerRuntime,
type RunVocabularySummaryTask,
} from './immersion-tracker/vocabulary-summary-worker-runtime';
import { LexicalRollupWorkerRuntime } from './immersion-tracker/lexical-rollup-worker-runtime';
import { areLexicalDailyRollupsReady } from './immersion-tracker/lexical-rollups';
import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler';
import {
cleanupDuplicateSubtitleLines,
@@ -185,6 +192,7 @@ import {
type StatsExcludedWordRow,
type StreakCalendarRow,
type VocabularyCleanupSummary,
type VocabularyStatsSummary,
type WatchTimePerAnimeRow,
type WordAnimeAppearanceRow,
type WordDetailRow,
@@ -405,13 +413,24 @@ export class ImmersionTrackerService {
private readonly monthlyRollupRetentionMs: number;
private readonly vacuumIntervalMs: number;
private readonly dbPath: string;
private readonly writeLock = { locked: false };
private readonly writeLock = {
locked: false,
reasons: new Set<'flush' | 'delete-maintenance' | 'lexical-rollup-backfill'>(),
};
private readonly destroyDeleteMaintenanceRunner: () => void;
private readonly runVocabularySummaryTask: (
knownWords: ReadonlySet<string> | null,
) => Promise<VocabularyStatsSummary>;
private readonly vocabularySummariesInFlight = new Map<string, Promise<VocabularyStatsSummary>>();
private readonly destroyVocabularySummaryRunner: () => void;
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
private readonly destroyLexicalRollupBackfillRunner: () => void;
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
private flushScheduled = false;
private droppedWriteCount = 0;
private preserveWriteQueueUntilDrained = false;
private lastVacuumMs = 0;
private isDestroyed = false;
private sessionState: SessionState | null = null;
@@ -434,6 +453,10 @@ export class ImmersionTrackerService {
dependencies: {
runDeleteMaintenanceTask?: RunDeleteMaintenanceTask;
destroyDeleteMaintenanceRunner?: () => void;
runVocabularySummaryTask?: RunVocabularySummaryTask;
destroyVocabularySummaryRunner?: () => void;
runLexicalRollupBackfillTask?: (dbPath: string) => Promise<void>;
destroyLexicalRollupBackfillRunner?: () => void;
} = {},
) {
this.dbPath = options.dbPath;
@@ -453,13 +476,34 @@ export class ImmersionTrackerService {
runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task),
onBusy: () => {
this.requireWriteQueueDrained('delete maintenance');
this.writeLock.locked = true;
this.setWriteLock('delete-maintenance', true);
},
onIdle: () => {
this.writeLock.locked = false;
this.setWriteLock('delete-maintenance', false);
if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0);
},
});
if (dependencies.runVocabularySummaryTask) {
this.runVocabularySummaryTask = (knownWords) =>
dependencies.runVocabularySummaryTask!(this.dbPath, knownWords);
this.destroyVocabularySummaryRunner =
dependencies.destroyVocabularySummaryRunner ?? (() => {});
} else {
const vocabularySummaryRuntime = new VocabularySummaryWorkerRuntime();
this.runVocabularySummaryTask = (knownWords) =>
vocabularySummaryRuntime.run(this.dbPath, knownWords);
this.destroyVocabularySummaryRunner = () => vocabularySummaryRuntime.destroy();
}
if (dependencies.runLexicalRollupBackfillTask) {
this.runLexicalRollupBackfillTask = () =>
dependencies.runLexicalRollupBackfillTask!(this.dbPath);
this.destroyLexicalRollupBackfillRunner =
dependencies.destroyLexicalRollupBackfillRunner ?? (() => {});
} else {
const lexicalRollupRuntime = new LexicalRollupWorkerRuntime();
this.runLexicalRollupBackfillTask = () => lexicalRollupRuntime.run(this.dbPath);
this.destroyLexicalRollupBackfillRunner = () => lexicalRollupRuntime.destroy();
}
const parentDir = path.dirname(this.dbPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
@@ -548,6 +592,7 @@ export class ImmersionTrackerService {
}
this.preparedStatements = createTrackerPreparedStatements(this.db);
this.scheduleMaintenance();
if (!areLexicalDailyRollupsReady(this.db)) this.startLexicalRollupBackfill();
this.scheduleFlush();
}
@@ -565,6 +610,8 @@ export class ImmersionTrackerService {
this.isDestroyed = true;
this.deleteMaintenanceScheduler.destroy();
this.destroyDeleteMaintenanceRunner();
this.destroyVocabularySummaryRunner();
this.destroyLexicalRollupBackfillRunner();
this.db.close();
}
@@ -634,6 +681,25 @@ export class ImmersionTrackerService {
return getVocabularyStats(this.db, limit, excludePos);
}
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
const key = knownWords ? JSON.stringify([...knownWords].sort()) : 'null';
const inFlight = this.vocabularySummariesInFlight.get(key);
if (inFlight) return inFlight;
const task = this.runVocabularySummaryTask(knownWords);
this.vocabularySummariesInFlight.set(key, task);
try {
return await task;
} finally {
if (this.vocabularySummariesInFlight.get(key) === task) {
this.vocabularySummariesInFlight.delete(key);
}
}
}
async getVocabularyChartData() {
return getVocabularyChartData(this.db);
}
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
return getStatsExcludedWords(this.db);
}
@@ -910,6 +976,33 @@ export class ImmersionTrackerService {
}
}
private setWriteLock(
reason: 'flush' | 'delete-maintenance' | 'lexical-rollup-backfill',
active: boolean,
): void {
if (active) this.writeLock.reasons.add(reason);
else this.writeLock.reasons.delete(reason);
this.writeLock.locked = this.writeLock.reasons.size > 0;
}
private startLexicalRollupBackfill(): void {
this.requireWriteQueueDrained('lexical rollup backfill');
this.preserveWriteQueueUntilDrained = true;
this.setWriteLock('lexical-rollup-backfill', true);
void this.runLexicalRollupBackfillTask()
.catch((error: unknown) => {
this.logger.warn(
'Lexical daily rollup backfill failed; it will retry on next startup',
error,
);
})
.finally(() => {
this.setWriteLock('lexical-rollup-backfill', false);
if (this.queue.length === 0) this.preserveWriteQueueUntilDrained = false;
else if (!this.isDestroyed) this.scheduleFlush(0);
});
}
async reassignAnimeAnilist(
animeId: number,
info: {
@@ -1906,7 +1999,12 @@ export class ImmersionTrackerService {
private recordWrite(write: QueuedWrite): void {
if (this.isDestroyed) return;
const { dropped } = enqueueWrite(this.queue, write, this.queueCap);
// A lexical migration owns the database write lock, so dropping the oldest
// entry cannot relieve pressure: nothing can flush until the worker exits.
// Preserve that finite startup burst and drain it as soon as the lock lifts.
const { dropped } = this.preserveWriteQueueUntilDrained
? (this.queue.push(write), { dropped: 0 })
: enqueueWrite(this.queue, write, this.queueCap);
if (dropped > 0) {
this.droppedWriteCount += dropped;
this.logger.warn(`Immersion tracker queue overflow; dropped ${dropped} oldest writes`);
@@ -1954,6 +2052,7 @@ export class ImmersionTrackerService {
private flushNow(): void {
if (this.writeLock.locked || this.isDestroyed) return;
if (this.queue.length === 0) {
this.preserveWriteQueueUntilDrained = false;
this.flushScheduled = false;
return;
}
@@ -1965,7 +2064,7 @@ export class ImmersionTrackerService {
}
const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length));
this.writeLock.locked = true;
this.setWriteLock('flush', true);
try {
this.db.exec('BEGIN IMMEDIATE');
for (const write of batch) {
@@ -1977,8 +2076,9 @@ export class ImmersionTrackerService {
this.queue.unshift(...batch);
this.logger.warn('Immersion tracker flush failed, retrying later', error as Error);
} finally {
this.writeLock.locked = false;
this.setWriteLock('flush', false);
this.flushScheduled = false;
if (this.queue.length === 0) this.preserveWriteQueueUntilDrained = false;
if (this.queue.length > 0) {
this.scheduleFlush(this.flushIntervalMs);
}
@@ -1,7 +1,4 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
@@ -21,17 +18,6 @@ interface SeedLine {
createdMs?: number;
}
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-duplicate-line-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
/** One episode, two sessions of it, and one word occurrence per seeded line. */
function seed(db: DatabaseSync, lines: SeedLine[]): void {
db.exec(`
@@ -82,12 +68,16 @@ function seed(db: DatabaseSync, lines: SeedLine[]): void {
`);
}
function createDb(lines: SeedLine[]): { db: DatabaseSync; dbPath: string } {
const dbPath = makeDbPath();
const db = new Database(dbPath);
/**
* These tests exercise the cleanup SQL, not durability. A fresh on-disk database per
* test pays a schema-creation fsync that is cheap on a local NVMe but slow enough on CI
* runners to blow the 5s per-test timeout, so the database stays in memory.
*/
function createDb(lines: SeedLine[]): { db: DatabaseSync } {
const db = new Database(':memory:');
ensureSchema(db);
seed(db, lines);
return { db, dbPath };
return { db };
}
/** A typeset line mpv reported once per animation frame. */
@@ -119,7 +109,7 @@ function wordFrequency(db: DatabaseSync): number {
}
test('a karaoke burst collapses to one line and gives back its word counts', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 40, 40),
{ session: 1, text: 'おはよう', startMs: 20_000, endMs: 22_000 },
]);
@@ -148,7 +138,6 @@ test('a karaoke burst collapses to one line and gives back its word counts', ()
assert.equal(summary.samples[0]!.videoTitle, 'Ep 1');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -160,7 +149,7 @@ test('ordinary repeated dialogue survives', () => {
startMs: 5_000 + index * 800,
endMs: 5_000 + (index + 1) * 800,
}));
const { db, dbPath } = createDb(lines);
const { db } = createDb(lines);
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -171,14 +160,13 @@ test('ordinary repeated dialogue survives', () => {
assert.equal(wordFrequency(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a long run of quarter-second frames is still a burst', () => {
// Between the timing-only bound (0.1s) and the animation-frame bound (0.3s): heavier
// typesetting lands here, and the run length is what makes it conclusive.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -189,12 +177,11 @@ test('a long run of quarter-second frames is still a burst', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a qualifying short-frame burst may end with one long hold frame', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 8, 40),
{ session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 },
]);
@@ -208,12 +195,11 @@ test('a qualifying short-frame burst may end with one long hold frame', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a long event before the final frame prevents burst cleanup', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 5, 40),
{ session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 },
{ session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 },
@@ -226,12 +212,11 @@ test('a long event before the final frame prevents burst cleanup', () => {
assert.equal(countLines(db), 7);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a run of frames longer than the animation bound survives', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -240,7 +225,6 @@ test('a run of frames longer than the animation bound survives', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -248,7 +232,7 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
// The streaming gate records the first four frames of a burst before the run is long
// enough to recognise. Four contiguous identical events under the strict timing-only
// bound are that residue, and no real dialogue.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -259,14 +243,13 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a four-frame run above the strict frame bound survives', () => {
// Long enough per event to be plausible dialogue; only a five-event run may use the
// looser animation-frame bound.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -275,14 +258,13 @@ test('a four-frame run above the strict frame bound survives', () => {
assert.equal(countLines(db), 4);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('an explicit minRunLength raises the bar', () => {
// Five quarter-second frames qualify under the defaults; a cautious run asking for six
// leaves them alone. Above the strict bound, so the residue rule stays out of it.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
try {
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
@@ -293,12 +275,11 @@ test('an explicit minRunLength raises the bar', () => {
assert.equal(countLines(db), 5);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('an explicit maxFrameSeconds tightens the frame bound', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: 0.2 });
@@ -307,13 +288,12 @@ test('an explicit maxFrameSeconds tightens the frame bound', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a non-finite maxFrameSeconds falls back to the default bound', () => {
// Six normal-beat lines: Infinity must not turn every event into a "short frame".
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
try {
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: Infinity });
@@ -322,12 +302,11 @@ test('a non-finite maxFrameSeconds falls back to the default bound', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('sampleLimit zero removes bursts but reports no samples', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db, { sampleLimit: 0 });
@@ -337,12 +316,11 @@ test('sampleLimit zero removes bursts but reports no samples', () => {
assert.equal(countLines(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a short run below every threshold survives', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -351,7 +329,6 @@ test('a short run below every threshold survives', () => {
assert.equal(countLines(db), 3);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -361,7 +338,7 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
const kanji = karaokeFrames(1, '飛び上がる', 10_000, 20, 60);
const romaji = karaokeFrames(1, 'tobiagaru', 10_001, 20, 60);
const interleaved = [...kanji, ...romaji].sort((a, b) => a.startMs - b.startMs);
const { db, dbPath } = createDb(interleaved);
const { db } = createDb(interleaved);
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -372,12 +349,11 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
assert.equal(wordFrequency(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('the same line in a rewatch session is never merged into the first watch', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
...karaokeFrames(2, '飛び上がる', 10_000, 6, 40),
]);
@@ -392,12 +368,11 @@ test('the same line in a rewatch session is never merged into the first watch',
assert.equal(wordFrequency(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a gap between runs splits them', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
...karaokeFrames(1, '飛び上がる', 60_000, 6, 40),
]);
@@ -409,12 +384,11 @@ test('a gap between runs splits them', () => {
assert.equal(countLines(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a dry run reports what an apply would do and writes nothing', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
try {
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
@@ -430,14 +404,13 @@ test('a dry run reports what an apply would do and writes nothing', () => {
assert.equal(countLines(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('the lookback window leaves older bursts alone', () => {
const recentMs = BASE_MS;
const oldMs = BASE_MS - 40 * DAY_MS;
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40).map((line) => ({
...line,
createdMs: oldMs,
@@ -462,6 +435,5 @@ test('the lookback window leaves older bursts alone', () => {
} finally {
globalThis.__subminerTestNowMs = undefined;
db.close();
cleanupDbPath(dbPath);
}
});
@@ -31,6 +31,7 @@ import {
getKanjiOccurrences,
getSessionSummaries,
getVocabularyStats,
getVocabularySummary,
getKanjiStats,
getSessionEvents,
getSessionTimeline,
@@ -1875,6 +1876,115 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
}
});
test('getVocabularySummary counts every tracked vocabulary row instead of a display page', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const nowSec = Math.floor(Date.now() / 1000);
const insertWord = db.prepare(`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES (?, ?, '', 'noun', '名詞', '一般', '', ?, ?, 1)
`);
const insertKanji = db.prepare(`
INSERT INTO imm_kanji (kanji, first_seen, last_seen, frequency)
VALUES (?, ?, ?, 1)
`);
for (let index = 0; index < 501; index += 1) {
insertWord.run(`単語${index}`, `単語${index}`, nowSec - 8 * 86_400, nowSec - 8 * 86_400);
}
for (let index = 0; index < 201; index += 1) {
insertKanji.run(
String.fromCodePoint(0x4e00 + index),
nowSec - 8 * 86_400,
nowSec - 8 * 86_400,
);
}
insertWord.run('今週', '今週', nowSec - 86_400, nowSec - 86_400);
assert.deepEqual(getVocabularySummary(db, new Set(['単語0', '今週']), nowSec * 1000), {
uniqueWords: 502,
uniqueWordsWithoutNames: 502,
uniqueKanji: 201,
newThisWeek: 1,
newThisWeekWithoutNames: 1,
knownWordCount: 2,
knownWordCountWithoutNames: 2,
});
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary applies vocabulary exclusions and Hide Names totals', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES (?, ?, '', 'noun', '名詞', ?, '', 1, 1, 1)
`);
insertWord.run('猫', '猫', '一般');
insertWord.run('太郎', '太郎', '固有名詞');
insertWord.run('東京', '東京都', '一般');
db.prepare(
`
INSERT INTO imm_stats_excluded_words (headword, word, reading)
VALUES ('東京', '東京', '')
`,
).run();
assert.deepEqual(getVocabularySummary(db, new Set(['猫', '太郎', '東京']), 9 * 86_400_000), {
uniqueWords: 2,
uniqueWordsWithoutNames: 1,
uniqueKanji: 0,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: 2,
knownWordCountWithoutNames: 1,
});
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary counts identically across id-keyed scan batches', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES (?, ?, '', 'noun', '名詞', '一般', '', 1, 1, 1)
`);
for (let index = 0; index < 5; index += 1) {
insertWord.run(`単語${index}`, `単語${index}`);
}
const fullScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000);
const batchedScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000, 2);
assert.equal(fullScan.uniqueWords, 5);
assert.deepEqual(batchedScan, fullScan);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
LexicalRollupWorkerRuntime,
resolveLexicalRollupWorkerPath,
} from './lexical-rollup-worker-runtime';
import { areLexicalDailyRollupsReady } from './lexical-rollups';
import { Database } from './sqlite';
import { applyPragmas, ensureSchema } from './storage';
test('lexical rollup worker backfills without using the tracker connection', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollup-runtime-'));
const dbPath = path.join(directory, 'immersion.sqlite');
const runtime = new LexicalRollupWorkerRuntime();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES ('鳥', '鳥', 'とり', 1700000000, 1700000000, 1)`,
).run();
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.prepare(`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = ?`).run(
'lexical_daily_rollups_version',
);
db.close();
await runtime.run(dbPath);
const checkDb = new Database(dbPath);
try {
assert.equal(areLexicalDailyRollupsReady(checkDb), true);
} finally {
checkDb.close();
}
} finally {
runtime.destroy();
try {
db.close();
} catch {
// Closed before the worker starts.
}
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('lexical rollup worker module resolves in the current layout', () => {
const workerPath = resolveLexicalRollupWorkerPath();
assert.ok(workerPath, 'expected the lexical rollup worker module to resolve');
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
});
test('lexical rollup worker leaves a backfill pending when no worker can start', async () => {
const runtime = new LexicalRollupWorkerRuntime({
resolveWorkerPath: () => null,
warn: () => {},
} as never);
try {
await assert.doesNotReject(runtime.run('/tmp/not-used.sqlite'));
} finally {
runtime.destroy();
}
});
test('lexical rollup worker absorbs termination failures after settling', async () => {
let sendMessage: ((message: { ok: boolean }) => void) | null = null;
const runtime = new LexicalRollupWorkerRuntime({
resolveWorkerPath: () => '/tmp/fake-worker.js',
createWorker: async () => ({
once(event: string, listener: (value: never) => void) {
if (event === 'message') sendMessage = listener as (message: { ok: boolean }) => void;
return this;
},
terminate: async () => {
throw new Error('termination failed');
},
}),
warn: () => {},
} as never);
const unhandled: unknown[] = [];
const captureUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', captureUnhandled);
try {
const task = runtime.run('/tmp/not-used.sqlite');
await new Promise((resolve) => setImmediate(resolve));
const notify = sendMessage as ((message: { ok: boolean }) => void) | null;
assert.ok(notify);
notify({ ok: true });
await task;
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled, []);
} finally {
process.off('unhandledRejection', captureUnhandled);
runtime.destroy();
}
});
test('lexical rollup worker times out when it never responds', async () => {
let terminated = false;
const runtime = new LexicalRollupWorkerRuntime({
resolveWorkerPath: () => '/tmp/fake-worker.js',
createWorker: async () => ({
once() {
return this;
},
terminate: async () => {
terminated = true;
return 0;
},
}),
timeoutMs: 1,
warn: () => {},
} as never);
try {
const outcome = await Promise.race([
runtime.run('/tmp/not-used.sqlite').then(
() => 'resolved',
(error: unknown) => String(error),
),
new Promise<string>((resolve) => setTimeout(() => resolve('still pending'), 50)),
]);
assert.match(outcome, /timed out/);
assert.equal(terminated, true);
} finally {
runtime.destroy();
}
});
@@ -0,0 +1,117 @@
import fs from 'node:fs';
import path from 'node:path';
import { createLogger } from '../../../logger';
interface WorkerResponse {
ok?: boolean;
error?: unknown;
}
interface WorkerHandle {
once(event: 'message', listener: (message: WorkerResponse) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
terminate(): Promise<number>;
}
interface LexicalRollupWorkerRuntimeOptions {
resolveWorkerPath?: () => string | null;
createWorker?: (workerPath: string, workerData: { dbPath: string }) => Promise<WorkerHandle>;
timeoutMs?: number;
warn?: (message: string, ...meta: unknown[]) => void;
}
const logger = createLogger('main:immersion-tracker:lexical-rollup-worker');
const DEFAULT_WORKER_TIMEOUT_MS = 5 * 60 * 1_000;
export function resolveLexicalRollupWorkerPath(): string | null {
const fileName = __filename.endsWith('.ts')
? 'lexical-rollup-worker-thread.ts'
: 'lexical-rollup-worker-thread.js';
const workerPath = path.join(__dirname, fileName);
return fs.existsSync(workerPath) ? workerPath : null;
}
export class LexicalRollupWorkerRuntime {
private readonly activeWorkers = new Set<WorkerHandle>();
private destroyed = false;
constructor(private readonly options: LexicalRollupWorkerRuntimeOptions = {}) {}
async run(dbPath: string): Promise<void> {
if (this.destroyed) throw new Error('Lexical rollup worker is shut down');
let worker: WorkerHandle;
try {
const workerPath = (this.options.resolveWorkerPath ?? resolveLexicalRollupWorkerPath)();
if (!workerPath) throw new Error('Emitted lexical rollup worker module was not found');
const createWorker =
this.options.createWorker ??
(async (resolvedPath, workerData) => {
const { Worker } = await import('node:worker_threads');
return new Worker(resolvedPath, { workerData });
});
worker = await createWorker(workerPath, { dbPath });
} catch (error) {
if (this.destroyed) throw new Error('Lexical rollup worker is shut down');
(this.options.warn ?? logger.warn)(
'Lexical rollup worker unavailable; leaving backfill pending for a later startup',
error,
);
return;
}
if (this.destroyed) {
await worker.terminate().catch(() => undefined);
throw new Error('Lexical rollup worker is shut down');
}
return new Promise<void>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
this.activeWorkers.add(worker);
const settle = (error?: Error) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
this.activeWorkers.delete(worker);
void worker.terminate().catch(() => undefined);
if (error) reject(error);
else resolve();
};
timeout = setTimeout(
() => settle(new Error('Lexical rollup worker timed out')),
this.options.timeoutMs ?? DEFAULT_WORKER_TIMEOUT_MS,
);
worker.once('message', (message) => {
if (message.ok) settle();
else
settle(
new Error(
`Lexical rollup backfill failed: ${String(message.error ?? 'unknown error')}`,
),
);
});
worker.once('error', (error) => settle(error));
worker.once('exit', (code) => {
if (!settled) {
settle(
new Error(
code === 0
? 'Lexical rollup worker exited without a response'
: `Lexical rollup worker exited with code ${code}`,
),
);
}
});
});
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
for (const worker of this.activeWorkers) {
void worker.terminate().catch(() => undefined);
}
this.activeWorkers.clear();
}
}
@@ -0,0 +1,11 @@
import { parentPort, workerData } from 'node:worker_threads';
import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker';
if (!parentPort) throw new Error('lexical rollup worker missing parent port');
try {
executeLexicalRollupBackfillTask((workerData as { dbPath: string }).dbPath);
parentPort.postMessage({ ok: true });
} catch (error) {
parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';
import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker';
import { Database } from './sqlite';
import { ensureSchema } from './storage';
test('lexical rollup backfill materializes pre-existing vocabulary off the caller DB connection', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollup-worker-'));
const dbPath = path.join(directory, 'immersion.sqlite');
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, ?, ?, ?, 1)`,
).run('犬', '犬', 'いぬ', 1_700_000_000, 1_700_000_000);
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.prepare(`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = ?`).run(
'lexical_daily_rollups_version',
);
executeLexicalRollupBackfillTask(dbPath);
assert.equal(areLexicalDailyRollupsReady(db), true);
assert.equal(getLexicalDailyRollups(db)[0]?.wordCount, 1);
} finally {
db.close();
fs.rmSync(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,15 @@
import { areLexicalDailyRollupsReady, rebuildLexicalDailyRollups } from './lexical-rollups';
import { Database } from './sqlite';
import { applyPragmas } from './storage';
export function executeLexicalRollupBackfillTask(dbPath: string): void {
const db = new Database(dbPath);
try {
applyPragmas(db);
if (!areLexicalDailyRollupsReady(db)) {
rebuildLexicalDailyRollups(db);
}
} finally {
db.close();
}
}
@@ -0,0 +1,421 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
rebuildLexicalDailyRollups,
} from './lexical-rollups';
import { getTrendsDashboard } from './query-trends';
import {
getVocabularyChartData,
getVocabularySummary,
replaceStatsExcludedWords,
} from './query-lexical';
import { Database } from './sqlite';
import type { DatabaseSync } from './sqlite';
import { ensureSchema } from './storage';
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
return path.join(dir, 'immersion.sqlite');
}
test('lexical daily rollups follow first-seen corrections and deletions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const firstDay = 19_500;
const correctedDay = firstDay + 2;
const firstSeen = firstDay * 86_400 + 43_200;
const correctedSeen = correctedDay * 86_400 + 43_200;
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, ?, ?, ?, 1)`,
).run('猫', '猫', 'ねこ', firstSeen, firstSeen);
db.prepare(
`INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency)
VALUES (?, ?, ?, 1)`,
).run('猫', firstSeen, firstSeen);
assert.deepEqual(getLexicalDailyRollups(db), [
{ epochDay: firstDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 1 },
]);
db.prepare(`UPDATE imm_words SET first_seen = ? WHERE headword = ?`).run(correctedSeen, '猫');
db.prepare(`DELETE FROM imm_kanji WHERE kanji = ?`).run('猫');
assert.deepEqual(getLexicalDailyRollups(db), [
{ epochDay: correctedDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 0 },
]);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('lexical daily rollups normalize second and millisecond timestamps', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const epochDay = 19_500;
const timestampSeconds = epochDay * 86_400 + 43_200;
const timestampMilliseconds = timestampSeconds * 1_000;
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, ?, ?, ?, 1)`,
).run('猫', '猫', 'ねこ', timestampSeconds, timestampSeconds);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, ?, ?, ?, 1)`,
).run('犬', '犬', 'いぬ', timestampMilliseconds, timestampMilliseconds);
db.prepare(
`INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency)
VALUES (?, ?, ?, 1)`,
).run('猫', timestampSeconds, timestampSeconds);
db.prepare(
`INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency)
VALUES (?, ?, ?, 1)`,
).run('犬', timestampMilliseconds, timestampMilliseconds);
assert.deepEqual(getLexicalDailyRollups(db), [
{ epochDay, wordCount: 2, wordCountWithoutNames: 2, kanjiCount: 2 },
]);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rules', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const epochDay = 19_500;
const firstSeen = epochDay * 86_400 + 43_200;
db.prepare(
`INSERT INTO imm_words(
headword, word, reading, part_of_speech, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, 1)`,
).run('猫', '猫', 'ねこ', 'noun', firstSeen, firstSeen);
db.prepare(
`INSERT INTO imm_words(
headword, word, reading, part_of_speech, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, 1)`,
).run('は', 'は', 'は', 'particle', firstSeen, firstSeen);
rebuildLexicalDailyRollups(db);
assert.deepEqual(getLexicalDailyRollups(db), [
{ epochDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 0 },
]);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (NULL, NULL, NULL, 1700000000, 1700000000, 1)`,
).run();
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (NULL, '猫', 'ねこ', 1700000000, 1700000000, 1)`,
).run();
assert.doesNotThrow(() => rebuildLexicalDailyRollups(db));
assert.equal(areLexicalDailyRollupsReady(db), true);
assert.equal(getVocabularySummary(db, null).uniqueWords, 1);
assert.equal(getVocabularySummary(db, new Set(['猫'])).knownWordCount, 1);
assert.equal(getVocabularyChartData(db).topWords[0]?.headword, '猫');
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('lexical rollup rebuild scans vocabulary visibility in bounded id batches', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const expectedBatchSize = 5_000;
try {
ensureSchema(db);
const insertWord = db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, '', 1700000000, 1700000000, 1)`,
);
db.exec('BEGIN');
for (let index = 0; index <= expectedBatchSize; index += 1) {
insertWord.run(`${index}`, `${index}`);
}
db.exec('COMMIT');
const scanPageSizes: number[] = [];
const instrumentedDb: DatabaseSync = {
prepare(source) {
const statement = db.prepare(source);
if (!source.includes('WHERE id > ?') || !source.includes('ORDER BY id')) {
return statement;
}
return {
run: (...params) => statement.run(...params),
get: (...params) => statement.get(...params),
all: (...params) => {
const rows = statement.all(...params);
scanPageSizes.push(rows.length);
return rows;
},
};
},
exec(source) {
db.exec(source);
return instrumentedDb;
},
close() {
return instrumentedDb;
},
};
rebuildLexicalDailyRollups(instrumentedDb);
assert.deepEqual(scanPageSizes, [expectedBatchSize, 1]);
assert.equal(getVocabularySummary(db, null).uniqueWords, expectedBatchSize + 1);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('chart exclusions do not subtract vocabulary rows already hidden from the rollup', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const epochDay = 19_500;
const firstSeen = epochDay * 86_400 + 43_200;
db.prepare(
`INSERT INTO imm_words(
headword, word, reading, part_of_speech, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, 1)`,
).run('猫', '猫', 'ねこ', 'noun', firstSeen, firstSeen);
db.prepare(
`INSERT INTO imm_words(
headword, word, reading, part_of_speech, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, 1)`,
).run('は', 'は', 'は', 'particle', firstSeen, firstSeen);
rebuildLexicalDailyRollups(db);
replaceStatsExcludedWords(db, [{ headword: 'は', word: 'は', reading: 'は' }]);
const charts = getVocabularyChartData(db);
assert.deepEqual(charts.newWordsTimeline, [{ epochDay, wordCount: 1 }]);
assert.deepEqual(charts.newWordsTimelineWithoutNames, [{ epochDay, wordCount: 1 }]);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('legacy lexical rollup readiness does not satisfy the current rollup version', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('lexical_daily_rollups_ready', '1')
ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`,
).run();
db.prepare(
`DELETE FROM imm_rollup_state WHERE state_key = 'lexical_daily_rollups_version'`,
).run();
assert.equal(areLexicalDailyRollupsReady(db), false);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('current lexical rollup readiness accepts legacy integer state storage', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
db.exec(`
CREATE TABLE imm_rollup_state(
state_key TEXT PRIMARY KEY,
state_value INTEGER NOT NULL
);
INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('lexical_daily_rollups_version', 2);
`);
assert.equal(areLexicalDailyRollupsReady(db), true);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('imm_words persists vocabulary visibility for rollup maintenance', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const columns = db.prepare(`PRAGMA table_info(imm_words)`).all() as Array<{ name: string }>;
assert.equal(
columns.some((column) => column.name === 'vocabulary_visible'),
true,
);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('vocabulary charts use complete top-word and lexical rollup data', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, '', 1700000000, 1700000000, ?)`,
);
for (let index = 0; index < 501; index += 1) {
insertWord.run(`${index}`, `${index}`, index === 500 ? 10_000 : 1);
}
const charts = getVocabularyChartData(db);
assert.equal(charts.topWords[0]?.headword, '語500');
assert.equal(charts.topWords[0]?.frequency, 10_000);
assert.equal(charts.newWordsTimeline[0]?.wordCount, 501);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('vocabulary charts find full top-word sets beyond excluded and name rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(
`INSERT INTO imm_words(headword, word, reading, pos2, first_seen, last_seen, frequency)
VALUES (?, ?, '', ?, 1700000000, 1700000000, ?)`,
);
const exclusions = [];
for (let index = 0; index < 100; index += 1) {
const headword = `${index}`;
insertWord.run(
headword,
headword,
index < 80 && index >= 60 ? '固有名詞' : '一般',
100 - index,
);
if (index < 60) exclusions.push({ headword, word: headword, reading: '' });
}
replaceStatsExcludedWords(db, exclusions);
const charts = getVocabularyChartData(db);
assert.equal(charts.topWords.length, 12);
assert.equal(charts.topWords[0]?.headword, '語60');
assert.equal(charts.topWordsWithoutNames.length, 12);
assert.equal(charts.topWordsWithoutNames[0]?.headword, '語80');
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES ('語0', '語0', '', 1700000000, 1700000000, 1)`,
).run();
const exclusions = Array.from({ length: 10_923 }, (_, index) => ({
headword: `${index}`,
word: `${index}`,
reading: '',
}));
replaceStatsExcludedWords(db, exclusions);
const charts = getVocabularyChartData(db);
assert.deepEqual(charts.topWords, []);
assert.deepEqual(charts.newWordsTimeline, []);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('lexical rollup rebuild preserves the original error when rollback also fails', () => {
const originalError = new Error('rebuild failed');
const db = {
exec(sql: string) {
if (sql === 'BEGIN IMMEDIATE') return;
if (sql === 'ROLLBACK') throw new Error('rollback failed');
throw originalError;
},
prepare() {
return { all: () => [], run: () => undefined };
},
} as unknown as DatabaseSync;
assert.throws(() => rebuildLexicalDailyRollups(db), originalError);
});
test('trends read historical new-word buckets from lexical rollups', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES ('海', '海', 'うみ', 1700000000, 1700000000, 1)`,
).run();
db.prepare(`UPDATE imm_lexical_daily_rollups SET word_count = 9`).run();
const dashboard = getTrendsDashboard(db, 'all', 'day', false);
assert.equal(dashboard.progress.newWords[0]?.value, 9);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
@@ -0,0 +1,229 @@
import type { DatabaseSync } from './sqlite';
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
export interface LexicalDailyRollup {
epochDay: number;
wordCount: number;
wordCountWithoutNames: number;
kanjiCount: number;
}
const LOCAL_EPOCH_DAY_SQL = `
CAST(julianday(
CASE
WHEN ABS(CAST(%VALUE% AS REAL)) >= 10000000000 THEN CAST(%VALUE% AS REAL) / 1000
ELSE CAST(%VALUE% AS REAL)
END,
'unixepoch', 'localtime'
) - 2440587.5 AS INTEGER)
`;
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
export function localEpochDaySql(value: string): string {
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
}
function createWordRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen');
db.exec(`
DROP TRIGGER IF EXISTS imm_words_lexical_rollup_insert;
DROP TRIGGER IF EXISTS imm_words_lexical_rollup_delete;
DROP TRIGGER IF EXISTS imm_words_lexical_rollup_first_seen_update;
CREATE TRIGGER imm_words_lexical_rollup_insert
AFTER INSERT ON imm_words
WHEN NEW.first_seen IS NOT NULL AND NEW.vocabulary_visible = 1
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForNew}, 1, CASE WHEN NEW.pos2 = '固有名詞' THEN 0 ELSE 1 END, 0)
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count + 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
END;
CREATE TRIGGER imm_words_lexical_rollup_delete
AFTER DELETE ON imm_words
WHEN OLD.first_seen IS NOT NULL AND OLD.vocabulary_visible = 1
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForOld}, -1, CASE WHEN OLD.pos2 = '固有名詞' THEN 0 ELSE -1 END, 0)
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count - 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
DELETE FROM imm_lexical_daily_rollups
WHERE epoch_day = ${dayForOld} AND word_count = 0 AND kanji_count = 0;
END;
CREATE TRIGGER imm_words_lexical_rollup_first_seen_update
AFTER UPDATE OF first_seen, pos2, vocabulary_visible ON imm_words
WHEN OLD.first_seen IS NOT NEW.first_seen
OR OLD.pos2 IS NOT NEW.pos2
OR OLD.vocabulary_visible IS NOT NEW.vocabulary_visible
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForOld}, -1, CASE WHEN OLD.pos2 = '固有名詞' THEN 0 ELSE -1 END, 0
WHERE OLD.first_seen IS NOT NULL AND OLD.vocabulary_visible = 1
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count - 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForNew}, 1, CASE WHEN NEW.pos2 = '固有名詞' THEN 0 ELSE 1 END, 0
WHERE NEW.first_seen IS NOT NULL AND NEW.vocabulary_visible = 1
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count + 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
DELETE FROM imm_lexical_daily_rollups
WHERE word_count = 0 AND kanji_count = 0;
END;
`);
}
function createKanjiRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen');
db.exec(`
DROP TRIGGER IF EXISTS imm_kanji_lexical_rollup_insert;
DROP TRIGGER IF EXISTS imm_kanji_lexical_rollup_delete;
DROP TRIGGER IF EXISTS imm_kanji_lexical_rollup_first_seen_update;
CREATE TRIGGER imm_kanji_lexical_rollup_insert
AFTER INSERT ON imm_kanji WHEN NEW.first_seen IS NOT NULL
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForNew}, 0, 0, 1)
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + 1;
END;
CREATE TRIGGER imm_kanji_lexical_rollup_delete
AFTER DELETE ON imm_kanji WHEN OLD.first_seen IS NOT NULL
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForOld}, 0, 0, -1)
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count - 1;
DELETE FROM imm_lexical_daily_rollups
WHERE epoch_day = ${dayForOld} AND word_count = 0 AND kanji_count = 0;
END;
CREATE TRIGGER imm_kanji_lexical_rollup_first_seen_update
AFTER UPDATE OF first_seen ON imm_kanji WHEN OLD.first_seen IS NOT NEW.first_seen
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForOld}, 0, 0, -1 WHERE OLD.first_seen IS NOT NULL
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count - 1;
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForNew}, 0, 0, 1 WHERE NEW.first_seen IS NOT NULL
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + 1;
DELETE FROM imm_lexical_daily_rollups WHERE word_count = 0 AND kanji_count = 0;
END;
`);
}
export function ensureLexicalDailyRollupTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_lexical_daily_rollups(
epoch_day INTEGER PRIMARY KEY,
word_count INTEGER NOT NULL DEFAULT 0,
word_count_without_names INTEGER NOT NULL DEFAULT 0,
kanji_count INTEGER NOT NULL DEFAULT 0
);
INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('${LEXICAL_DAILY_ROLLUP_VERSION_KEY}', '0')
ON CONFLICT(state_key) DO NOTHING;
`);
createWordRollupTriggers(db);
createKanjiRollupTriggers(db);
}
export function areLexicalDailyRollupsReady(db: DatabaseSync): boolean {
const row = db
.prepare(`SELECT state_value AS value FROM imm_rollup_state WHERE state_key = ?`)
.get(LEXICAL_DAILY_ROLLUP_VERSION_KEY) as { value: string | number } | undefined;
// Older databases created this column with INTEGER affinity, while current
// databases use TEXT. SQLite returns the same persisted version with a
// different JS type depending on that legacy schema.
return row !== undefined && String(row.value) === LEXICAL_DAILY_ROLLUP_VERSION;
}
export function markLexicalDailyRollupsReady(db: DatabaseSync): void {
db.prepare(
`INSERT INTO imm_rollup_state(state_key, state_value)
VALUES (?, ?)
ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`,
).run(LEXICAL_DAILY_ROLLUP_VERSION_KEY, LEXICAL_DAILY_ROLLUP_VERSION);
}
/** Rebuild from the first-seen source of truth; run off the UI/main DB thread. */
export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
let transactionStarted = false;
try {
db.exec('BEGIN IMMEDIATE');
transactionStarted = true;
const scanVocabulary = db.prepare(
`SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
pos1, pos2, pos3, frequency_rank AS frequencyRank
FROM imm_words
WHERE id > ?
ORDER BY id
LIMIT ?`,
);
const updateVisibility = db.prepare(
`UPDATE imm_words SET vocabulary_visible = ? WHERE id = ? AND vocabulary_visible IS NOT ?`,
);
let lastId = Number.MIN_SAFE_INTEGER;
for (;;) {
const vocabularyRows = scanVocabulary.all(
lastId,
VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE,
) as Array<VocabularyVisibilityRow & { id: number }>;
if (vocabularyRows.length === 0) break;
for (const row of vocabularyRows) {
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
updateVisibility.run(visible, row.id, visible);
}
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
}
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.exec(`
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${localEpochDaySql('first_seen')}, COUNT(*),
SUM(CASE WHEN pos2 = '固有名詞' THEN 0 ELSE 1 END), 0
FROM imm_words
WHERE first_seen IS NOT NULL AND vocabulary_visible = 1
GROUP BY ${localEpochDaySql('first_seen')};
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${localEpochDaySql('first_seen')}, 0, 0, COUNT(*)
FROM imm_kanji
WHERE first_seen IS NOT NULL
GROUP BY ${localEpochDaySql('first_seen')}
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + excluded.kanji_count;
`);
markLexicalDailyRollupsReady(db);
db.exec('COMMIT');
} catch (error) {
if (transactionStarted) {
try {
db.exec('ROLLBACK');
} catch {
// Preserve the rebuild failure; it is the actionable cause.
}
}
throw error;
}
}
export function getLexicalDailyRollups(db: DatabaseSync): LexicalDailyRollup[] {
return db
.prepare(
`
SELECT epoch_day AS epochDay, word_count AS wordCount,
word_count_without_names AS wordCountWithoutNames, kanji_count AS kanjiCount
FROM imm_lexical_daily_rollups
ORDER BY epoch_day ASC
`,
)
.all() as LexicalDailyRollup[];
}
@@ -1,6 +1,4 @@
import type { DatabaseSync } from './sqlite';
import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
import type {
KanjiAnimeAppearanceRow,
KanjiDetailRow,
@@ -13,19 +11,38 @@ import type {
SimilarWordRow,
StatsExcludedWordRow,
VocabularyStatsRow,
VocabularyStatsSummary,
WordAnimeAppearanceRow,
WordDetailRow,
WordOccurrenceRow,
} from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
localEpochDaySql,
} from './lexical-rollups';
import { isVocabularyStatsRowVisible } from './vocabulary-visibility';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
const VOCABULARY_CHART_LIMIT = 12;
const VOCABULARY_CHART_PAGE_SIZE = 100;
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
const SENTENCE_SEARCH_MAX_LIMIT = 100;
const KANJI_PATTERN = /\p{Script=Han}/gu;
export interface VocabularyChartData {
ready: boolean;
topWords: Array<{ wordId: number; headword: string; frequency: number }>;
topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>;
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
}
function resolveSentenceSearchLimit(limit: number): number {
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
const normalized = Math.floor(limit);
@@ -73,33 +90,6 @@ function uniqueKanji(text: string): string[] {
return Array.from(new Set(text.match(KANJI_PATTERN) ?? []));
}
function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
const partOfSpeech =
row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech)
? (row.partOfSpeech as PartOfSpeech)
: PartOfSpeech.other;
return {
surface: row.word,
reading: row.reading ?? '',
headword: row.headword,
startPos: 0,
endPos: row.word.length,
partOfSpeech,
pos1: row.pos1 ?? '',
pos2: row.pos2 ?? '',
pos3: row.pos3 ?? '',
frequencyRank: row.frequencyRank ?? undefined,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
};
}
function isVocabularyStatsRowVisible(row: VocabularyStatsRow): boolean {
return !shouldExcludeTokenFromVocabularyPersistence(toVocabularyToken(row));
}
export function getVocabularyStats(
db: DatabaseSync,
limit = 100,
@@ -153,6 +143,198 @@ export function getVocabularyStats(
return visibleRows.slice(0, limit);
}
/**
* Chart data is intentionally independent of the paginated vocabulary tables.
* Top words use the frequency index; new-word history reads permanent daily
* lexical rollups rather than loading every vocabulary row into the dashboard.
*/
export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
const ready = areLexicalDailyRollupsReady(db);
const excludedAliases = new Set(
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
);
const isExcluded = (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>): boolean =>
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias));
const topWords = getTopVocabularyChartWords(db, isExcluded);
const rollups = ready ? getLexicalDailyRollups(db) : [];
const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }]));
if (excludedAliases.size > 0 && ready) {
const aliases = [...excludedAliases];
const excludedRows = new Map<
number,
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
wordId: number;
epochDay: number;
}
>();
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
const placeholders = batch.map(() => '?').join(', ');
const rows = db
.prepare(
`
SELECT id AS wordId, headword, word, reading, pos2,
${localEpochDaySql('first_seen')} AS epochDay
FROM imm_words
WHERE vocabulary_visible = 1
AND (headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders}))
`,
)
.all(...batch, ...batch, ...batch) as Array<
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
wordId: number;
epochDay: number;
}
>;
for (const row of rows) excludedRows.set(row.wordId, row);
}
for (const word of excludedRows.values()) {
if (!isExcluded(word)) continue;
const rollup = timeline.get(word.epochDay);
if (!rollup) continue;
rollup.wordCount -= 1;
if (word.pos2 !== '固有名詞') rollup.wordCountWithoutNames -= 1;
}
}
return {
ready,
topWords: topWords.all.map((word) => ({
wordId: word.wordId,
headword: vocabularyDisplayHeadword(word),
frequency: word.frequency,
})),
topWordsWithoutNames: topWords.withoutNames.map((word) => ({
wordId: word.wordId,
headword: vocabularyDisplayHeadword(word),
frequency: word.frequency,
})),
newWordsTimeline: [...timeline.values()]
.filter((row) => row.wordCount > 0)
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })),
newWordsTimelineWithoutNames: [...timeline.values()]
.filter((row) => row.wordCountWithoutNames > 0)
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCountWithoutNames })),
};
}
function getTopVocabularyChartWords(
db: DatabaseSync,
isExcluded: (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>) => boolean,
): { all: VocabularyStatsRow[]; withoutNames: VocabularyStatsRow[] } {
const stmt = db.prepare(`
SELECT id AS wordId, headword, word, reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
frequency, frequency_rank AS frequencyRank,
first_seen AS firstSeen, last_seen AS lastSeen,
0 AS animeCount
FROM imm_words
ORDER BY frequency DESC, id
LIMIT ? OFFSET ?
`);
const all: VocabularyStatsRow[] = [];
const withoutNames: VocabularyStatsRow[] = [];
let offset = 0;
while (all.length < VOCABULARY_CHART_LIMIT || withoutNames.length < VOCABULARY_CHART_LIMIT) {
const page = stmt.all(VOCABULARY_CHART_PAGE_SIZE, offset) as VocabularyStatsRow[];
if (page.length === 0) break;
for (const word of page) {
if (!isVocabularyStatsRowVisible(word) || isExcluded(word)) continue;
if (all.length < VOCABULARY_CHART_LIMIT) all.push(word);
if (word.pos2 !== '固有名詞' && withoutNames.length < VOCABULARY_CHART_LIMIT) {
withoutNames.push(word);
}
}
offset += page.length;
}
return { all, withoutNames };
}
function excludedVocabularyAliases(
word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>,
): string[] {
const aliases = [word.headword?.trim() ?? '', word.word?.trim() ?? ''].filter(Boolean);
if (aliases.length === 0) aliases.push(word.reading?.trim() ?? '');
return [...new Set(aliases)];
}
function vocabularyDisplayHeadword(
word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>,
): string {
return word.headword?.trim() || word.word?.trim() || word.reading?.trim() || '';
}
function timestampSeconds(timestamp: number): number {
return timestamp < 10_000_000_000 ? timestamp : Math.floor(timestamp / 1000);
}
export function getVocabularySummary(
db: DatabaseSync,
knownWords: ReadonlySet<string> | null,
nowMs: number = Date.now(),
scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE,
): VocabularyStatsSummary {
// Visibility and exclusion rules live in JS, so rows are scanned in id-keyed
// batches to keep memory bounded on large vocabularies.
const scanStmt = db.prepare(`
SELECT id AS wordId, headword, word, reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
frequency, frequency_rank AS frequencyRank,
first_seen AS firstSeen, last_seen AS lastSeen,
0 AS animeCount
FROM imm_words
WHERE id > ?
ORDER BY id
LIMIT ?
`);
const excludedAliases = new Set(
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
);
const weekAgoSec = nowMs / 1000 - 7 * 86_400;
const summary: VocabularyStatsSummary = {
uniqueWords: 0,
uniqueWordsWithoutNames: 0,
uniqueKanji: (db.prepare('SELECT COUNT(*) AS count FROM imm_kanji').get() as { count: number })
.count,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: knownWords ? 0 : null,
knownWordCountWithoutNames: knownWords ? 0 : null,
};
let lastId = Number.MIN_SAFE_INTEGER;
for (;;) {
const words = scanStmt.all(lastId, scanBatchSize) as VocabularyStatsRow[];
if (words.length === 0) break;
lastId = words[words.length - 1]!.wordId;
for (const word of words) {
if (
!isVocabularyStatsRowVisible(word) ||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
) {
continue;
}
const isName = word.pos2 === '固有名詞';
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
const isKnown = knownWords?.has(vocabularyDisplayHeadword(word)) ?? false;
summary.uniqueWords += 1;
if (!isName) summary.uniqueWordsWithoutNames += 1;
if (isNewThisWeek) {
summary.newThisWeek += 1;
if (!isName) summary.newThisWeekWithoutNames += 1;
}
if (isKnown) {
summary.knownWordCount! += 1;
if (!isName) summary.knownWordCountWithoutNames! += 1;
}
}
if (words.length < scanBatchSize) break;
}
return summary;
}
export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] {
return db
.prepare(
@@ -13,6 +13,7 @@ import {
toDbTimestamp,
} from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month';
@@ -660,6 +661,16 @@ function buildNewWordsPerDay(
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
if (areLexicalDailyRollupsReady(db)) {
// A trend range is defined in calendar buckets, so the rollup includes the
// complete local cutoff day rather than applying a time-of-day boundary.
const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
const rows = getLexicalDailyRollups(db).filter(
(row) => cutoffDay === null || row.epochDay >= cutoffDay,
);
return fillAxisPoints(axis, new Map(rows.map((row) => [row.epochDay, row.wordCount])));
}
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -691,6 +702,18 @@ function buildNewWordsPerMonth(
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
if (areLexicalDailyRollupsReady(db)) {
const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
const byMonth = new Map<number, number>();
for (const row of getLexicalDailyRollups(db)) {
if (cutoffDay !== null && row.epochDay < cutoffDay) continue;
const { year, month } = dayPartsFromEpochDay(row.epochDay);
const monthKey = year * 100 + month;
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + row.wordCount);
}
return fillAxisPoints(axis, byMonth);
}
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -3,7 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from './sqlite';
import { Database, type DatabaseSync } from './sqlite';
import { getStatsExcludedWords, replaceStatsExcludedWords } from './query-lexical';
import { finalizeSessionRecord, startSessionRecord } from './session';
import {
@@ -87,6 +87,29 @@ test('applyPragmas sets the SQLite tuning defaults used by immersion tracking',
}
});
test('applyPragmas installs the busy timeout before WAL negotiation', () => {
const statements: string[] = [];
const db: DatabaseSync = {
exec(source) {
statements.push(source);
return db;
},
prepare() {
throw new Error('not used');
},
close() {
return db;
},
};
applyPragmas(db);
assert.deepEqual(statements.slice(0, 2), [
'PRAGMA busy_timeout = 2500',
'PRAGMA journal_mode = WAL',
]);
});
test('ensureSchema creates immersion core tables', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -184,6 +207,51 @@ test('ensureSchema adds manual assignment locks when upgrading the previous sche
}
});
test('ensureSchema preserves durable session rollups across unrelated schema upgrades', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.exec(`
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (1, 'local:/tmp/preserved.mkv', 'Preserved', 1, 0, '1', '1');
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (20000, 1, 2, 30, 40, 50, 3);
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (202410, 1, 2, 30, 40, 50, 3);
UPDATE imm_rollup_state
SET state_value = '123'
WHERE state_key = 'last_rollup_sample_ms';
UPDATE imm_schema_version SET schema_version = 21;
`);
ensureSchema(db);
const daily = db
.prepare('SELECT total_sessions AS totalSessions FROM imm_daily_rollups')
.get() as { totalSessions: number } | null;
const monthly = db
.prepare('SELECT total_sessions AS totalSessions FROM imm_monthly_rollups')
.get() as { totalSessions: number } | null;
const rollupState = db
.prepare(`SELECT state_value AS value FROM imm_rollup_state WHERE state_key = ?`)
.get('last_rollup_sample_ms') as { value: string } | null;
assert.equal(daily?.totalSessions, 2);
assert.equal(monthly?.totalSessions, 2);
assert.equal(rollupState?.value, '123');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('stats excluded words are replaced and read from sqlite storage', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
+29 -11
View File
@@ -4,6 +4,7 @@ import { parseMediaInfo } from '../../../jimaku/utils';
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs, toDbTimestamp } from './query-shared';
@@ -314,10 +315,12 @@ function migrateSessionEventTimestampsToText(db: DatabaseSync): void {
}
export function applyPragmas(db: DatabaseSync): void {
// Install the wait policy before WAL negotiation, which can briefly contend with
// another connection closing or checkpointing the same database.
db.exec('PRAGMA busy_timeout = 2500');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 2500');
db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`);
}
@@ -890,11 +893,11 @@ export function ensureSchema(db: DatabaseSync): void {
VALUES ('last_rollup_sample_ms', 0)
ON CONFLICT(state_key) DO NOTHING
`);
const currentVersion = db
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
.get() as { schema_version: number } | null;
if (currentVersion?.schema_version === SCHEMA_VERSION) {
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
ensureAnimeMergeTables(db);
@@ -1068,6 +1071,7 @@ export function ensureSchema(db: DatabaseSync): void {
last_seen REAL,
frequency INTEGER,
frequency_rank INTEGER,
vocabulary_visible INTEGER NOT NULL DEFAULT 1 CHECK(vocabulary_visible IN (0, 1)),
UNIQUE(headword, word, reading)
);
`);
@@ -1451,8 +1455,18 @@ export function ensureSchema(db: DatabaseSync): void {
addColumnIfMissing(db, 'imm_sessions', 'ended_media_ms', 'INTEGER');
}
if (currentVersion?.schema_version && currentVersion.schema_version < 23) {
addColumnIfMissing(
db,
'imm_words',
'vocabulary_visible',
'INTEGER NOT NULL DEFAULT 1 CHECK(vocabulary_visible IN (0, 1))',
);
}
migrateSessionEventTimestampsToText(db);
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
@@ -1572,19 +1586,21 @@ export function ensureSchema(db: DatabaseSync): void {
ON imm_youtube_videos(youtube_video_id)
`);
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
db.exec('DELETE FROM imm_daily_rollups');
db.exec('DELETE FROM imm_monthly_rollups');
db.exec(
`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`,
);
}
// Session rollups intentionally outlive raw session and telemetry retention.
// Preserve them across unrelated schema upgrades because deleted historical
// buckets cannot be rebuilt after their source rows have been pruned.
db.exec(`
INSERT INTO imm_schema_version(schema_version, applied_at_ms)
VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())})
ON CONFLICT DO NOTHING
`);
// A new database has no history to materialize. Upgrades are populated by the
// background worker so startup never scans the existing vocabulary table.
if (!currentVersion) {
markLexicalDailyRollupsReady(db);
}
}
export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements {
@@ -1617,9 +1633,10 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
`),
wordUpsertStmt: db.prepare(`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency, frequency_rank
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen,
frequency, frequency_rank, vocabulary_visible
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, 1
)
ON CONFLICT(headword, word, reading) DO UPDATE SET
frequency = COALESCE(frequency, 0) + 1,
@@ -1632,6 +1649,7 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
vocabulary_visible = 1,
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
frequency_rank = CASE
+11 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 21;
export const SCHEMA_VERSION = 23;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -306,6 +306,16 @@ export interface VocabularyStatsRow {
lastSeen: number;
}
export interface VocabularyStatsSummary {
uniqueWords: number;
uniqueWordsWithoutNames: number;
uniqueKanji: number;
newThisWeek: number;
newThisWeekWithoutNames: number;
knownWordCount: number | null;
knownWordCountWithoutNames: number | null;
}
export interface StatsExcludedWordRow {
headword: string;
word: string;
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
resolveVocabularySummaryWorkerPath,
VocabularySummaryWorkerRuntime,
} from './vocabulary-summary-worker-runtime';
import { Database } from './sqlite';
import { applyPragmas, ensureSchema } from './storage';
test('vocabulary summary worker reads the database from a separate connection', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-vocabulary-summary-worker-'));
const dbPath = path.join(tempDir, 'immersion.sqlite');
const runtime = new VocabularySummaryWorkerRuntime();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES ('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1, 1, 1)
`,
).run();
db.close();
const summary = await runtime.run(dbPath, new Set(['猫']));
assert.equal(summary.uniqueWords, 1);
assert.equal(summary.knownWordCount, 1);
} finally {
runtime.destroy();
try {
db.close();
} catch {
// The worker needs the setup connection closed before it starts.
}
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('vocabulary summary worker module resolves in the current layout', () => {
const workerPath = resolveVocabularySummaryWorkerPath();
assert.ok(workerPath, 'expected the vocabulary summary worker module to resolve');
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
});
test('vocabulary summary worker never falls back to the caller thread', async () => {
const runtime = new VocabularySummaryWorkerRuntime({
resolveWorkerPath: () => null,
warn: () => {},
});
try {
await assert.rejects(
runtime.run('/tmp/subminer-summary-worker-not-used.sqlite', null),
/worker unavailable/i,
);
} finally {
runtime.destroy();
}
});
test('vocabulary summary worker times out when it never responds', async () => {
let terminated = false;
const runtime = new VocabularySummaryWorkerRuntime({
resolveWorkerPath: () => '/tmp/fake-worker.js',
createWorker: async () => ({
once() {
return this;
},
terminate: async () => {
terminated = true;
return 0;
},
}),
timeoutMs: 1,
warn: () => {},
} as never);
try {
const outcome = await Promise.race([
runtime.run('/tmp/not-used.sqlite', null).then(
() => 'resolved',
(error: unknown) => String(error),
),
new Promise<string>((resolve) => setTimeout(() => resolve('still pending'), 50)),
]);
assert.match(outcome, /timed out/);
assert.equal(terminated, true);
} finally {
runtime.destroy();
}
});
@@ -0,0 +1,133 @@
import fs from 'node:fs';
import path from 'node:path';
import { createLogger } from '../../../logger';
import type { VocabularyStatsSummary } from './types';
interface VocabularySummaryWorkerResponse {
summary?: VocabularyStatsSummary;
error?: unknown;
}
interface VocabularySummaryWorkerHandle {
once(event: 'message', listener: (message: VocabularySummaryWorkerResponse) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
terminate(): Promise<number>;
}
interface VocabularySummaryWorkerRuntimeOptions {
resolveWorkerPath?: () => string | null;
createWorker?: (
workerPath: string,
workerData: { dbPath: string; knownWords: string[] | null },
) => Promise<VocabularySummaryWorkerHandle>;
timeoutMs?: number;
warn?: (message: string, ...meta: unknown[]) => void;
}
export type RunVocabularySummaryTask = (
dbPath: string,
knownWords: ReadonlySet<string> | null,
) => Promise<VocabularyStatsSummary>;
export function resolveVocabularySummaryWorkerPath(): string | null {
const fileName = __filename.endsWith('.ts')
? 'vocabulary-summary-worker-thread.ts'
: 'vocabulary-summary-worker-thread.js';
const workerPath = path.join(__dirname, fileName);
return fs.existsSync(workerPath) ? workerPath : null;
}
const logger = createLogger('main:immersion-tracker:vocabulary-summary-worker');
const DEFAULT_WORKER_TIMEOUT_MS = 5 * 60 * 1_000;
export class VocabularySummaryWorkerRuntime {
private readonly activeWorkers = new Set<VocabularySummaryWorkerHandle>();
private destroyed = false;
constructor(private readonly options: VocabularySummaryWorkerRuntimeOptions = {}) {}
async run(
dbPath: string,
knownWords: ReadonlySet<string> | null,
): Promise<VocabularyStatsSummary> {
if (this.destroyed) throw new Error('Vocabulary summary worker is shut down');
const workerData = { dbPath, knownWords: knownWords ? [...knownWords] : null };
let worker: VocabularySummaryWorkerHandle;
try {
const workerPath = (this.options.resolveWorkerPath ?? resolveVocabularySummaryWorkerPath)();
if (!workerPath) throw new Error('Emitted vocabulary summary worker module was not found');
const createWorker =
this.options.createWorker ??
(async (resolvedPath, data) => {
const { Worker } = await import('node:worker_threads');
return new Worker(resolvedPath, { workerData: data });
});
worker = await createWorker(workerPath, workerData);
} catch (error) {
if (this.destroyed) throw new Error('Vocabulary summary worker is shut down');
(this.options.warn ?? logger.warn)(
'Vocabulary summary worker unavailable; refusing to scan vocabulary on the current thread',
error,
);
throw new Error('Vocabulary summary worker unavailable');
}
if (this.destroyed) {
await worker.terminate().catch(() => undefined);
throw new Error('Vocabulary summary worker is shut down');
}
return new Promise<VocabularyStatsSummary>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
this.activeWorkers.add(worker);
const settle = (result: VocabularyStatsSummary | Error) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
this.activeWorkers.delete(worker);
void worker.terminate().catch(() => undefined);
if (result instanceof Error) reject(result);
else resolve(result);
};
timeout = setTimeout(
() => settle(new Error('Vocabulary summary worker timed out')),
this.options.timeoutMs ?? DEFAULT_WORKER_TIMEOUT_MS,
);
worker.once('message', (message) => {
if (message.summary) {
settle(message.summary);
return;
}
settle(
new Error(
`Vocabulary summary failed: ${String(message.error ?? 'unknown worker error')}`,
),
);
});
worker.once('error', (error) => settle(error));
worker.once('exit', (code) => {
if (!settled) {
settle(
new Error(
code === 0
? 'Vocabulary summary worker exited without a response'
: `Vocabulary summary worker exited with code ${code}`,
),
);
}
});
});
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
for (const worker of this.activeWorkers) {
void worker.terminate().catch(() => undefined);
}
this.activeWorkers.clear();
}
}
@@ -0,0 +1,19 @@
import { parentPort, workerData } from 'node:worker_threads';
import { executeVocabularySummaryTask } from './vocabulary-summary-worker';
interface VocabularySummaryWorkerData {
dbPath: string;
knownWords: string[] | null;
}
if (!parentPort) throw new Error('vocabulary summary worker missing parent port');
const request = workerData as VocabularySummaryWorkerData;
try {
parentPort.postMessage({
summary: executeVocabularySummaryTask(request.dbPath, request.knownWords),
});
} catch (error) {
parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
@@ -0,0 +1,17 @@
import { getVocabularySummary } from './query-lexical';
import { Database } from './sqlite';
import { applyPragmas } from './storage';
import type { VocabularyStatsSummary } from './types';
export function executeVocabularySummaryTask(
dbPath: string,
knownWords: string[] | null,
): VocabularyStatsSummary {
const db = new Database(dbPath);
try {
applyPragmas(db);
return getVocabularySummary(db, knownWords ? new Set(knownWords) : null);
} finally {
db.close();
}
}
@@ -0,0 +1,43 @@
import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
export interface VocabularyVisibilityRow {
word: string | null;
headword: string | null;
reading?: string | null;
partOfSpeech?: string | null;
pos1?: string | null;
pos2?: string | null;
pos3?: string | null;
frequencyRank?: number | null;
}
function toVocabularyToken(row: VocabularyVisibilityRow): MergedToken {
const word = row.word ?? '';
const headword = row.headword ?? word;
const partOfSpeech =
row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech)
? (row.partOfSpeech as PartOfSpeech)
: PartOfSpeech.other;
return {
surface: word,
reading: row.reading ?? '',
headword,
startPos: 0,
endPos: word.length,
partOfSpeech,
pos1: row.pos1 ?? '',
pos2: row.pos2 ?? '',
pos3: row.pos3 ?? '',
frequencyRank: row.frequencyRank ?? undefined,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
};
}
export function isVocabularyStatsRowVisible(row: VocabularyVisibilityRow): boolean {
if (!(row.word?.trim() || row.headword?.trim())) return false;
return !shouldExcludeTokenFromVocabularyPersistence(toVocabularyToken(row));
}
+5 -6
View File
@@ -50,6 +50,11 @@ export {
} from './tokenizer/yomitan-parser-runtime';
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export {
resolveSanitizedSubtitleSeekCommand,
subtitleCueListSeekTime,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
@@ -126,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,
@@ -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,
);
});
@@ -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<string, number> {
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<string, number> = {};
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
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;
}
}
+76 -39
View File
@@ -125,9 +125,83 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
]);
});
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
test('mineSentenceCard prefers a canonical primary subtitle snapshot', async () => {
const created: Array<{
sentence: string;
startTime: number;
endTime: number;
secondarySub?: string;
}> = [];
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '今今今手手手',
currentSubStart: 11.4,
currentSubEnd: 11.8,
currentSecondarySubText: 'English subtitle',
},
primarySubtitle: {
text: '今 手にある物差しでは',
startTime: 11.13,
endTime: 13.83,
},
showMpvOsd: () => {},
});
assert.deepEqual(created, [
{
sentence: '今 手にある物差しでは',
startTime: 11.13,
endTime: 13.83,
secondarySub: 'English subtitle',
},
]);
});
test('mineSentenceCard uses normalized secondary subtitle state instead of raw mpv text', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
let requestedRawSecondaryText = false;
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'Your\nmosaic',
requestProperty: async () => {
requestedRawSecondaryText = true;
return 'Your\nYour\nYour\nYour\nmosaic';
},
},
showMpvOsd: () => {},
});
assert.equal(requestedRawSecondaryText, false);
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'Your\nmosaic' }]);
});
test('mineSentenceCard omits normalized secondary text that matches the primary subtitle', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
const requestedProperties: string[] = [];
await mineSentenceCard({
ankiIntegration: {
@@ -145,43 +219,6 @@ test('mineSentenceCard refreshes secondary subtitle text before creating card',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: '日本語字幕',
requestProperty: async (name: string) => {
requestedProperties.push(name);
return name === 'secondary-sub-text' ? 'English subtitle' : null;
},
},
showMpvOsd: () => {},
});
assert.deepEqual(requestedProperties, ['secondary-sub-text']);
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]);
});
test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'stale cached subtitle',
requestProperty: async (name: string) => {
if (name === 'secondary-sub-text') {
return '';
}
return null;
},
},
showMpvOsd: () => {},
});
+10 -16
View File
@@ -129,18 +129,10 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
return trimmed;
}
async function getCurrentSecondarySubTextForSentenceCard(
function getCurrentSecondarySubTextForSentenceCard(
mpvClient: MpvClientLike,
): Promise<string | undefined> {
const primaryText = mpvClient.currentSubText;
if (mpvClient.requestProperty) {
try {
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
return normalizeSecondarySubText(latestSecondaryText, primaryText);
} catch {
// Fall back to the cached secondary subtitle below.
}
}
primaryText: string,
): string | undefined {
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText);
}
@@ -175,6 +167,7 @@ export async function markLastCardAsAudioCard(deps: {
export async function mineSentenceCard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null;
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
showMpvOsd: (text: string) => void;
}): Promise<boolean> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
@@ -185,16 +178,17 @@ export async function mineSentenceCard(deps: {
deps.showMpvOsd('MPV not connected');
return false;
}
if (!mpvClient.currentSubText) {
const primaryText = deps.primarySubtitle?.text ?? mpvClient.currentSubText;
if (!primaryText) {
deps.showMpvOsd('No current subtitle');
return false;
}
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient);
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
return await anki.createSentenceCard(
mpvClient.currentSubText,
mpvClient.currentSubStart,
mpvClient.currentSubEnd,
primaryText,
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
secondarySubText,
);
}
+2
View File
@@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'secondary-sub-visibility',
'sub-visibility',
'sid',
'secondary-sid',
'secondary-sub-delay',
'track-list',
];
+33 -1
View File
@@ -63,6 +63,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
emitSubtitleTrackChange: (payload) => state.events.push(payload),
emitSecondarySubtitleTrackChange: (payload) => state.events.push(payload),
emitSecondarySubtitleDelayChange: (payload) => state.events.push(payload),
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => {
@@ -158,12 +160,42 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sub-delay', data: '0.5' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
deps,
);
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
assert.deepEqual(state.events, [
{ sid: 3 },
{ sid: 4 },
{ delay: 0.5 },
{ trackList: [{ type: 'sub', id: 3 }] },
]);
});
test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '4.5' }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4.5' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: 4.5 }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: 4.5 },
deps,
);
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
});
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
+21 -1
View File
@@ -54,6 +54,8 @@ export interface MpvProtocolHandleMessageDeps {
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
@@ -281,7 +283,25 @@ export async function dispatchMpvProtocolMessage(
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
} else if (msg.name === 'secondary-sid') {
const sid =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSecondarySubtitleTrackChange({
sid: sid !== null && Number.isInteger(sid) ? sid : null,
});
} else if (msg.name === 'secondary-sub-delay') {
const delay =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: 0;
deps.emitSecondarySubtitleDelayChange({ delay: Number.isFinite(delay) ? delay : 0 });
} else if (msg.name === 'track-list') {
deps.emitSubtitleTrackListChange({
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
+8
View File
@@ -131,6 +131,8 @@ export interface MpvIpcClientEventMap {
'fullscreen-change': { fullscreen: boolean };
'secondary-subtitle-change': { text: string };
'subtitle-track-change': { sid: number | null };
'secondary-subtitle-track-change': { sid: number | null };
'secondary-subtitle-delay-change': { delay: number };
'subtitle-track-list-change': { trackList: unknown[] | null };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
@@ -438,6 +440,12 @@ export class MpvIpcClient implements MpvClient {
emitSubtitleTrackChange: (payload) => {
this.emit('subtitle-track-change', payload);
},
emitSecondarySubtitleTrackChange: (payload) => {
this.emit('secondary-subtitle-track-change', payload);
},
emitSecondarySubtitleDelayChange: (payload) => {
this.emit('secondary-subtitle-delay-change', payload);
},
emitSubtitleTrackListChange: (payload) => {
this.emit('subtitle-track-list-change', payload);
},
@@ -21,6 +21,7 @@ type CreateAnkiIntegrationArgs = {
mpvClient: { send?: (payload: { command: string[] }) => void };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -74,6 +75,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
args.getYoutubeMediaSourceUrl,
args.dismissOverlayNotification,
);
}
@@ -137,6 +139,7 @@ export function initializeOverlayRuntime(
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -177,6 +180,7 @@ export function initializeOverlayAnkiIntegration(options: {
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -219,6 +223,7 @@ export function initializeOverlayAnkiIntegration(options: {
mpvClient,
showDesktopNotification: options.showDesktopNotification,
showOverlayNotification: options.showOverlayNotification,
dismissOverlayNotification: options.dismissOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
@@ -0,0 +1,15 @@
const MIN_FLATTENED_DUPLICATE_LENGTH = 16;
const TERMINAL_SENTENCE_PUNCTUATION = /[.!?]+$/gu;
/**
* Identifies long lines that become duplicates when positioned ASS events are
* flattened into the secondary subtitle bar. Short dialogue stays distinct.
*/
export function flattenedSecondarySubtitleLineIdentity(text: string): string | null {
const identity = text
.normalize('NFKC')
.replace(/\s+/gu, '')
.replace(TERMINAL_SENTENCE_PUNCTUATION, '');
return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null;
}
@@ -10,6 +10,7 @@ import {
parseExcludedWordsBody,
parseIntQuery,
parsePositiveIdList,
loadKnownWordsSet,
} from './route-support.js';
export function registerStatsLibraryRoutes(
@@ -31,6 +32,17 @@ export function registerStatsLibraryRoutes(
return c.json(statsJson('vocabulary', vocab));
});
app.get('/api/stats/vocabulary/summary', async (c) => {
const summary = await tracker.getVocabularySummary(
loadKnownWordsSet(options?.knownWordCachePath),
);
return c.json(statsJson('vocabularySummary', summary));
});
app.get('/api/stats/vocabulary/charts', async (c) => {
return c.json(statsJson('vocabularyCharts', await tracker.getVocabularyChartData()));
});
app.get('/api/stats/excluded-words', async (c) => {
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
});
@@ -89,6 +89,7 @@ const WORD_COPY_COLUMNS = [
'last_seen',
'frequency',
'frequency_rank',
'vocabulary_visible',
] as const;
export function mergeAnime(
+182 -8
View File
@@ -27,17 +27,188 @@ function cueKey(cue: SubtitleCue): string {
/**
* Identical text over an identical span is redundant however it was authored -- most
* often a layered ASS event stacking a shadow copy under the visible one.
* often a layered ASS event stacking a shadow copy under the visible one. When one of
* the duplicates is a recovered canonical cue, that copy survives: dropping it would
* strip the `source` marker and animation envelope the live overlay substitutes on.
*/
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const seen = new Set<string>();
return cues.filter((cue) => {
const survivorByKey = new Map<string, AnnotatedSubtitleCue>();
const keysInOrder: string[] = [];
for (const cue of cues) {
const key = cueKey(cue);
if (seen.has(key)) {
return false;
const existing = survivorByKey.get(key);
if (!existing) {
survivorByKey.set(key, cue);
keysInOrder.push(key);
} else if (!existing.source && cue.source) {
survivorByKey.set(key, cue);
}
seen.add(key);
}
return keysInOrder.map((key) => survivorByKey.get(key)!);
}
const SPATIAL_ASS_OVERRIDE_COMMANDS = new Set([
'a',
'an',
'clip',
'iclip',
'move',
'org',
'pbo',
'pos',
'q',
]);
interface RepeatedPhaseRun {
cues: AnnotatedSubtitleCue[];
indices: number[];
}
// A changing override signature alone is weak: two ordinary repeats restyled with
// different colors look identical to a phase pair. Real phase redraws carry a styling
// stack over a full lyric line, and they exist to move a color/highlight boundary
// *within* the line -- so every event also has an override block after visible text
// began. An ordinary restyled repeat carries only a leading block and stays separate.
const MIN_PHASE_EVIDENCE_OVERRIDES = 2;
const MIN_PHASE_TEXT_LENGTH = 4;
function hasMidLineOverrideBlock(rawText: string): boolean {
let sawVisibleText = false;
for (let i = 0; i < rawText.length; i += 1) {
if (rawText[i] === '{') {
const close = rawText.indexOf('}', i);
if (close === -1) {
// Unclosed brace renders as literal text; nothing after it is markup.
return false;
}
if (sawVisibleText) {
return true;
}
i = close;
} else if (!/\s/.test(rawText[i]!)) {
sawVisibleText = true;
}
}
return false;
}
function assStyleKey(cue: AnnotatedSubtitleCue): string {
return `${cue.style}\0${cue.name}\0${cue.layer}`;
}
function spatialOverrideSignature(cue: AnnotatedSubtitleCue): string {
return cue.overrides
.filter((command) => SPATIAL_ASS_OVERRIDE_COMMANDS.has(command.name.toLowerCase()))
.map((command) => `${command.name.toLowerCase()}(${command.args})`)
.join('|');
}
function hasStableSpatialOverrides(run: readonly AnnotatedSubtitleCue[]): boolean {
const firstSignature = spatialOverrideSignature(run[0]!);
return run.every((cue) => spatialOverrideSignature(cue) === firstSignature);
}
function hasDirectPhaseEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
// Phases redraw one authored line in place. Whatever the animation evidence, a run
// whose spatial placement changes is separate authored occurrences -- two flush
// same-text `\move` signs at different coordinates must never merge.
if (!hasStableSpatialOverrides(run)) {
return false;
}
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
return true;
}
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
return true;
}
const [first] = run;
return (
first!.text.replace(/\s+/gu, '').length >= MIN_PHASE_TEXT_LENGTH &&
run.every(
(cue) =>
cue.overrides.length >= MIN_PHASE_EVIDENCE_OVERRIDES &&
hasMidLineOverrideBlock(cue.rawText),
) &&
run.some((cue) => cue.overrideSignature !== first!.overrideSignature)
);
}
function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRun[] {
const runs: RepeatedPhaseRun[] = [];
let start = 0;
while (start < cues.length) {
const first = cues[start]!;
const styleKey = assStyleKey(first);
let end = start;
while (end + 1 < cues.length) {
const current = cues[end]!;
const next = cues[end + 1]!;
const isFlush =
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
if (
first.source !== undefined ||
next.source !== undefined ||
next.text !== first.text ||
assStyleKey(next) !== styleKey ||
!isFlush
) {
break;
}
end += 1;
}
if (end > start) {
const indices = Array.from({ length: end - start + 1 }, (_, offset) => start + offset);
runs.push({
cues: indices.map((index) => cues[index]!),
indices,
});
}
start = end + 1;
}
return runs;
}
/**
* Some karaoke scripts redraw one complete lyric for each color/highlight phase. These
* events last far longer than animation frames, but are still one sidebar/history line.
* The events must prove themselves through direct animation metadata or changing
* non-spatial overrides. Plain repeated dialogue and separately positioned signs stay
* intact.
*/
function collapseAnimatedStylePhases(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const runs = collectRepeatedPhaseRuns(cues);
if (runs.length === 0) {
return cues;
}
const dropped = new Set<number>();
const extendedEnd = new Map<number, number>();
for (const run of runs) {
if (!hasDirectPhaseEvidence(run.cues)) {
continue;
}
const [firstIndex, ...remainingIndices] = run.indices;
for (const index of remainingIndices) {
dropped.add(index);
}
extendedEnd.set(firstIndex!, Math.max(...run.cues.map((cue) => cue.endTime)));
}
if (dropped.size === 0) {
return cues;
}
return cues.flatMap((cue, index) => {
if (dropped.has(index)) {
return [];
}
const endTime = extendedEnd.get(index);
return endTime !== undefined ? [{ ...cue, endTime }] : [cue];
});
}
@@ -52,7 +223,7 @@ function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number)
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
* changes from event to event, which is how per-frame typesetting is authored.
*/
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
export function hasAssAnimationEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
return true;
}
@@ -176,5 +347,8 @@ export function mergeDuplicateCues(
cues: AnnotatedSubtitleCue[],
format: SubtitleSourceFormat,
): AnnotatedSubtitleCue[] {
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
const exactDeduplicated = collapseExactDuplicates(cues);
const phaseDeduplicated =
format === 'ass' ? collapseAnimatedStylePhases(exactDeduplicated) : exactDeduplicated;
return collapseAnimationBursts(phaseDeduplicated, format);
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveSanitizedSubtitleSeekCommand,
subtitleCueListSeekTime,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
test('next subtitle navigation skips generated ASS events and seeks to the next sanitized cue', () => {
const cues = [
{
startTime: 10,
endTime: 13,
text: 'first lyric',
source: 'canonical-ass' as const,
animationStartTime: 9.7,
animationEndTime: 13.4,
},
{
startTime: 13,
endTime: 16,
text: 'second lyric',
source: 'canonical-ass' as const,
animationStartTime: 12.7,
animationEndTime: 16.4,
},
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
'seek',
13.08,
'absolute+exact',
]);
});
test('next subtitle navigation treats simultaneous sanitized cues as one line boundary', () => {
const cues = [
{ startTime: 10, endTime: 13, text: 'romaji' },
{ startTime: 10.02, endTime: 13, text: 'English' },
{ startTime: 13, endTime: 16, text: 'next romaji' },
{ startTime: 13.02, endTime: 16, text: 'Next English' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
'seek',
13.08,
'absolute+exact',
]);
});
test('next subtitle navigation advances past the latest overlapping lyric', () => {
const cues = [
{ startTime: 10, endTime: 14, text: 'exiting lyric' },
{ startTime: 13, endTime: 16, text: 'current lyric' },
{ startTime: 16, endTime: 19, text: 'next lyric' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
'seek',
16.08,
'absolute+exact',
]);
});
test('previous subtitle navigation leaves the current cue and seeks to the prior cue', () => {
const cues = [
{ startTime: 10, endTime: 12, text: 'first line' },
{ startTime: 13, endTime: 16, text: 'current line' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
'seek',
10.08,
'absolute+exact',
]);
});
test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
});
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08);
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03);
});
test('sidebar cue selection clears an overlapping previous lyric', () => {
const cues = [
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
{ startTime: 3, endTime: 5, text: 'selected lyric' },
];
assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48);
});
test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => {
const cues = [
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
{ startTime: 3, endTime: 3.2, text: 'selected lyric' },
];
const seekTime = subtitleCueListSeekTime(cues, cues[1]!);
assert.ok(seekTime >= 3.19);
assert.ok(seekTime < cues[1]!.endTime);
});
@@ -0,0 +1,128 @@
import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
const CUE_BOUNDARY_SEEK_OFFSET_SECONDS = 0.08;
const CUE_END_GUARD_SECONDS = 0.01;
type CueGroup = {
startTime: number;
endTime: number;
cue: SubtitleCue;
};
function isValidCue(cue: SubtitleCue): boolean {
return (
Number.isFinite(cue.startTime) && Number.isFinite(cue.endTime) && cue.endTime > cue.startTime
);
}
function groupCueBoundaries(cues: readonly SubtitleCue[]): CueGroup[] {
const sorted = cues.filter(isValidCue).sort((left, right) => {
return left.startTime - right.startTime || left.endTime - right.endTime;
});
const groups: CueGroup[] = [];
for (const cue of sorted) {
const current = groups.at(-1);
if (current && cue.startTime - current.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS) {
current.endTime = Math.max(current.endTime, cue.endTime);
continue;
}
groups.push({ startTime: cue.startTime, endTime: cue.endTime, cue });
}
return groups;
}
/** A small offset avoids asking mpv to render exactly on a subtitle boundary. */
export function subtitleCueSeekTime(cue: SubtitleCue): number {
return Math.max(
cue.startTime,
Math.min(cue.endTime - CUE_END_GUARD_SECONDS, cue.startTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS),
);
}
/**
* Choose a stable point inside a selected cue. Karaoke lines can overlap while the
* previous line animates out, so a sidebar selection should clear that overlap when
* the selected cue has enough time remaining.
*/
export function subtitleCueListSeekTime(
cues: readonly SubtitleCue[],
selectedCue: SubtitleCue,
): number {
const groups = groupCueBoundaries(cues);
const selectedGroupIndex = groups.findIndex(
(group) =>
selectedCue.startTime >= group.startTime &&
selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS,
);
const previousGroupEndTime =
selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined;
if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) {
return subtitleCueSeekTime(selectedCue);
}
return Math.max(
selectedCue.startTime,
Math.min(
selectedCue.endTime - CUE_END_GUARD_SECONDS,
previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS,
),
);
}
/**
* Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can
* contain hundreds of subtitle events for one visible line, while the parsed list has
* already collapsed those events into the authored lines the user expects to navigate.
*/
export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[],
cues: readonly SubtitleCue[],
currentTimeSec: number,
): (string | number)[] | null {
if (
command.length < 2 ||
command[0] !== 'sub-seek' ||
(command[1] !== -1 && command[1] !== 1) ||
!Number.isFinite(currentTimeSec)
) {
return null;
}
const groups = groupCueBoundaries(cues);
if (groups.length === 0) {
return null;
}
let activeIndex = -1;
for (const [index, group] of groups.entries()) {
if (group.startTime <= currentTimeSec && group.endTime > currentTimeSec) {
activeIndex = index;
}
}
let destination: CueGroup | undefined;
if (command[1] === 1) {
destination =
activeIndex >= 0
? groups[activeIndex + 1]
: groups.find((group) => group.startTime > currentTimeSec);
} else if (activeIndex >= 0) {
destination = groups[activeIndex - 1];
} else {
for (let index = groups.length - 1; index >= 0; index -= 1) {
const group = groups[index]!;
if (group.startTime < currentTimeSec) {
destination = group;
break;
}
}
}
if (!destination) {
return null;
}
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
try {
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = cachedTokenized;
output = { ...cachedTokenized, text };
} else {
// Cache miss: show the plain line on time; the tokenized payload
// upgrades it once ready. Skipped on refreshes of an already
@@ -266,7 +266,7 @@ export function createSubtitleProcessingController(
lastEmittedText = text;
lastEmittedGeneration = cacheGeneration;
lastPlainEmittedText = null;
return cached;
return { ...cached, text };
},
hasCachedSubtitle: (text: string) => {
const cacheKey = normalizeSubtitleCacheKey(text);
@@ -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);
});
-153
View File
@@ -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<number>();
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;
}
+17
View File
@@ -84,6 +84,17 @@ function createDeferred<T>() {
};
}
test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => {
// The tokenized payload's text drives display; folding the cue boundary would merge
// two speakers back onto one line the moment tokenization upgrades the plain emit.
const result = await tokenizeSubtitle(
'\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee',
makeDeps({ getYomitanExt: () => null }),
);
assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee');
});
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
const result = await tokenizeSubtitle(
'猫です',
@@ -1682,6 +1693,12 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
assert.equal(result.tokens, null);
});
test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => {
const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps());
assert.deepEqual(result, { text: 'a\n\nb', tokens: null });
});
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
let parseInput = '';
const result = await tokenizeSubtitle(
+9 -1
View File
@@ -887,7 +887,15 @@ export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
): Promise<SubtitleData> {
const displayText = normalizePlainSubtitleText(text);
// Normalize per cue group: the blank line separating simultaneous cues is display
// structure the payload text must keep, or the tokenized upgrade re-merges lines the
// provisional plain emit already showed apart.
const displayText = text
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((part) => normalizePlainSubtitleText(part))
.filter(Boolean)
.join('\n\n');
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
+112
View File
@@ -39,6 +39,118 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
);
});
test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => {
// Real-world shape of YouTube's sentence-level auto captions: window-append
// filler rows (a="1", sometimes without d) mark the display timeline, while
// long text rows carry a placeholder d="3000" far shorter than the speech.
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext><body>',
'<p t="98550" d="3010" w="1" a="1">\n</p>',
'<p t="98560" d="3000" w="1"><s ac="0">ありがとうって言えないよね。こんなんじゃ。</s></p>',
'<p t="106950" w="1" a="1">\n</p>',
'<p t="106960" d="3799" w="1"><s ac="0">私だったら無理だよ。</s></p>',
'</body></timedtext>',
].join('\n'),
);
assert.equal(
result,
[
'WEBVTT',
'',
'00:01:38.560 --> 00:01:46.950',
'ありがとうって言えないよね。こんなんじゃ。',
'',
'00:01:46.960 --> 00:01:50.759',
'私だったら無理だよ。',
'',
].join('\n'),
);
});
test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => {
const text =
'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな';
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext format="3">',
'<head>',
'<ws id="1" mh="2" ju="0" sd="3"/>',
'<wp id="1" ap="6" ah="20" av="100" rc="2" cc="40"/>',
'</head>',
'<body>',
'<w t="0" id="1" wp="1" ws="1"/>',
`<p t="60440" d="3000" w="1"><s ac="0">${text}</s></p>`,
'<p t="72695" w="1" a="1">\n</p>',
'</body>',
'</timedtext>',
].join('\n'),
);
const cues = result
.trim()
.split(/\n\n/)
.filter((block) => block.includes('-->'));
const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n'));
assert.equal(cues.length, 2);
assert.deepEqual(
cues.map((cue) => cue.split('\n')[0]),
['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'],
);
assert.ok(cueText.every((page) => [...page].length <= 80));
assert.equal(cueText.join(''), text);
});
test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => {
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext format="3">',
'<head>',
'<ws id="1" mh="0"/>',
'<wp id="1" rc="2" cc="4"/>',
'</head>',
'<body>',
'<w t="0" id="1" wp="1" ws="1"/>',
'<p t="1000" d="3000" w="1">abcdefghijklmnopqrst</p>',
'</body>',
'</timedtext>',
].join('\n'),
);
assert.equal(
result,
['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'),
);
});
test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => {
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext><body>',
'<p t="20305" d="3000" w="1">[音楽]</p>',
'<p t="26269" w="1" a="1">\n</p>',
'<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
'</body></timedtext>',
].join('\n'),
);
assert.equal(
result,
[
'WEBVTT',
'',
'00:00:20.305 --> 00:00:23.305',
'[音楽]',
'',
'00:00:26.279 --> 00:00:29.279',
'じゃあ、君からお願いします。',
'',
].join('\n'),
);
});
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
const result = normalizeYoutubeAutoVtt(
[
+228 -12
View File
@@ -2,9 +2,31 @@ interface YoutubeTimedTextRow {
startMs: number;
durationMs: number;
text: string;
isGenerated: boolean;
rollingWindow: YoutubeRollingWindow | null;
}
interface YoutubeRollingWindow {
rowCount: number;
columnCount: number;
}
interface YoutubeTimedTextWindowDefinitions {
rollingStyleIds: Set<string>;
positions: Map<string, YoutubeRollingWindow>;
windows: Map<string, YoutubeRollingWindow>;
}
interface YoutubeTimedTextDocument {
rows: YoutubeTimedTextRow[];
// Start times of every <p> event, including empty window-append fillers.
// Rolling speech rows with a 3000ms placeholder display until the next event.
eventStartsMs: number[];
hasRollingWindowEvents: boolean;
}
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000;
function decodeNumericEntity(match: string, codePoint: number): string {
if (
@@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map<string, string> {
return attrs;
}
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
function parsePositiveInteger(value: string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions {
const rollingStyleIds = new Set<string>();
for (const match of xml.matchAll(/<ws\b([^>]*)\/?\s*>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const id = attrs.get('id');
if (id !== undefined && attrs.get('mh') === '2') {
rollingStyleIds.add(id);
}
}
const positions = new Map<string, YoutubeRollingWindow>();
for (const match of xml.matchAll(/<wp\b([^>]*)\/?\s*>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const id = attrs.get('id');
const rowCount = parsePositiveInteger(attrs.get('rc'));
const columnCount = parsePositiveInteger(attrs.get('cc'));
if (id !== undefined && rowCount !== null && columnCount !== null) {
positions.set(id, { rowCount, columnCount });
}
}
const windows = new Map<string, YoutubeRollingWindow>();
for (const match of xml.matchAll(/<w\b([^>]*)\/?\s*>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const id = attrs.get('id');
const styleId = attrs.get('ws');
const positionId = attrs.get('wp');
const position = positionId === undefined ? undefined : positions.get(positionId);
if (
id !== undefined &&
styleId !== undefined &&
rollingStyleIds.has(styleId) &&
position !== undefined
) {
windows.set(id, position);
}
}
return { rollingStyleIds, positions, windows };
}
function resolveRollingWindow(
attrs: Map<string, string>,
definitions: YoutubeTimedTextWindowDefinitions,
): YoutubeRollingWindow | null {
const windowId = attrs.get('w');
if (windowId !== undefined) {
return definitions.windows.get(windowId) ?? null;
}
const styleId = attrs.get('ws');
const positionId = attrs.get('wp');
if (
styleId === undefined ||
positionId === undefined ||
!definitions.rollingStyleIds.has(styleId)
) {
return null;
}
return definitions.positions.get(positionId) ?? null;
}
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
const rows: YoutubeTimedTextRow[] = [];
const eventStartsMs: number[] = [];
let hasRollingWindowEvents = false;
const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml);
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const startMs = Number(attrs.get('t'));
if (!Number.isFinite(startMs)) {
continue;
}
eventStartsMs.push(startMs);
if (attrs.get('a') === '1') {
hasRollingWindowEvents = true;
}
const durationMs = Number(attrs.get('d'));
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
if (!Number.isFinite(durationMs)) {
continue;
}
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const rawInner = match[2] ?? '';
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const text = decodeHtmlEntities(inner).trim();
if (!text) {
continue;
}
rows.push({ startMs, durationMs, text });
rows.push({
startMs,
durationMs,
text,
isGenerated: /<s\b/.test(rawInner),
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
});
}
return rows;
eventStartsMs.sort((a, b) => a - b);
return { rows, eventStartsMs, hasRollingWindowEvents };
}
function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined {
for (const startMs of eventStartsMs) {
if (startMs > afterMs) {
return startMs;
}
}
return undefined;
}
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
}
function formatVttTimestamp(ms: number): string {
@@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
}
const ROLLING_PAGE_BREAK_PATTERN = /[\s!?]/u;
// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so
// the overlay keeps their bounded presentation without changing authored cues.
function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] {
const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount;
const characters = [...text];
if (
!Number.isSafeInteger(pageCapacity) ||
pageCapacity <= 0 ||
characters.length <= pageCapacity
) {
return [text];
}
const pages: string[] = [];
let pageStart = 0;
while (pageStart < characters.length) {
let pageEnd = Math.min(pageStart + pageCapacity, characters.length);
if (pageEnd < characters.length) {
const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6);
for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) {
if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) {
pageEnd = index + 1;
break;
}
}
}
pages.push(characters.slice(pageStart, pageEnd).join(''));
pageStart = pageEnd;
}
return pages;
}
interface TimedCaptionPage {
startMs: number;
endMs: number;
text: string;
}
function timeCaptionPages(input: {
text: string;
pages: string[];
startMs: number;
endMs: number;
}): TimedCaptionPage[] {
const durationMs = input.endMs - input.startMs;
if (input.pages.length === 1 || durationMs < input.pages.length) {
return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }];
}
const totalCharacters = [...input.text].length;
const timedPages: TimedCaptionPage[] = [];
let consumedCharacters = 0;
let pageStartMs = input.startMs;
// Automatic captions often omit span offsets, so distribute the known cue
// duration by page length while guaranteeing every page at least one ms.
for (let index = 0; index < input.pages.length; index += 1) {
const page = input.pages[index]!;
consumedCharacters += [...page].length;
const remainingPages = input.pages.length - index - 1;
const proportionalEndMs =
input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters);
const pageEndMs =
remainingPages === 0
? input.endMs
: Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages);
timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page });
pageStartMs = pageEndMs;
}
return timedPages;
}
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
if (!value) {
return false;
@@ -79,7 +276,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
}
export function convertYoutubeTimedTextToVtt(xml: string): string {
const rows = extractYoutubeTimedTextRows(xml);
const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
if (rows.length === 0) {
return 'WEBVTT\n';
}
@@ -90,10 +287,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
const row = rows[index]!;
const nextRow = rows[index + 1];
const unclampedEnd = row.startMs + row.durationMs;
// YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
// Plain-text cues can explicitly use the same duration and must keep it.
const nextEventStart =
isGeneratedRollingCue(row, hasRollingWindowEvents) &&
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
? findNextEventStartMs(eventStartsMs, row.startMs)
: undefined;
const clampedEnd =
nextRow && unclampedEnd > nextRow.startMs
? Math.max(row.startMs, nextRow.startMs - 1)
: unclampedEnd;
nextEventStart !== undefined
? nextEventStart
: nextRow && unclampedEnd > nextRow.startMs
? Math.max(row.startMs, nextRow.startMs - 1)
: unclampedEnd;
if (clampedEnd <= row.startMs) {
continue;
}
@@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
if (!text) {
continue;
}
blocks.push(
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
);
const pages = row.rollingWindow
? splitRollingCaptionIntoPages(text, row.rollingWindow)
: [text];
for (const page of timeCaptionPages({
text,
pages,
startMs: row.startMs,
endMs: clampedEnd,
})) {
blocks.push(
`${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`,
);
}
}
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
+123 -41
View File
@@ -235,6 +235,7 @@ import {
createCycleSecondarySubModeRuntimeHandler,
} from './main/runtime/domains/mpv';
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text';
import {
createBuildCopyCurrentSubtitleMainDepsHandler,
createBuildHandleMineSentenceDigitMainDepsHandler,
@@ -301,7 +302,6 @@ import {
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
listJellyfinSubtitleTracksRuntime,
loadJellyfinSubtitleDelay,
loadSubtitlePosition as loadSubtitlePositionCore,
loadYomitanExtension as loadYomitanExtensionCore,
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
@@ -311,9 +311,9 @@ import {
promoteSettingsWindowAboveOverlay,
registerGlobalShortcuts as registerGlobalShortcutsCore,
replayCurrentSubtitleRuntime,
resolveSanitizedSubtitleSeekCommand,
resolveJellyfinPlaybackPlanRuntime,
runStartupBootstrapRuntime,
saveJellyfinSubtitleDelay,
saveSubtitlePosition as saveSubtitlePositionCore,
clearYomitanParserCachesForWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
@@ -527,6 +527,7 @@ import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from './main/runtime/subtitle-prefetch-runtime';
import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track';
import {
createCreateAnilistSetupWindowHandler,
createCreateConfigSettingsWindowHandler,
@@ -585,9 +586,10 @@ import {
import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import {
createCachedInternalSubtitleTrackExtractor,
loadSubtitleSourceText,
extractInternalSubtitleTrackToTempFile,
} from './main/runtime/internal-subtitle-extraction';
import { createRemoteMediaPathDetector } from './main/runtime/network-media-path';
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
import { getSubsyncConfig } from './subsync/utils';
@@ -673,7 +675,6 @@ function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
let backgroundWarmupsStarted = false;
@@ -1806,10 +1807,42 @@ async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text,
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues,
});
return {
...payload,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null,
endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? null,
};
}
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues,
});
// Same validity bar as the live capture path: an unusable canonical span must fall
// back rather than hand mining an empty line or an inverted range.
const canonicalText = canonical?.text.trim();
if (
!canonical ||
!canonicalText ||
!Number.isFinite(canonical.startTime) ||
!Number.isFinite(canonical.endTime) ||
canonical.endTime <= canonical.startTime
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return {
source: 'overlay',
text: canonicalText,
startTime: canonical.startTime,
endTime: canonical.endTime,
capturedAtMs: Date.now(),
};
}
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
@@ -1924,6 +1957,31 @@ let linuxVisibleOverlayOwnerBindingKey: string | null = null;
let linuxVisibleOverlayWindowModeSwitchToken = 0;
let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
let explicitSeekIntentExpiresAtMs = 0;
function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean {
return command[0] === 'seek' || command[0] === 'sub-seek';
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
const command =
resolveSanitizedSubtitleSeekCommand(
rawCommand,
appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN,
) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
}
sendMpvCommandRuntime(appState.mpvClient, command);
}
function consumeExplicitSeekIntent(): boolean {
const pending = explicitSeekIntentExpiresAtMs >= Date.now();
explicitSeekIntentExpiresAtMs = 0;
return pending;
}
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => appState.currentMediaPath,
@@ -1943,7 +2001,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
getLastObservedTimePos: () => lastObservedTimePos,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
emitSecondarySubtitle: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
secondarySubtitleTrackController.handleLiveText(text);
},
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
@@ -1994,13 +2052,33 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
}
},
});
const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor();
const detectRemoteMediaPath = createRemoteMediaPathDetector();
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message),
});
const secondarySubtitleTrackController = createSecondarySubtitleTrackController({
getMpvClient: () => appState.mpvClient,
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos,
resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
setCurrentSecondaryText: (text) => {
if (appState.mpvClient) {
appState.mpvClient.currentSecondarySubText = text;
}
},
broadcastSecondaryText: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
@@ -2008,8 +2086,8 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
// Remote media has no extractable on-disk track to fall back to, so a transient
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
// otherwise drop a working cue list for the rest of the episode.
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)),
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
logDebug: (message) => logger.debug(message),
@@ -2401,7 +2479,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback;
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({
@@ -2571,6 +2648,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
userDataPath: USER_DATA_PATH,
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
onIndexReady: () => refreshCurrentSubtitleAnnotations(),
onIndexReadyError: (error) =>
logger.warn(
'Failed to refresh subtitle annotations after character portrait index became ready.',
error,
),
});
// Lets the Yomitan scan runtime skip name lookups at positions where no
@@ -3019,23 +3102,6 @@ const {
wait: (ms) => new Promise<void>((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) => {
@@ -3101,7 +3167,6 @@ const {
getActivePlayback: () => activeJellyfinRemotePlayback,
clearActivePlayback: () => {
activeJellyfinRemotePlayback = null;
activeJellyfinSubtitleDelayKey = null;
},
getSession: () => appState.jellyfinRemoteSession,
getNow: () => Date.now(),
@@ -3878,6 +3943,7 @@ const {
appState.yomitanSettingsWindow = null;
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
@@ -3995,7 +4061,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
};
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
function refreshCurrentSubtitleAnnotations(): void {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause();
@@ -4006,7 +4072,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
// Idle controller: no settle is coming to release the pause above.
subtitlePrefetchService?.resume();
}
};
}
let hasAttemptedImmersionTrackerStartup = false;
const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
@@ -4383,6 +4449,7 @@ const {
onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -4401,6 +4468,9 @@ const {
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
onSecondarySubtitleChange: (text) => {
secondarySubtitleTrackController.handleLiveText(text);
},
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
emitSubtitlePayload(payload);
@@ -4434,6 +4504,8 @@ const {
appState.activeParsedSubtitleMediaPath,
);
if ((normalizedPath || null) !== previousPath) {
cachedInternalSubtitleTrackExtractor.clear();
secondarySubtitleTrackController.reset();
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
@@ -4451,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);
@@ -4468,6 +4539,7 @@ const {
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
// Retry after a short delay because MPV can populate track-list after path.
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
@@ -4516,12 +4588,14 @@ const {
reportJellyfinRemoteProgress: (forceImmediate) => {
void reportJellyfinRemoteProgress(forceImmediate);
},
consumeExplicitSeek: () => consumeExplicitSeekIntent(),
onTimePosUpdate: (time) => {
const delta = time - lastObservedTimePos;
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
subtitlePrefetchService.onSeek(time);
}
lastObservedTimePos = time;
secondarySubtitleTrackController.handleTimePos(time);
},
onFullscreenChange: (fullscreen) => {
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
@@ -4549,6 +4623,13 @@ const {
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
},
onSecondarySubtitleTrackChange: () => {
secondarySubtitleTrackController.handleTrackChange();
secondarySubtitleTrackController.scheduleRefresh(0);
},
onSecondarySubtitleDelayChange: (delay) => {
secondarySubtitleTrackController.handleDelayChange(delay);
},
onSubtitleTrackListChange: (trackList) => {
const diagnostics = buildSubtitleTrackDiagnostics(
lastObservedPrimarySubtitleTrackId,
@@ -4562,6 +4643,7 @@ const {
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
}
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
secondarySubtitleTrackController.scheduleRefresh(0);
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
},
@@ -5014,9 +5096,7 @@ function initializeOverlayRuntime(): void {
overlayModalRuntime.primeModalWindow();
}
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate,
);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
syncOverlayMpvSubtitleSuppression();
}
@@ -5231,6 +5311,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => appState.ankiIntegration,
getMpvClient: () => appState.mpvClient,
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
mineSentenceCardCore,
recordCardsMined: (count, noteIds) => {
@@ -5413,8 +5494,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
sendMpvCommand: (rawCommand: (string | number)[]) =>
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
sendMpvCommand: (rawCommand: (string | number)[]) => sendRendererMpvCommand(rawCommand),
getMpvClient: () => appState.mpvClient,
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
@@ -5544,9 +5624,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
recordSubtitleMiningContext: (context) =>
recordSubtitleMiningContext(
context ?? captureLiveSubtitleMiningContext(appState.mpvClient),
),
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => {
@@ -5810,7 +5888,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration = integration;
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate,
refreshCurrentSubtitleAnnotations,
);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext,
@@ -5824,6 +5902,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showDesktopNotification,
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () =>
overlayVisibilityComposer.broadcastRuntimeOptionsChanged(),
@@ -6314,6 +6394,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showDesktopNotification,
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
+3 -3
View File
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
const resolvedNameSplits = nameSplitTokenizerAvailable
const nameSplitResolution = nameSplitTokenizerAvailable
? await resolveJapaneseNameSplits(
characters,
deps.tokenizeJapaneseName!,
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
},
)
: undefined;
const nameSplitSource =
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
const resolvedNameSplits = nameSplitResolution?.splits;
const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
progress?.onGenerateProgress?.({
mediaId,
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
assert.equal(scoped.alt, 'Kazuma');
});
test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 21858,
mediaTitle: 'Little Witch Academia',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'ダイアナ',
'だいあな',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: {
tag: 'img',
path: 'img/m21858-c81709.png',
alt: 'ダイアナ・キャベンディッシュ',
},
},
],
0,
'',
],
],
images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }],
};
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const callbackError = new Error('annotation refresh failed');
const reportingError = new Error('error reporter failed');
let readyCount = 0;
const reportedErrors: unknown[] = [];
const lookup = createCharacterDictionaryImageLookup({
outputDir,
onIndexReady: () => {
readyCount += 1;
if (readyCount === 1) {
throw callbackError;
}
},
onIndexReadyError: (error) => {
reportedErrors.push(error);
throw reportingError;
},
});
assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null);
await waitForRefresh(() => (reportedErrors.length === 1 ? true : null));
assert.ok(lookup.get('ダイアナ', snapshot.mediaId));
assert.equal(readyCount, 2);
assert.deepEqual(reportedErrors, [callbackError]);
lookup.get('ダイアナ', snapshot.mediaId);
assert.equal(readyCount, 2);
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string;
outputDir?: string;
getCurrentMediaId?: () => number | null | undefined;
onIndexReady?: () => void;
onIndexReadyError?: (error: unknown) => void;
}): {
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void;
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
let refreshInFlight = false;
let indexReadyDeliveryPending = false;
function deliverIndexReadyIfPending(): void {
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
return;
}
indexReadyDeliveryPending = false;
try {
deps.onIndexReady();
} catch (error) {
indexReadyDeliveryPending = true;
try {
deps.onIndexReadyError?.(error);
} catch {
// Error reporting must not reject the detached index refresh task.
}
}
}
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
signature = '';
return;
}
deliverIndexReadyIfPending();
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature || refreshInFlight) {
return;
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
index = nextIndex;
indexByMediaId = nextIndexByMediaId;
signature = nextSignature;
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
deliverIndexReadyIfPending();
} finally {
refreshInFlight = false;
}
@@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
}),
);
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' });
assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
});
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
@@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
}),
);
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
});
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
@@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
}),
);
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
});
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
@@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
}),
);
assert.equal(splits.size, 0);
assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
});
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
@@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
}),
);
assert.equal(splits.size, 0);
assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
});
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
(message) => warnings.push(message),
);
assert.equal(splits.size, 0);
assert.equal(splits.kind, 'incomplete');
assert.equal(splits.splits.size, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mecab unavailable/);
});
@@ -7,6 +7,10 @@ import type {
ResolvedNameSplit,
} from './types';
export type JapaneseNameSplitResolution =
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
function joinSurfaces(tokens: NameSplitToken[]): string {
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
tokenize: NameSplitTokenizer,
logWarn?: (message: string) => void,
onCharacterResolved?: (completed: number, total: number) => void,
): Promise<Map<string, ResolvedNameSplit>> {
): Promise<JapaneseNameSplitResolution> {
const splits = new Map<string, ResolvedNameSplit>();
let tokenizerFailed = false;
let resolvedCharacters = 0;
for (const character of characters) {
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
try {
tokens = await tokenize(name);
} catch (err) {
tokenizerFailed = true;
logWarn?.(
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
);
continue;
}
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
if (!tokens) {
tokenizerFailed = true;
continue;
}
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
const splitIndex =
splitIndexFromPersonNamePos(tokens) ??
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
resolvedCharacters += 1;
onCharacterResolved?.(resolvedCharacters, characters.length);
}
return splits;
return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
}
@@ -7,7 +7,7 @@ import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import type { CharacterDictionarySnapshot } from './types';
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
const GRAPHQL_URL = 'https://graphql.anilist.co';
const PNG_1X1 = Buffer.from(
@@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
}
});
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
characterPageRequests: number;
firstResultFromCache: boolean;
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
secondResultFromCache: boolean;
}> {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
}) as typeof globalThis.fetch;
try {
let tokenizerCalls = 0;
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
source: 'fallback',
}),
getNameMatchImagesEnabled: () => false,
tokenizeJapaneseName: async () => {
tokenizerCalls += 1;
return null;
},
tokenizeJapaneseName,
getJapaneseNameTokenizerAvailable: () => true,
now: () => 1_700_000_000_500,
});
const result = await runtime.generateForCurrentMedia();
const firstResult = await runtime.generateForCurrentMedia();
const refreshedSnapshot = JSON.parse(
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
) as CharacterDictionarySnapshot;
const secondResult = await runtime.generateForCurrentMedia();
assert.equal(result.fromCache, false);
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
const retriedResult = await runtime.generateForCurrentMedia();
assert.equal(retriedResult.fromCache, false);
assert.equal(characterPageRequests, 2);
assert.equal(tokenizerCalls, 2);
return {
characterPageRequests,
firstResultFromCache: firstResult.fromCache,
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
secondResultFromCache: secondResult.fromCache,
};
} finally {
globalThis.fetch = originalFetch;
}
}
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return null;
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'heuristic');
assert.equal(result.secondResultFromCache, false);
assert.equal(result.characterPageRequests, 2);
assert.equal(tokenizerCalls, 2);
});
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return [];
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'mecab');
assert.equal(result.secondResultFromCache, true);
assert.equal(result.characterPageRequests, 1);
assert.equal(tokenizerCalls, 1);
});
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
+2
View File
@@ -132,6 +132,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
@@ -334,6 +335,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
: {}),
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
dismissOverlayNotification: params.dismissOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver,
+36 -3
View File
@@ -183,7 +183,10 @@ test('remote media keeps parsed cues when the active subtitle source cannot be r
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
assert.match(
actionBlock,
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
);
});
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
@@ -482,10 +485,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
@@ -503,6 +506,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
);
});
test('character portrait index readiness refreshes cached subtitle annotations', () => {
const source = readMainSource();
const lookupDeps = source.match(
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(lookupDeps);
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
assert.match(
lookupDeps,
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
);
});
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
const source = readMainSource();
const depsBlock = source.match(
@@ -846,3 +863,19 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
);
});
test('main process extracts internal subtitle tracks without a network-mount guard', () => {
const source = readMainSource();
const resolverWiring = source.match(
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(resolverWiring);
// Network-mounted files are extracted like local ones; only remote URLs skip
// extraction, handled inside the resolver itself.
assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/);
assert.match(
resolverWiring,
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
);
});
@@ -64,11 +64,17 @@ test('anki action main deps builders map callbacks', async () => {
const mine = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
getMpvClient: () => ({ connected: true }),
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
showMpvOsd: (text) => calls.push(`mine:${text}`),
mineSentenceCardCore: async () => true,
recordCardsMined: (count) => calls.push(`cards:${count}`),
})();
assert.deepEqual(mine.getMpvClient(), { connected: true });
assert.deepEqual(mine.getPrimarySubtitle?.(), {
text: '正式な字幕',
startTime: 1,
endTime: 3,
});
mine.showMpvOsd('m');
await mine.mineSentenceCardCore({
ankiIntegration: { enabled: true },
+7 -1
View File
@@ -1,4 +1,4 @@
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions';
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
@@ -72,10 +72,12 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
getPrimarySubtitle?: () => PrimarySubtitle | null;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
@@ -83,10 +85,14 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
getMpvClient: () => deps.getMpvClient(),
...(deps.getPrimarySubtitle
? { getPrimarySubtitle: () => deps.getPrimarySubtitle?.() ?? null }
: {}),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => deps.mineSentenceCardCore(options),
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
+17
View File
@@ -87,3 +87,20 @@ test('mine sentence handler records mined cards only when core returns true', as
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
});
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => ({}),
getMpvClient: () => ({}),
getPrimarySubtitle: () => primarySubtitle,
showMpvOsd: () => {},
mineSentenceCardCore: async (options) => {
assert.equal(options.primarySubtitle, primarySubtitle);
return true;
},
recordCardsMined: () => {},
});
await mineSentenceCard();
});
+10
View File
@@ -2,6 +2,12 @@ type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>;
};
export type PrimarySubtitle = {
text: string;
startTime: number;
endTime: number;
};
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
@@ -69,18 +75,22 @@ export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
getPrimarySubtitle?: () => PrimarySubtitle | null;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) {
return async (): Promise<void> => {
const primarySubtitle = deps.getPrimarySubtitle?.();
const created = await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(),
...(primarySubtitle ? { primarySubtitle } : {}),
showMpvOsd: deps.showMpvOsd,
});
if (created) {
@@ -43,6 +43,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
@@ -50,10 +51,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 34);
assert.equal(calls.length, 35);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
@@ -97,6 +99,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
@@ -104,7 +107,11 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
});
assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
]);
});
test('should restore windows on activate requires initialized runtime and no windows', () => {
+6 -1
View File
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void;
@@ -67,7 +68,11 @@ export function createOnWillQuitCleanupHandler(deps: {
try {
deps.stopJellyfinRemoteSession();
} finally {
deps.cleanupJellyfinSubtitleCache();
try {
deps.cleanupJellyfinSubtitleCache();
} finally {
deps.cleanupInternalSubtitleTrackCache();
}
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
@@ -72,6 +72,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
@@ -95,6 +96,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -152,6 +154,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {},
@@ -204,6 +207,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {},
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void;
@@ -144,6 +145,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
import type { SubtitleData } from '../../types';
import {
@@ -211,6 +212,69 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
]);
});
test('parsed cues replace a duplicate raw autoplay subtitle that was already primed', async () => {
const rawText = 'ジグザグな道を抜け\nジグザグな道を抜け';
const correctedText = 'ジグザグな道を抜け';
const mediaPath = '/media/video.mkv';
let currentSubText = '';
const emitted: string[] = [];
const client = {
connected: true,
currentVideoPath: mediaPath,
currentTimePos: 90,
currentSubText: rawText,
requestProperty: async (name: string) => {
if (name === 'sub-text') return rawText;
if (name === 'time-pos') return 90;
return null;
},
};
const cues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
`Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
`Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
].join('\n'),
'startup-ending.ass',
);
let activeCues = cues.slice(0, 0);
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => client,
setCurrentSubText: (text) => {
currentSubText = text;
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => activeCues,
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: () => true,
refreshCurrentSubtitle: () => true,
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: (payload) => emitted.push(payload.text),
getSubtitlePrefetchService: () => null,
getLastObservedTimePos: () => 90,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
logDebug: () => {},
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.equal(currentSubText, rawText);
activeCues = cues;
await runtime.primeAutoplaySubtitleFromParsedCues(mediaPath, cues);
assert.equal(currentSubText, correctedText);
assert.deepEqual(emitted, [rawText, correctedText]);
});
// Driven by the real processing controller rather than a stub: the failure this
// covers is a disagreement between the priming path and the controller's own
// staleness rules, which a hand-written stub cannot reproduce.
@@ -1,6 +1,7 @@
import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
@@ -11,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string;
currentTimePos?: number;
currentSubText?: string;
currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
};
@@ -106,11 +108,19 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
autoplaySubtitlePrimedMediaPath = null;
}
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
function emitAutoplayPrimedSubtitle(
mediaPath: string,
text: string,
options: { replaceExisting?: boolean } = {},
): boolean {
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
return false;
}
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
if (!options.replaceExisting || deps.getCurrentSubText() === text) {
return false;
}
} else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
return false;
}
@@ -141,6 +151,16 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return true;
}
function resolveLivePrimarySubtitleText(text: string): string {
const client = deps.getMpvClient();
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({
liveText: text,
currentTimeSec,
cues: deps.getActiveParsedSubtitleCues(),
});
}
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
@@ -155,7 +175,8 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
);
return null;
});
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
const text = resolveLivePrimarySubtitleText(liveText);
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
return;
}
@@ -175,6 +196,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => deps.getMpvClient(),
resolvePrimarySubtitleText: (text) => resolveLivePrimarySubtitleText(text),
setCurrentSubText: (text) => {
deps.setCurrentSubText(text);
},
@@ -239,11 +261,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
mediaPath: string,
cues: SubtitleCue[],
): Promise<void> {
if (
cues.length === 0 ||
autoplaySubtitlePrimedMediaPath === mediaPath ||
!isCurrentAutoplayMediaPath(mediaPath)
) {
if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) {
return;
}
@@ -252,16 +270,21 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
);
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
const cue = selectAutoplayStartupCue(
cues,
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
resolvedTimeSeconds,
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
);
if (!cue) {
const liveText = client?.currentSubText ?? '';
const text = liveText.trim()
? resolvePrimarySubtitleText({ liveText, currentTimeSec: resolvedTimeSeconds, cues })
: (cue?.text ?? '');
if (!text) {
return;
}
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true });
}
function clearScheduledSubtitlePrefetchRefresh(): void {
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {},
@@ -46,6 +46,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
getMpvClient: () => CurrentSubtitleMpvClient | null;
setCurrentSubText: (text: string) => void;
resolvePrimarySubtitleText?: (text: string) => string;
getCurrentSubtitleData: () => SubtitleData | null;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
@@ -73,7 +74,8 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
return;
}
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
const text = deps.resolvePrimarySubtitleText?.(liveText) ?? liveText;
deps.setCurrentSubText(text);
const primeSecondarySubtitle = async (): Promise<void> => {
@@ -6,6 +6,7 @@ import process from 'node:process';
import test from 'node:test';
import {
buildFfmpegSubtitleExtractionArgs,
createCachedInternalSubtitleTrackExtractor,
extractInternalSubtitleTrackToTempFile,
parseTrackId,
} from './internal-subtitle-extraction';
@@ -22,6 +23,65 @@ test('parseTrackId rejects negative track ids', () => {
assert.equal(parseTrackId(' -2 '), null);
});
test('cached internal subtitle extraction shares concurrent and repeated track requests', async () => {
let extractionCalls = 0;
let cleanupCalls = 0;
let resolveExtraction:
| ((result: { path: string; cleanup: () => Promise<void> }) => void)
| undefined;
const firstExtraction = new Promise<{ path: string; cleanup: () => Promise<void> }>((resolve) => {
resolveExtraction = resolve;
});
const extractor = createCachedInternalSubtitleTrackExtractor({
extract: async () => {
extractionCalls += 1;
if (extractionCalls === 1) {
return firstExtraction;
}
return {
path: `/tmp/subtitle-${extractionCalls}.ass`,
cleanup: async () => {
cleanupCalls += 1;
},
};
},
});
const request = () =>
extractor.extract('ffmpeg', '/Volumes/media/episode.mkv', {
'ff-index': 3,
codec: 'ass',
});
const concurrent = Array.from({ length: 6 }, request);
assert.equal(extractionCalls, 1);
if (!resolveExtraction) {
throw new Error('extraction did not start');
}
resolveExtraction({
path: '/tmp/subtitle-1.ass',
cleanup: async () => {
cleanupCalls += 1;
},
});
const results = await Promise.all(concurrent);
assert.deepEqual(
results.map((result) => result?.path),
Array.from({ length: 6 }, () => '/tmp/subtitle-1.ass'),
);
await Promise.all(results.map((result) => result?.cleanup()));
assert.equal(cleanupCalls, 0);
assert.equal((await request())?.path, '/tmp/subtitle-1.ass');
assert.equal(extractionCalls, 1);
extractor.clear();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cleanupCalls, 1);
assert.equal((await request())?.path, '/tmp/subtitle-2.ass');
assert.equal(extractionCalls, 2);
});
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
const videoPath = path.join(root, 'video.mkv');
@@ -35,7 +35,21 @@ export type MpvSubtitleTrackLike = {
'external-filename'?: unknown;
};
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
export type ExtractedInternalSubtitleTrack = {
path: string;
cleanup: () => Promise<void>;
};
export type InternalSubtitleTrackExtractor = (
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
) => Promise<ExtractedInternalSubtitleTrack | null>;
// Subtitle packets are interleaved through the container, so extraction reads the
// entire file. Network mounts move ~100 MB/s on gigabit, so large Bluray remuxes
// need well over 30 seconds.
const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000;
export function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
@@ -80,7 +94,7 @@ export async function extractInternalSubtitleTrackToTempFile(
videoPath: string,
track: MpvSubtitleTrackLike,
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
): Promise<ExtractedInternalSubtitleTrack | null> {
const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined);
@@ -145,3 +159,69 @@ export async function extractInternalSubtitleTrackToTempFile(
},
};
}
type CachedExtraction = {
promise: Promise<ExtractedInternalSubtitleTrack | null>;
};
function buildCachedExtractionKey(
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): string {
const codec = typeof track.codec === 'string' ? track.codec : null;
return JSON.stringify([ffmpegPath, videoPath, parseTrackId(track['ff-index']), codec]);
}
const releaseCachedExtraction = async (): Promise<void> => {};
/**
* Owns extracted subtitle files for the active media and shares one extraction between callers.
* Caller cleanup releases only its view; clear removes the owned files on media changes or quit.
*/
export function createCachedInternalSubtitleTrackExtractor(
deps: { extract?: InternalSubtitleTrackExtractor } = {},
): {
extract: InternalSubtitleTrackExtractor;
clear: () => void;
} {
const extractTrack = deps.extract ?? extractInternalSubtitleTrackToTempFile;
const extractions = new Map<string, CachedExtraction>();
const extract: InternalSubtitleTrackExtractor = async (ffmpegPath, videoPath, track) => {
const key = buildCachedExtractionKey(ffmpegPath, videoPath, track);
let cached = extractions.get(key);
if (!cached) {
const next: CachedExtraction = {
promise: extractTrack(ffmpegPath, videoPath, track),
};
cached = next;
extractions.set(key, next);
void next.promise.catch(() => {
if (extractions.get(key) === next) {
extractions.delete(key);
}
});
}
const result = await cached.promise;
if (extractions.get(key) !== cached || !result) {
return null;
}
return {
path: result.path,
cleanup: releaseCachedExtraction,
};
};
const clear = (): void => {
const staleExtractions = [...extractions.values()];
extractions.clear();
for (const extraction of staleExtractions) {
void extraction.promise.then((result) => result?.cleanup()).catch(() => undefined);
}
};
return { extract, clear };
}
@@ -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']);
});
@@ -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,
@@ -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<string>;
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<Array<string | number>> = [];
const activeDelayKeys: Array<unknown> = [];
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<Array<string | number>> = [];
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<Array<string | number>> = [];
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<Array<string | number>> = [];
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<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
+1 -89
View File
@@ -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<string>;
logDebug: (message: string, error: unknown) => void;
},
primaryTrack: CachedExternalSubtitleTrack | null,
referenceTrack: CachedExternalSubtitleTrack | null,
): Promise<number | null> {
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<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
@@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
itemId: string;
}): Promise<void> => {
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) {
@@ -8,6 +8,18 @@ import {
resolveManagedLinuxRuntimePluginPaths,
} from './linux-runtime-plugin-assets';
const THUMBNAILER_RELATIVE_PATH = path.join(
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
);
function writeThumbnailer(rootDir: string, content = '[Thumbnailer Entry]\n'): string {
const thumbnailerPath = path.join(rootDir, THUMBNAILER_RELATIVE_PATH);
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
fs.writeFileSync(thumbnailerPath, content);
return thumbnailerPath;
}
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
try {
@@ -48,6 +60,7 @@ test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', ()
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
thumbnailerPath: '/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
});
});
@@ -79,6 +92,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
@@ -94,6 +108,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
thumbnailerSourcePath,
}),
});
@@ -117,6 +132,19 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
),
'/* theme */\n',
);
assert.equal(
fs.readFileSync(
path.join(
tempDir,
'xdg-data',
'SubMiner',
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
'utf8',
),
'[Thumbnailer Entry]\n',
);
});
});
@@ -124,6 +152,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
@@ -143,6 +172,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
thumbnailerSourcePath,
}),
});
@@ -169,6 +199,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
await withTempDir(async (tempDir) => {
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
@@ -183,6 +214,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl
xdgDataHome,
resolveBundledAssets: () => ({
themeSourcePath,
thumbnailerSourcePath,
}),
});
@@ -250,6 +282,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* existing theme */\n',
);
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
@@ -258,6 +291,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
thumbnailerSourcePath,
}),
});
@@ -332,6 +366,7 @@ test('ensureLinuxRuntimePluginAssets returns already-present when managed assets
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* theme */\n',
);
writeThumbnailer(path.join(xdgDataHome, 'SubMiner'));
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
@@ -369,6 +404,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
@@ -385,6 +421,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
thumbnailerSourcePath,
}),
copyFile: async () => {
throw new Error('copy failed');
@@ -10,6 +10,7 @@ export interface ManagedLinuxRuntimePluginPaths {
pluginEntrypointPath: string;
pluginConfigPath: string;
themePath: string;
thumbnailerPath: string;
}
export interface EnsureLinuxRuntimePluginAssetsResult {
@@ -23,6 +24,7 @@ interface RuntimePluginAssetSources {
pluginDirSource?: string;
pluginConfigSource?: string;
themeSourcePath?: string;
thumbnailerSourcePath?: string;
}
interface RuntimePluginDirentLike {
@@ -72,6 +74,11 @@ export function resolveManagedLinuxRuntimePluginPaths(options: {
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
thumbnailerPath: pathModule.join(
dataDir,
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
};
}
@@ -95,11 +102,12 @@ async function copyDirectoryRecursive(
}
}
function resolveBundledThemePath(options: {
function resolveBundledAssetPath(options: {
dirname: string;
appPath: string;
resourcesPath: string;
existsSync: (candidate: string) => boolean;
relativePath: string;
}): string | null {
const roots = [
path.join(options.resourcesPath, 'assets'),
@@ -111,7 +119,7 @@ function resolveBundledThemePath(options: {
];
for (const root of roots) {
const candidate = path.join(root, 'themes', 'subminer.rasi');
const candidate = path.join(root, options.relativePath);
if (options.existsSync(candidate)) return candidate;
}
@@ -129,16 +137,25 @@ function resolveBundledAssetsDefault(
existsSync,
});
const themeSourcePath = resolveBundledThemePath({
const themeSourcePath = resolveBundledAssetPath({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
relativePath: path.join('themes', 'subminer.rasi'),
});
const thumbnailerSourcePath = resolveBundledAssetPath({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
relativePath: path.join('thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer'),
});
return {
...(pluginAssets ?? {}),
...(themeSourcePath ? { themeSourcePath } : {}),
...(thumbnailerSourcePath ? { thumbnailerSourcePath } : {}),
};
}
@@ -178,7 +195,8 @@ export async function ensureLinuxRuntimePluginAssets(
const pluginAssetsExist =
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
const themeExists = existsSync(managedPaths.themePath);
if (pluginAssetsExist && themeExists) {
const thumbnailerExists = existsSync(managedPaths.thumbnailerPath);
if (pluginAssetsExist && themeExists && thumbnailerExists) {
return {
ok: true,
status: 'already-present',
@@ -193,6 +211,7 @@ export async function ensureLinuxRuntimePluginAssets(
const shouldInstallPluginAssets = !pluginAssetsExist;
const shouldInstallTheme = !themeExists;
const shouldInstallThumbnailer = !thumbnailerExists;
if (
shouldInstallPluginAssets &&
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
@@ -210,6 +229,13 @@ export async function ensureLinuxRuntimePluginAssets(
error: 'Bundled Linux runtime theme asset was not found.',
};
}
if (shouldInstallThumbnailer && !bundledAssets.thumbnailerSourcePath) {
return {
ok: false,
status: 'failed',
error: 'Bundled Linux rofi thumbnailer asset was not found.',
};
}
const stagingSuffix = `${process.pid}-${Date.now()}`;
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
@@ -221,9 +247,14 @@ export async function ensureLinuxRuntimePluginAssets(
pathModule.dirname(managedPaths.themePath),
`.subminer.rasi-stage-${stagingSuffix}`,
);
const stagedThumbnailerPath = pathModule.join(
pathModule.dirname(managedPaths.thumbnailerPath),
`.subminer-ffmpegthumbnailer.thumbnailer-stage-${stagingSuffix}`,
);
let pluginDirInstalled = false;
let pluginConfigInstalled = false;
let themeInstalled = false;
let thumbnailerInstalled = false;
try {
if (shouldInstallPluginAssets) {
@@ -249,6 +280,14 @@ export async function ensureLinuxRuntimePluginAssets(
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
await copyFile(themeSourcePath, stagedThemePath);
}
if (shouldInstallThumbnailer) {
const thumbnailerSourcePath = bundledAssets.thumbnailerSourcePath;
if (!thumbnailerSourcePath) {
throw new Error('Bundled Linux rofi thumbnailer asset was not found.');
}
await mkdir(pathModule.dirname(managedPaths.thumbnailerPath), { recursive: true });
await copyFile(thumbnailerSourcePath, stagedThumbnailerPath);
}
if (shouldInstallPluginAssets) {
await rm(managedPaths.pluginDir, { recursive: true, force: true });
await rm(managedPaths.pluginConfigPath, { force: true });
@@ -262,6 +301,11 @@ export async function ensureLinuxRuntimePluginAssets(
await rename(stagedThemePath, managedPaths.themePath);
themeInstalled = true;
}
if (shouldInstallThumbnailer) {
await rm(managedPaths.thumbnailerPath, { force: true });
await rename(stagedThumbnailerPath, managedPaths.thumbnailerPath);
thumbnailerInstalled = true;
}
return {
ok: true,
@@ -278,9 +322,13 @@ export async function ensureLinuxRuntimePluginAssets(
if (themeInstalled) {
await rm(managedPaths.themePath, { force: true }).catch(() => {});
}
if (thumbnailerInstalled) {
await rm(managedPaths.thumbnailerPath, { force: true }).catch(() => {});
}
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
await rm(stagedThemePath, { force: true }).catch(() => {});
await rm(stagedThumbnailerPath, { force: true }).catch(() => {});
return {
ok: false,
status: 'failed',
@@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => {
onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {},
onSubtitleTrackChange: () => {},
onSecondarySubtitleTrackChange: () => {},
onSecondarySubtitleDelayChange: () => {},
onSubtitleTrackListChange: () => {},
onSubtitleTiming: () => {},
onMediaPathChange: () => {},
@@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => {
'subtitle-ass-change',
'secondary-subtitle-change',
'subtitle-track-change',
'secondary-subtitle-track-change',
'secondary-subtitle-delay-change',
'subtitle-track-list-change',
'subtitle-timing',
'media-path-change',
@@ -4,6 +4,8 @@ type MpvBindingEventName =
| 'subtitle-ass-change'
| 'secondary-subtitle-change'
| 'subtitle-track-change'
| 'secondary-subtitle-track-change'
| 'secondary-subtitle-delay-change'
| 'subtitle-track-list-change'
| 'subtitle-timing'
| 'media-path-change'
@@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: {
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
onSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string | null }) => void;
@@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: {
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange);
mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange);
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
mpvClient.on('media-path-change', deps.onMediaPathChange);
@@ -26,6 +26,29 @@ test('subtitle change handler updates state and forwards uncached text without r
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
});
test('subtitle change handler consistently forwards resolved canonical text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
resolveSubtitleText: () => '今 手にある物差しでは',
setCurrentSubText: (text) => calls.push(`set:${text}`),
getImmediateSubtitlePayload: (text) => {
calls.push(`lookup:${text}`);
return null;
},
broadcastSubtitle: () => {},
onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => {},
});
handler({ text: '今今今手手手ににに' });
assert.deepEqual(calls, [
'set:今 手にある物差しでは',
'lookup:今 手にある物差しでは',
'process:今 手にある物差しでは',
]);
});
test('subtitle change handler clears immediately for empty subtitle text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
@@ -335,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () =>
]);
});
test('time-pos handler treats an explicit short jump as a seek', () => {
const updateKinds: string[] = [];
let explicitSeekPending = false;
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: () => {},
reportJellyfinRemoteProgress: () => {},
refreshDiscordPresence: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
consumeExplicitSeek: () => {
const pending = explicitSeekPending;
explicitSeekPending = false;
return pending;
},
onTimePosUpdate: (_time, kind) => updateKinds.push(kind),
});
timeHandler({ time: 10 });
explicitSeekPending = true;
timeHandler({ time: 11.5 });
timeHandler({ time: 11.6 });
assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
+18 -5
View File
@@ -4,7 +4,10 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
type TimePosUpdateKind = 'initial' | 'playback' | 'seek';
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
@@ -14,6 +17,7 @@ function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): bo
}
export function createHandleMpvSubtitleChangeHandler(deps: {
resolveSubtitleText?: (text: string) => string;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
@@ -22,7 +26,8 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
refreshDiscordPresence: () => void;
logDebug?: (message: string) => void;
}) {
return ({ text }: { text: string }): void => {
return ({ text: liveText }: { text: string }): void => {
const text = deps.resolveSubtitleText?.(liveText) ?? liveText;
deps.setCurrentSubText(text);
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
@@ -135,12 +140,20 @@ export function createHandleMpvTimePosChangeHandler(deps: {
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
consumeExplicitSeek?: () => boolean;
}) {
let lastObservedTime: number | null = null;
return ({ time }: { time: number }): void => {
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
const explicitSeek = deps.consumeExplicitSeek?.() ?? false;
const updateKind: TimePosUpdateKind =
lastObservedTime === null
? 'initial'
: explicitSeek || isSeekLikeTimeChange(lastObservedTime, time)
? 'seek'
: 'playback';
const forceImmediate = updateKind === 'seek';
if (Number.isFinite(time)) {
lastObservedTime = time;
}
@@ -150,7 +163,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
deps.onTimePosUpdate?.(time, updateKind);
};
}
@@ -1,10 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
test('main mpv event binder wires callbacks through to runtime deps', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
let currentTime = 0;
const seekLiveText = '少しだけ好きになる\n少しだけ好きになる';
const seekCues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
].join('\n'),
'seek-ending.ass',
);
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
@@ -27,6 +40,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
resolveSubtitleText: (liveText) =>
resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }),
getCurrentLiveSubtitleText: () => seekLiveText,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
@@ -37,6 +53,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
onSecondarySubtitleDelayChange: (delay) =>
calls.push(`secondary-subtitle-delay-change:${delay}`),
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
@@ -57,6 +76,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
reportJellyfinRemoteProgress: (forceImmediate) =>
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
onTimePosUpdate: (time) => {
currentTime = time;
},
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
@@ -73,12 +95,20 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
assert.ok(calls.includes('set-sub:少しだけ好きになる'));
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
@@ -86,6 +116,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.equal(calls.includes('broadcast-sub:line'), true);
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
assert.ok(calls.includes('subtitle-track-list-change'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
+17 -1
View File
@@ -43,6 +43,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void;
resolveSubtitleText?: (text: string) => string;
getCurrentLiveSubtitleText?: () => string;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void;
@@ -54,6 +56,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
broadcastSubtitleAss: (text: string) => void;
broadcastSecondarySubtitle: (text: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleDelayChange?: (delay: number) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
@@ -75,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
recordPauseState: (paused: boolean) => void;
@@ -117,6 +122,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
resolveSubtitleText: deps.resolveSubtitleText,
setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: deps.emitImmediateSubtitle
@@ -167,7 +173,15 @@ export function createBindMpvMainEventHandlersHandler(deps: {
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time, updateKind) => {
deps.onTimePosUpdate?.(time);
if (updateKind === 'playback') return;
const liveText = deps.getCurrentLiveSubtitleText?.();
if (liveText !== undefined) {
handleMpvSubtitleChange({ text: liveText });
}
},
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused),
@@ -189,6 +203,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid),
onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay),
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
onSubtitleTiming: handleMpvSubtitleTiming,
onMediaPathChange: handleMpvMediaPathChange,
@@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
@@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.onSecondarySubtitleTrackChange?.(4);
deps.onSecondarySubtitleDelayChange?.(0.5);
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
@@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('timing:y:secondary'));
assert.ok(calls.includes('secondary:sec'));
assert.ok(calls.includes('secondary-track:4'));
assert.ok(calls.includes('secondary-delay:0.5'));
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
@@ -387,3 +396,182 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
assert.deepEqual(recordedStarts.slice(-1), [20]);
});
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: {
recordSubtitleLine: (text: string, start: number, end: number) =>
immersion.push({ text, start, end }),
},
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass',
},
{
startTime: 3,
endTime: 6,
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
{
startTime: 10,
endTime: 12,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentMediaPath: '/video.mkv',
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: () => {},
})();
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('手', 0.86, 1.56);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Concurrent dialogue during the song is not part of the animation: it must be
// recorded as itself -- without the fragment lines beside it -- and must not cause
// the song line to be recorded again when the animation frames resume.
assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手');
handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2);
handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9);
handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0);
assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]);
assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]);
// Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A).
// Every recorded cue is remembered, so each authored line still records exactly once.
handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4);
handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7);
handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4);
handlers.recordSubtitleTiming('手にある', 3.5, 3.7);
assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
// A backward seek means the user is rewatching: the timing history (a viewing log)
// records the revisited line again, while immersion stays once-per-media.
handlers.onTimePosUpdate?.(30);
handlers.onTimePosUpdate?.(2);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
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);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
assert.equal(immersion.length, 3);
assert.equal(timing.length, 5);
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
const appState = {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: { recordSubtitleLine: () => {} },
subtitleTimingTracker: { recordSubtitle: () => {} },
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass' as const,
},
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
activeParsedSubtitleSource: 'track-a.ass' as string | null,
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
};
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
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: () => {},
})();
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
// The new track's cues arrive only after an async re-parse; until then, the old
// track's canonical lyric must not replace the new track's live text.
handlers.onSubtitleTrackChange?.(2);
assert.deepEqual(appState.activeParsedSubtitleCues, []);
assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
});
+174 -35
View File
@@ -1,5 +1,11 @@
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,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
@@ -15,6 +21,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
overlayRuntimeInitialized: boolean;
mpvClient: {
connected?: boolean;
currentSubText?: string;
currentSecondarySubText?: string;
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
@@ -36,6 +43,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
} | null;
activeParsedSubtitleCues?: SubtitleCue[] | null;
/** Cache key of the source the cues were parsed from; cleared with the cues. */
activeParsedSubtitleSource?: string | null;
currentMediaPath?: string | null;
currentSubText: string;
currentSubAssText: string;
@@ -53,11 +62,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSecondarySubtitleChange?: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleDelayChange?: (delay: number) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
@@ -74,6 +86,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
@@ -93,6 +106,38 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
const immersionLineDedupGate = createSubtitleLineDedupGate({
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
});
// One seen-set per consumer: canonical cues overlap, so live samples resolve to
// shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just
// the previous sample -- keeps each authored line recorded exactly once per source.
const recordedImmersionCanonicalKeys = new Set<string>();
const recordedTimingCanonicalKeys = new Set<string>();
// Bumped on track/media changes so an immersion record whose tokenization resolves
// after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0;
let lastTimePosForTimingReset: number | null = null;
const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`;
const resetSubtitleDeduplication = (): void => {
immersionLineDedupGate.reset();
recordedImmersionCanonicalKeys.clear();
recordedTimingCanonicalKeys.clear();
subtitleSessionEpoch += 1;
lastTimePosForTimingReset = null;
};
const resolveCanonicalSample = (liveText: string, startSec: number) =>
resolveCanonicalPrimarySubtitle({
liveText,
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({
liveText,
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
});
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
Boolean(
deps.appState.initialArgs?.managedPlayback ||
@@ -111,45 +156,107 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
quitApp: () => deps.quitApp(),
resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({
liveText,
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
cues: deps.appState.activeParsedSubtitleCues,
}),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
const tracker = deps.appState.immersionTracker;
if (!tracker?.recordSubtitleLine) {
return;
}
const recordLine = (lineText: string, startSec: number, endSec: number): void => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens =
deps.appState.currentSubtitleData?.text === lineText
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText);
return;
}
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
return;
}
const epochAtRecord = subtitleSessionEpoch;
void deps
.tokenizeSubtitleForImmersion(lineText)
.then((payload) => {
if (subtitleSessionEpoch !== epochAtRecord) {
return;
}
tracker.recordSubtitleLine?.(
lineText,
startSec,
endSec,
payload?.tokens ?? null,
secondaryText,
);
})
.catch(() => {
if (subtitleSessionEpoch !== epochAtRecord) {
return;
}
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
});
};
const canonical = resolveCanonicalSample(text, start);
if (canonical) {
for (const cue of canonical.cues) {
const key = canonicalCueKey(cue);
if (recordedImmersionCanonicalKeys.has(key)) {
continue;
}
recordedImmersionCanonicalKeys.add(key);
recordLine(cue.text, cue.startTime, cue.endTime);
}
return;
}
text = stripFragmentsForRecording(text, start);
if (!text.trim()) {
return;
}
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
return;
}
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens =
deps.appState.currentSubtitleData?.text === text
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
return;
}
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
return;
}
void deps
.tokenizeSubtitleForImmersion(text)
.then((payload) => {
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
})
.catch(() => {
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
});
recordLine(text, start, end);
},
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
text,
start,
end,
deps.appState.mpvClient?.currentSecondarySubText || undefined,
),
recordSubtitleTiming: (text: string, start: number, end: number) => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
const recordableText = stripFragmentsForRecording(text, start);
if (!recordableText.trim()) {
return;
}
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
recordableText,
start,
end,
secondaryText,
);
return;
}
for (const cue of canonical.cues) {
const key = canonicalCueKey(cue);
if (recordedTimingCanonicalKeys.has(key)) {
continue;
}
recordedTimingCanonicalKeys.add(key);
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
cue.text,
cue.startTime,
cue.endTime,
secondaryText,
);
}
},
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) =>
@@ -170,9 +277,22 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
? (message: string) => deps.logSubtitleProcessingDebug!(message)
: undefined,
onSubtitleTrackChange: (sid: number | null) => {
immersionLineDedupGate.reset();
resetSubtitleDeduplication();
// The replacement track's cues arrive only after an async re-read and re-parse.
// Clearing synchronously keeps the previous track's canonical cues from
// substituting into, or recording against, the new track's live text. The source
// key is cleared with the cues so cue-list consumers (the sidebar snapshot)
// re-parse on demand instead of trusting the stale pairing.
deps.appState.activeParsedSubtitleCues = [];
deps.appState.activeParsedSubtitleSource = null;
deps.onSubtitleTrackChange?.(sid);
},
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
: undefined,
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
: undefined,
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
: undefined,
@@ -182,10 +302,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
broadcastSubtitleAss: (text: string) =>
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
broadcastSecondarySubtitle: (text: string) => {
if (deps.onSecondarySubtitleChange) {
deps.onSecondarySubtitleChange(text);
return;
}
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
updateCurrentMediaPath: (path: string) => {
immersionLineDedupGate.reset();
resetSubtitleDeduplication();
deps.updateCurrentMediaPath(path);
},
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
@@ -217,9 +342,23 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
onTimePosUpdate: deps.onTimePosUpdate
? (time: number) => deps.onTimePosUpdate!(time)
: undefined,
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.
if (
Number.isFinite(time) &&
lastTimePosForTimingReset !== null &&
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
) {
recordedTimingCanonicalKeys.clear();
}
if (Number.isFinite(time)) {
lastTimePosForTimingReset = time;
}
deps.onTimePosUpdate?.(time);
},
onFullscreenChange: deps.onFullscreenChange
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
: undefined,
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRemoteMediaPathDetector } from './network-media-path';
test('remote media detector recognizes mounted network filesystems', async () => {
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'darwin',
readMountOutput: async () =>
[
'/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled)',
'//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)',
].join('\n'),
});
assert.equal(await detectRemoteMedia('/Volumes/jellyfin/movie.mkv'), true);
assert.equal(await detectRemoteMedia('/Volumes/jellyfin-another/movie.mkv'), false);
assert.equal(await detectRemoteMedia('/Users/viewer/movie.mkv'), false);
});
test('remote media detector recognizes Linux network mount output', async () => {
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'linux',
readMountOutput: async () =>
'//media/jellyfin on /mnt/Jellyfin\\040Media type cifs (rw,relatime)',
});
assert.equal(await detectRemoteMedia('/mnt/Jellyfin Media/movie.mkv'), true);
});
test('remote media detector shares its mount lookup between concurrent callers', async () => {
let mountReads = 0;
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'darwin',
readMountOutput: async () => {
mountReads += 1;
return '//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)';
},
});
const results = await Promise.all(
Array.from({ length: 6 }, () => detectRemoteMedia('/Volumes/jellyfin/movie.mkv')),
);
assert.deepEqual(
results,
Array.from({ length: 6 }, () => true),
);
assert.equal(mountReads, 1);
});
test('remote media detector recognizes URLs and Windows UNC paths without reading mounts', async () => {
let mountReads = 0;
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'win32',
readMountOutput: async () => {
mountReads += 1;
return '';
},
});
assert.equal(await detectRemoteMedia('https://media.example/movie.mkv'), true);
assert.equal(await detectRemoteMedia('\\\\media-server\\jellyfin\\movie.mkv'), true);
assert.equal(mountReads, 0);
});
+142
View File
@@ -0,0 +1,142 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const DEFAULT_MOUNT_CACHE_TTL_MS = 5_000;
const NETWORK_FILESYSTEM_TYPES = new Set([
'9p',
'afpfs',
'cifs',
'davfs',
'davfs2',
'fuse.sshfs',
'nfs',
'nfs4',
'smbfs',
'sshfs',
'webdav',
]);
function isRemoteUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function decodeMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, digits: string) =>
String.fromCharCode(Number.parseInt(digits, 8)),
);
}
function parseNetworkMountPaths(output: string): string[] {
const networkMountPaths: string[] = [];
for (const line of output.split('\n')) {
const optionsStart = line.lastIndexOf(' (');
if (optionsStart < 0) continue;
let mountDescription = line.slice(0, optionsStart);
const options = line.slice(optionsStart + 2, line.indexOf(')', optionsStart));
const linuxTypeSeparator = mountDescription.lastIndexOf(' type ');
const filesystemType = (
linuxTypeSeparator >= 0
? mountDescription.slice(linuxTypeSeparator + ' type '.length)
: (options.split(',').at(0) ?? '')
)
.trim()
.toLowerCase();
if (!NETWORK_FILESYSTEM_TYPES.has(filesystemType)) continue;
if (linuxTypeSeparator >= 0) {
mountDescription = mountDescription.slice(0, linuxTypeSeparator);
}
const mountSeparator = mountDescription.indexOf(' on ');
if (mountSeparator < 0) continue;
networkMountPaths.push(
path.posix.normalize(decodeMountPath(mountDescription.slice(mountSeparator + 4).trim())),
);
}
return networkMountPaths;
}
function readMountOutput(platform: NodeJS.Platform): Promise<string> {
if (platform === 'win32') return Promise.resolve('');
const command = platform === 'darwin' ? '/sbin/mount' : 'mount';
return new Promise((resolve, reject) => {
execFile(
command,
[],
{ encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
},
);
});
}
function isPathWithinMount(filePath: string, mountPath: string): boolean {
const relativePath = path.posix.relative(mountPath, filePath);
return (
relativePath === '' ||
(relativePath !== '..' &&
!relativePath.startsWith(`..${path.posix.sep}`) &&
!path.posix.isAbsolute(relativePath))
);
}
export type RemoteMediaPathDetector = (mediaPath: string) => Promise<boolean>;
export function createRemoteMediaPathDetector(
deps: {
platform?: NodeJS.Platform;
readMountOutput?: () => Promise<string>;
now?: () => number;
mountCacheTtlMs?: number;
} = {},
): RemoteMediaPathDetector {
const platform = deps.platform ?? process.platform;
const getMountOutput = deps.readMountOutput ?? (() => readMountOutput(platform));
const now = deps.now ?? Date.now;
const mountCacheTtlMs = deps.mountCacheTtlMs ?? DEFAULT_MOUNT_CACHE_TTL_MS;
let mountCache: { expiresAt: number; networkMountPaths: Promise<readonly string[]> } | undefined;
const getNetworkMountPaths = (): Promise<readonly string[]> => {
const currentTime = now();
if (mountCache && currentTime < mountCache.expiresAt) {
return mountCache.networkMountPaths;
}
const networkMountPaths = getMountOutput()
.then(parseNetworkMountPaths)
.catch(() => []);
mountCache = {
expiresAt: currentTime + mountCacheTtlMs,
networkMountPaths,
};
return networkMountPaths;
};
return async (mediaPath): Promise<boolean> => {
const source = mediaPath.trim();
if (!source) return false;
if (isRemoteUrl(source)) return true;
const filePath = resolveSubtitleSourcePath(source);
if (platform === 'win32') {
return filePath.startsWith('\\\\');
}
if (!path.posix.isAbsolute(filePath)) return false;
const networkMountPaths = await getNetworkMountPaths();
const normalizedPath = path.posix.normalize(filePath);
return networkMountPaths.some((mountPath) => isPathWithinMount(normalizedPath, mountPath));
};
}
@@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: {
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
@@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.refreshCurrentSubtitle?.();
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
deps.showOverlayNotification?.({ title: 'title' });
deps.dismissOverlayNotification?.('notification-id');
const tracker = {
close: () => {},
@@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'refresh-subtitle',
'sync-shortcuts',
'notify',
'show-overlay',
'dismiss-overlay',
]);
assert.equal(appState.windowTracker, tracker);
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
@@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
@@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
},
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),

Some files were not shown because too many files have changed in this diff Show More