feat: add subtitle generation and bundle Bun launcher runtime

- Add local subtitle generation and card timing review workflows
- Package cross-platform Bun runtimes, launchers, licenses, and source
- Consolidate release packaging and refresh v0.19.6 documentation
This commit is contained in:
sudacode committed 2026-09-15 21:59:10 -07:00
commit 93fb4eff3a
279 files changed
+23054 -1603

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
+35
View File
@@ -1263,6 +1263,41 @@ test('AnkiIntegration dismisses persistent overlay update progress when no termi
assert.deepEqual(dismissedIds, ['anki-update-progress']);
});
test('AnkiIntegration dismisses overlay update progress after notifications switch to OSD', () => {
const behavior: NonNullable<AnkiConnectConfig['behavior']> = {
notificationType: 'overlay',
};
const dismissedIds: string[] = [];
const integration = new AnkiIntegration(
{ behavior },
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
() => {},
undefined,
undefined,
undefined,
(id) => {
dismissedIds.push(id);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
endUpdateProgress: () => void;
};
updateNotifications.beginUpdateProgress('Updating card');
behavior.notificationType = 'osd';
updateNotifications.endUpdateProgress();
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[] = [];
+42 -6
View File
@@ -28,6 +28,8 @@ import {
KikuMergePreviewResponse,
NotificationOptions,
type WordCardKind,
type MediaTimingReviewDecision,
type MediaTimingReviewRequest,
} from './types/anki';
import { AiConfig } from './types/integrations';
import type { KnownWordMaturityTier } from './types/subtitle';
@@ -240,6 +242,9 @@ export class AnkiIntegration {
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private mediaTimingReviewCallback:
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
| null = null;
private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>();
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
@@ -511,6 +516,7 @@ export class AnkiIntegration {
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
},
mediaGenerator: {
generateAudio: (
@@ -568,6 +574,7 @@ export class AnkiIntegration {
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
isUpdateInProgress: () => this.updateInProgress,
setUpdateInProgress: (value) => {
this.updateInProgress = value;
@@ -583,6 +590,7 @@ export class AnkiIntegration {
recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation');
},
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
});
}
@@ -639,12 +647,14 @@ export class AnkiIntegration {
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
},
getConfig: () => this.config,
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
extractFields: (fields) => this.extractFields(fields),
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
@@ -680,6 +690,7 @@ export class AnkiIntegration {
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
});
}
@@ -799,6 +810,12 @@ export class AnkiIntegration {
}
}
private removeKnownWordNote(noteId: number): void {
if (this.knownWordCache.removeNote(noteId)) {
this.notifyKnownWordCacheUpdated();
}
}
private notifyKnownWordCacheUpdated(): void {
if (!this.knownWordCacheUpdatedCallback) {
return;
@@ -1076,7 +1093,7 @@ export class AnkiIntegration {
videoPath,
startTime,
endTime,
this.config.media?.audioPadding,
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
this.config.media?.normalizeAudio !== false,
await this.getMpvVolumeScale(),
@@ -1109,7 +1126,7 @@ export class AnkiIntegration {
videoPath,
mediaRange.startTime,
mediaRange.endTime,
this.config.media?.audioPadding,
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
{
fps: this.config.media?.animatedFps,
maxWidth: this.config.media?.animatedMaxWidth,
@@ -1257,11 +1274,11 @@ export class AnkiIntegration {
}
private endUpdateProgress(): void {
if (this.overlayUpdateProgressActive) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationDismissCallback?.('anki-update-progress');
}
if (!this.shouldUseOsdNotifications()) {
if (this.overlayUpdateProgressActive) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationDismissCallback?.('anki-update-progress');
}
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => {
@@ -1761,6 +1778,25 @@ export class AnkiIntegration {
this.consumeSubtitleMiningContextCallback = callback;
}
setMediaTimingReviewCallback(
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
): void {
this.mediaTimingReviewCallback = callback;
}
private async reviewMediaTiming(
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
): Promise<MediaTimingReviewDecision> {
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
return { action: 'use-original' };
}
return await this.mediaTimingReviewCallback({
...request,
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
});
}
resolveCurrentNoteId(noteId: number): number {
let resolved = noteId;
const seen = new Set<number>();
@@ -85,6 +85,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
@@ -128,6 +129,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -200,6 +202,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
@@ -266,6 +269,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
@@ -328,6 +332,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
@@ -374,6 +379,7 @@ test('manual clipboard subtitle update uses configured audio when SentenceAudio
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
});
@@ -462,6 +468,7 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path) => {
@@ -510,3 +517,98 @@ test('createSentenceCard relies on Anki progress notification without standalone
assert.deepEqual(progressMessages, ['Creating sentence card']);
assert.deepEqual(statusMessages, []);
});
test('discarding an audio-card timing review deletes the note before evicting its cache entry', async () => {
const events: string[] = [];
const statusMessages: string[] = [];
const { service } = createManualUpdateService({
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 4,
currentSubEnd: 6,
currentTimePos: 5,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: { Expression: { value: '単語' } },
},
],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async (noteIds) => {
events.push(`delete:${noteIds.join(',')}`);
},
},
reviewMediaTiming: async () => ({ action: 'discard' }),
removeKnownWordNote: (noteId) => {
events.push(`cache:${noteId}`);
},
showStatusNotification: (message) => {
statusMessages.push(message);
},
});
await service.markLastCardAsAudioCard();
assert.deepEqual(events, ['delete:42', 'cache:42']);
assert.deepEqual(statusMessages, ['Card deleted.']);
});
test('keeping an audio card without media skips generation and preserves the note', async () => {
let generatedAudio = false;
let deleted = false;
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const { service, storedMedia } = createManualUpdateService({
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 4,
currentSubEnd: 6,
currentTimePos: 5,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
},
],
updateNoteFields: async (noteId, fields) => {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => {
deleted = true;
},
},
mediaGenerator: {
generateAudio: async () => {
generatedAudio = true;
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
reviewMediaTiming: async () => ({ action: 'skip-media' }),
});
await service.markLastCardAsAudioCard();
assert.equal(generatedAudio, false);
assert.equal(deleted, false);
assert.deepEqual(storedMedia, []);
assert.deepEqual(updates, [{ noteId: 42, fields: { Sentence: '字幕' } }]);
});
@@ -12,6 +12,7 @@ test('sentence card writes generated audio only to sentence audio field', async
const storedMedia: string[] = [];
const requestedProperties: string[] = [];
const audioVolumeScales: Array<number | undefined> = [];
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const deps: CardCreationDeps = {
getConfig: () =>
@@ -73,17 +74,19 @@ test('sentence card writes generated audio only to sentence audio field', async
},
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (
_path,
_startTime,
_endTime,
_audioPadding,
startTime,
endTime,
audioPadding,
_audioStreamIndex,
_normalizeAudio,
volumeScale,
) => {
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
audioVolumeScales.push(volumeScale);
return Buffer.from('audio');
},
@@ -121,17 +124,15 @@ test('sentence card writes generated audio only to sentence audio field', async
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
};
const created = await new CardCreationService(deps).createSentenceCard(
'字幕',
12,
14,
'Subtitle',
);
const service = new CardCreationService(deps);
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
assert.equal(created, true);
assert.deepEqual(addedFields[0], {
@@ -143,7 +144,19 @@ test('sentence card writes generated audio only to sentence audio field', async
assert.equal(storedMedia.length, 1);
assert.deepEqual(requestedProperties, ['volume']);
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
assert.equal('ExpressionAudio' in mediaUpdate!, false);
deps.reviewMediaTiming = async () => ({ action: 'discard' });
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
assert.equal(addedFields.length, 1);
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
assert.equal(addedFields.length, 2);
assert.equal(storedMedia.length, 1);
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
assert.deepEqual(requestedProperties, ['volume']);
});
@@ -42,6 +42,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -73,6 +74,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -138,6 +140,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -171,6 +174,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => {
@@ -236,6 +240,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -269,6 +274,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
recordCardsMinedCallback: () => {
@@ -345,6 +351,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path) => {
@@ -388,6 +395,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -450,6 +458,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
@@ -490,6 +499,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -585,6 +595,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => {
@@ -628,6 +639,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -695,6 +707,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -726,6 +739,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -783,6 +797,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -814,6 +829,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
+99 -24
View File
@@ -3,7 +3,13 @@ import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
import {
AnkiConnectConfig,
type CardKind,
type MediaTimingReviewDecision,
type MediaTimingReviewRequest,
type WordCardKind,
} from '../types/anki';
import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -55,6 +61,7 @@ interface CardCreationClient {
storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
retrieveMediaFile(filename: string): Promise<string>;
deleteNotes(noteIds: number[]): Promise<void>;
}
interface CardCreationMediaGenerator {
@@ -137,12 +144,16 @@ interface CardCreationDeps {
};
getFallbackDurationSeconds: () => number;
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
removeKnownWordNote: (noteId: number) => void;
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>;
}
export class CardCreationService {
@@ -456,6 +467,30 @@ export class CardCreationService {
this.deps.getConfig(),
);
const timingDecision = this.deps.reviewMediaTiming
? await this.deps.reviewMediaTiming({
kind: 'audio',
text: mpvClient.currentSubText,
startTime,
endTime,
noteId,
})
: ({ action: 'use-original' } as const);
if (timingDecision.action === 'discard') {
await this.deps.client.deleteNotes([noteId]);
this.deps.removeKnownWordNote(noteId);
this.deps.showStatusNotification('Card deleted.');
return;
}
const skipMedia = timingDecision.action === 'skip-media';
const exactReviewedRange = timingDecision.action === 'confirm';
let sentenceText = mpvClient.currentSubText;
if (timingDecision.action === 'confirm') {
startTime = timingDecision.startTime;
endTime = timingDecision.endTime;
sentenceText = timingDecision.text?.trim() || sentenceText;
}
const updatedFields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
@@ -465,30 +500,33 @@ export class CardCreationService {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
if (sentenceField) {
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
const processedSentence = this.deps.processSentence(sentenceText, fields);
updatedFields[sentenceField] = processedSentence;
}
const audioFieldName = sentenceCardConfig.audioField;
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
startTime,
endTime,
);
if (!skipMedia) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
startTime,
endTime,
exactReviewedRange ? 0 : undefined,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
miscInfoFilename = audioFilename;
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
miscInfoFilename = audioFilename;
}
} catch (error) {
log.error('Failed to generate audio for audio card:', (error as Error).message);
errors.push('audio');
}
} catch (error) {
log.error('Failed to generate audio for audio card:', (error as Error).message);
errors.push('audio');
}
if (shouldGenerateImage(this.deps.getConfig())) {
if (!skipMedia && shouldGenerateImage(this.deps.getConfig())) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
@@ -497,6 +535,7 @@ export class CardCreationService {
startTime,
endTime,
animatedLeadInSeconds,
exactReviewedRange,
);
const imageField = this.deps.getConfig().fields?.image;
@@ -569,9 +608,29 @@ export class CardCreationService {
try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const timingDecision = this.deps.reviewMediaTiming
? await this.deps.reviewMediaTiming({
kind: 'sentence',
text: sentence,
startTime,
endTime,
})
: ({ action: 'use-original' } as const);
if (timingDecision.action === 'discard') {
this.deps.showStatusNotification('Card creation cancelled.');
return false;
}
const skipMedia = timingDecision.action === 'skip-media';
const exactReviewedRange = timingDecision.action === 'confirm';
if (timingDecision.action === 'confirm') {
startTime = timingDecision.startTime;
endTime = timingDecision.endTime;
sentence = timingDecision.text?.trim() || sentence;
}
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const generateAudio = !skipMedia && shouldGenerateAudio(config);
const generateImage = !skipMedia && shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
const videoPath = generateImage
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
@@ -736,6 +795,7 @@ export class CardCreationService {
generateAudio,
generateImage,
volumeScale,
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
});
await this.deps.showNotification(noteId, label, 'media queued');
return true;
@@ -751,7 +811,12 @@ export class CardCreationService {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = audioSourcePath
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
? await this.mediaGenerateAudio(
audioSourcePath,
startTime,
endTime,
exactReviewedRange ? 0 : undefined,
)
: null;
if (audioBuffer) {
@@ -769,7 +834,13 @@ export class CardCreationService {
if (generateImage) {
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
const imageBuffer = await this.generateImageBuffer(
videoPath!,
startTime,
endTime,
0,
exactReviewedRange,
);
const imageField = config.fields?.image;
if (imageBuffer && imageField) {
@@ -821,6 +892,7 @@ export class CardCreationService {
videoPath: MediaInput,
startTime: number,
endTime: number,
audioPaddingOverride?: number,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
@@ -831,7 +903,7 @@ export class CardCreationService {
videoPath,
startTime,
endTime,
this.deps.getConfig().media?.audioPadding,
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(
videoPath,
mpvClient.currentAudioStreamIndex ?? undefined,
@@ -849,13 +921,16 @@ export class CardCreationService {
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
exactReviewedRange = false,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
return null;
}
const timestamp = mpvClient.currentTimePos || 0;
const timestamp = exactReviewedRange
? startTime + (endTime - startTime) / 2
: mpvClient.currentTimePos || 0;
if (this.deps.getConfig().media?.imageType === 'avif') {
let imageStart = startTime;
@@ -871,7 +946,7 @@ export class CardCreationService {
videoPath,
imageStart,
imageEnd,
this.deps.getConfig().media?.audioPadding,
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
{
fps: this.deps.getConfig().media?.animatedFps,
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
@@ -261,6 +261,32 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
}
});
test('KnownWordCacheManager removes a deleted note from memory and persisted state', () => {
const config: AnkiConnectConfig = {
fields: { word: 'Word' },
knownWords: { highlightEnabled: true },
};
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 42,
fields: { Word: { value: '猫' } },
});
assert.equal(manager.removeNote(42), true);
assert.equal(manager.removeNote(42), false);
assert.equal(manager.isKnownWord('猫'), false);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
notes?: Record<string, unknown>;
};
assert.deepEqual(persisted.notes, {});
} finally {
cleanup();
}
});
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
const config: AnkiConnectConfig = {
fields: {
+11
View File
@@ -350,6 +350,17 @@ export class KnownWordCacheManager {
return true;
}
removeNote(noteId: number): boolean {
if (!this.noteEntriesById.has(noteId)) {
return false;
}
this.removeNoteSnapshot(noteId);
this.persistKnownWordCacheState();
log.info('Known-word cache removed deleted note', `noteId=${noteId}`);
return true;
}
clearKnownWordCacheState(): void {
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
@@ -44,6 +44,7 @@ function createWorkflowHarness() {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
deleteNotes: async () => undefined,
},
getConfig: () => ({
fields: {
@@ -61,6 +62,7 @@ function createWorkflowHarness() {
fieldGroupingMode: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
removeKnownWordNote: (_noteId: number) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
@@ -634,3 +636,141 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
assert.equal(queuedUpdates[0]?.context, undefined);
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
});
test('NoteUpdateWorkflow deletes an existing word card when timing review discards it', async () => {
const harness = createWorkflowHarness();
const deletedNoteIds: number[][] = [];
const removedKnownWordNoteIds: number[] = [];
let appendedKnownWords = false;
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'subtitle-text',
startTime: 4,
endTime: 6,
});
harness.deps.client.deleteNotes = async (noteIds) => {
deletedNoteIds.push(noteIds);
};
harness.deps.appendKnownWordsFromNoteInfo = () => {
appendedKnownWords = true;
};
harness.deps.removeKnownWordNote = (noteId) => {
removedKnownWordNoteIds.push(noteId);
};
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
await harness.workflow.execute(42);
assert.deepEqual(deletedNoteIds, [[42]]);
assert.deepEqual(removedKnownWordNoteIds, [42]);
assert.equal(appendedKnownWords, false);
assert.deepEqual(harness.updates, []);
assert.deepEqual(harness.notifications, []);
});
test('NoteUpdateWorkflow keeps the word card but skips media after timing review', async () => {
const harness = createWorkflowHarness();
const mediaCalls: string[] = [];
const deletedNoteIds: number[][] = [];
const queuedUpdates: unknown[] = [];
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'subtitle-text',
startTime: 4,
endTime: 6,
});
harness.deps.getConfig = () => ({
fields: { sentence: 'Sentence', image: 'Picture' },
media: { generateAudio: true, generateImage: true },
behavior: {},
});
harness.deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
harness.deps.generateAudio = async () => {
mediaCalls.push('audio');
return Buffer.from('audio');
};
harness.deps.generateImage = async () => {
mediaCalls.push('image');
return Buffer.from('image');
};
harness.deps.queuePendingYoutubeMediaUpdate = async (update) => {
queuedUpdates.push(update);
return true;
};
harness.deps.client.deleteNotes = async (noteIds) => {
deletedNoteIds.push(noteIds);
};
await harness.workflow.execute(42);
assert.deepEqual(mediaCalls, []);
assert.deepEqual(queuedUpdates, []);
assert.deepEqual(deletedNoteIds, []);
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
assert.deepEqual(harness.notifications, [{ noteId: 42, label: 'taberu' }]);
});
test('NoteUpdateWorkflow uses the combined review sentence for the card and media range', async () => {
const harness = createWorkflowHarness();
const audioContexts: Array<SubtitleMiningContext | undefined> = [];
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'current-line',
startTime: 4,
endTime: 6,
});
harness.deps.getConfig = () => ({
fields: { sentence: 'Sentence' },
media: { generateAudio: true, generateImage: false },
behavior: {},
});
harness.deps.reviewMediaTiming = async () => ({
action: 'confirm',
startTime: 2,
endTime: 7,
text: 'previous-line current-line next-line',
});
harness.deps.generateAudio = async (context) => {
audioContexts.push(context);
return null;
};
await harness.workflow.execute(42);
assert.deepEqual(harness.updates, [
{ noteId: 42, fields: { Sentence: 'previous-line current-line next-line' } },
]);
assert.equal(audioContexts.length, 1);
assert.equal(audioContexts[0]?.text, 'previous-line current-line next-line');
assert.equal(audioContexts[0]?.startTime, 2);
assert.equal(audioContexts[0]?.endTime, 7);
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
});
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
const harness = createWorkflowHarness();
const statusMessages: string[] = [];
let removedKnownWord = false;
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'subtitle-text',
startTime: 4,
endTime: 6,
});
harness.deps.client.deleteNotes = async () => {
throw new Error('delete failed');
};
harness.deps.removeKnownWordNote = () => {
removedKnownWord = true;
};
harness.deps.showOsdNotification = (message) => {
statusMessages.push(message);
};
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
await harness.workflow.execute(42);
assert.equal(removedKnownWord, false);
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
assert.ok(harness.warnings.length === 0);
});
+57 -6
View File
@@ -1,7 +1,12 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle';
import type { CardKind, WordCardKind } from '../types/anki';
import type {
CardKind,
MediaTimingReviewDecision,
MediaTimingReviewRequest,
WordCardKind,
} from '../types/anki';
import { resolveWordCardKind } from './note-field-utils';
export interface NoteUpdateWorkflowNoteInfo {
@@ -14,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
deleteNotes(noteIds: number[]): Promise<void>;
};
getConfig: () => {
fields?: {
@@ -44,6 +50,7 @@ export interface NoteUpdateWorkflowDeps {
wordCardKind?: WordCardKind;
};
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
removeKnownWordNote: (noteId: number) => void;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
findDuplicateNote: (
expression: string,
@@ -102,6 +109,9 @@ export interface NoteUpdateWorkflowDeps {
logWarn: (message: string, ...args: unknown[]) => void;
logInfo: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>;
}
function normalizeSubtitleContextText(text: string): string {
@@ -171,7 +181,6 @@ export class NoteUpdateWorkflow {
}
const noteInfo = notesInfo[0]!;
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.deps.extractFields(noteInfo.fields);
const config = this.deps.getConfig();
@@ -207,11 +216,53 @@ export class NoteUpdateWorkflow {
// 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
// timings per generator clips whichever line is on screen when each one starts.
const mediaTimingContext =
let mediaTimingContext =
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
let skipMedia = false;
let reviewedSentenceText: string | undefined;
const noteLabel = hasExpressionText ? expressionText : noteId;
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (mediaTimingContext) {
const timingDecision = this.deps.reviewMediaTiming
? await this.deps.reviewMediaTiming({
kind: 'word',
text: mediaTimingContext.text,
startTime: mediaTimingContext.startTime,
endTime: mediaTimingContext.endTime,
noteId,
})
: ({ action: 'use-original' } as const);
if (timingDecision.action === 'discard') {
try {
await this.deps.client.deleteNotes([noteId]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.deps.logError('Failed to delete discarded card:', message);
this.deps.showOsdNotification(`Card deletion failed: ${message}`);
return;
}
this.deps.removeKnownWordNote(noteId);
this.deps.showOsdNotification('Card deleted.');
return;
}
if (timingDecision.action === 'confirm') {
reviewedSentenceText = timingDecision.text?.trim() || undefined;
mediaTimingContext = {
...mediaTimingContext,
...(reviewedSentenceText !== undefined ? { text: reviewedSentenceText } : {}),
startTime: timingDecision.startTime,
endTime: timingDecision.endTime,
mediaPaddingSeconds: 0,
};
} else if (timingDecision.action === 'skip-media') {
skipMedia = true;
}
}
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const currentSubtitleText =
reviewedSentenceText ?? subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
@@ -239,8 +290,8 @@ export class NoteUpdateWorkflow {
}
}
const generateAudio = config.media?.generateAudio !== false;
const generateImage = config.media?.generateImage !== false;
const generateAudio = !skipMedia && config.media?.generateAudio !== false;
const generateImage = !skipMedia && config.media?.generateImage !== false;
const mediaCacheQueued =
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
? await this.deps.queuePendingYoutubeMediaUpdate({
@@ -147,6 +147,9 @@ export class PendingYoutubeMediaQueue {
generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config),
volumeScale,
...(job.context?.mediaPaddingSeconds !== undefined
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
: {}),
});
return true;
}
@@ -282,7 +285,7 @@ export class PendingYoutubeMediaQueue {
cachedMediaInput,
job.startTime,
job.endTime,
config.media?.audioPadding,
job.mediaPaddingSeconds ?? config.media?.audioPadding,
undefined,
config.media?.normalizeAudio !== false,
job.volumeScale,
@@ -316,6 +319,7 @@ export class PendingYoutubeMediaQueue {
job.startTime,
job.endTime,
animatedLeadInSeconds,
job.mediaPaddingSeconds,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -376,6 +380,7 @@ export class PendingYoutubeMediaQueue {
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
mediaPaddingSeconds?: number,
): Promise<Buffer | null> {
const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') {
@@ -383,7 +388,7 @@ export class PendingYoutubeMediaQueue {
videoPath,
startTime,
endTime,
config.media?.audioPadding,
mediaPaddingSeconds ?? config.media?.audioPadding,
{
fps: config.media?.animatedFps,
maxWidth: config.media?.animatedMaxWidth,
@@ -10,6 +10,7 @@ export interface PendingYoutubeMediaUpdate {
generateAudio: boolean;
generateImage: boolean;
volumeScale?: number;
mediaPaddingSeconds?: number;
}
function trimToNonEmptyString(value: unknown): string | null {
+1
View File
@@ -2182,6 +2182,7 @@ test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [
'anki.autoUpdateNewCards',
'anki.mediaReviewTiming',
'subtitle.annotation.knownWords.highlightEnabled',
'subtitle.annotation.knownWords.maturityEnabled',
'subtitle.annotation.nPlusOne',
+2
View File
@@ -1,4 +1,5 @@
import { RawConfig, ResolvedConfig } from '../types/config';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../shared/subtitle-generation';
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
@@ -55,6 +56,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
subtitlePosition,
keybindings,
websocket,
+1
View File
@@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T',
openSubtitleGeneration: 'Ctrl+Shift+G',
openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
@@ -55,6 +55,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
syncAnimatedImageToWordAudio: true,
normalizeAudio: true,
mirrorMpvVolume: true,
reviewTiming: false,
audioPadding: 0,
fallbackDuration: 3.0,
maxMediaDuration: 30,
+6
View File
@@ -628,6 +628,12 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
},
{
path: 'shortcuts.openSubtitleGeneration',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openSubtitleGeneration,
description: 'Accelerator that opens the standalone Japanese subtitle generation modal.',
},
{
path: 'shortcuts.openControllerSelect',
kind: 'string',
@@ -196,6 +196,14 @@ export function buildIntegrationConfigOptionRegistry(
description:
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
},
{
path: 'ankiConnect.media.reviewTiming',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
description:
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
runtime: runtimeOptionById.get('anki.mediaReviewTiming'),
},
{
path: 'ankiConnect.media.generateImage',
kind: 'boolean',
@@ -1,10 +1,46 @@
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
...(
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
).map((key) => ({
path: `subtitleGeneration.${key}`,
kind: 'string' as const,
defaultValue: defaultConfig.subtitleGeneration[key],
description: {
whisperPath:
'Optional path override for whisper.cpp. Leave empty to find whisper-cli on PATH.',
modelPath:
'Path to an existing multilingual whisper.cpp GGML model. Leave empty to use a SubMiner-managed model. A configured path always takes precedence.',
ffmpegPath:
'Optional FFmpeg path override for audio extraction. Leave empty to find ffmpeg on PATH.',
ffprobePath:
'Optional FFprobe path override for audio tracks and timing. Leave empty to find ffprobe on PATH.',
vadModelPath:
'Path to a whisper.cpp Silero VAD model. Enables dialogue-focused generation while retaining uncertain audible sections, which may include songs. Leave empty to transcribe the full audio.',
vadPath:
'Optional speech detector executable override. With vadModelPath configured, leave empty to find whisper-vad-speech-segments or vad-speech-segments on PATH.',
}[key],
})),
{
path: 'subtitleGeneration.managedModel',
kind: 'enum',
enumValues: SUBTITLE_GENERATION_MODELS.map((model) => model.id),
defaultValue: defaultConfig.subtitleGeneration.managedModel,
description:
'Multilingual whisper.cpp model to use when modelPath is empty. Download it explicitly from the generation modal or launcher.',
},
{
path: 'subtitleGeneration.threads',
kind: 'number',
defaultValue: defaultConfig.subtitleGeneration.threads,
description: 'Positive integer CPU thread count for whisper.cpp Japanese transcription.',
},
{
path: 'subtitleStyle.primaryDefaultMode',
kind: 'enum',
+14
View File
@@ -19,6 +19,20 @@ export function buildRuntimeOptionRegistry(
behavior: { autoUpdateNewCards: value === true },
}),
},
{
id: 'anki.mediaReviewTiming',
path: 'ankiConnect.media.reviewTiming',
label: 'Review Media Timing',
scope: 'ankiConnect',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
media: { reviewTiming: value === true },
}),
},
{
id: 'subtitle.annotation.knownWords.highlightEnabled',
path: 'ankiConnect.knownWords.highlightEnabled',
+10 -1
View File
@@ -1,6 +1,15 @@
import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Japanese Subtitle Generation',
description: [
'Generate timed Japanese subtitles from local audio using whisper.cpp.',
'Configure an existing GGML model path or explicitly download a SubMiner-managed model.',
],
notes: ['Hot-reload: settings apply to the next generation or model download.'],
key: 'subtitleGeneration',
},
{
title: 'Visible Overlay Auto-Start',
description: [
@@ -135,7 +144,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
+28
View File
@@ -21,6 +21,34 @@ function makeContext(ankiConnect: unknown): {
return { context, warnings };
}
test('media timing review is disabled by default and accepts a boolean override', () => {
const defaultContext = makeContext({});
applyAnkiConnectResolution(defaultContext.context);
assert.equal(defaultContext.context.resolved.ankiConnect.media.reviewTiming, false);
const enabledContext = makeContext({ media: { reviewTiming: true } });
applyAnkiConnectResolution(enabledContext.context);
assert.equal(enabledContext.context.resolved.ankiConnect.media.reviewTiming, true);
assert.deepEqual(enabledContext.warnings, []);
});
test('modern media duration accepts zero as the disabled cap sentinel', () => {
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
applyAnkiConnectResolution(disabledCap.context);
assert.equal(disabledCap.context.resolved.ankiConnect.media.maxMediaDuration, 0);
assert.deepEqual(disabledCap.warnings, []);
const invalidCap = makeContext({ media: { maxMediaDuration: -1 } });
applyAnkiConnectResolution(invalidCap.context);
assert.equal(
invalidCap.context.resolved.ankiConnect.media.maxMediaDuration,
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
);
assert.ok(
invalidCap.warnings.some((warning) => warning.path === 'ankiConnect.media.maxMediaDuration'),
);
});
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
const { context, warnings } = makeContext({
nPlusOne: { highlightEnabled: true },
+25 -14
View File
@@ -19,6 +19,7 @@ export function applyModernMediaResolution(
'syncAnimatedImageToWordAudio',
'normalizeAudio',
'mirrorMpvVolume',
'reviewTiming',
] as const) {
applyModernValue(
context,
@@ -128,18 +129,28 @@ export function applyModernMediaResolution(
'Expected non-negative number.',
);
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asPositiveNumber,
DEFAULT_CONFIG.ankiConnect.media[key],
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected positive number.',
);
}
applyModernValue(
context,
media,
'fallbackDuration',
'ankiConnect.media.fallbackDuration',
asPositiveNumber,
DEFAULT_CONFIG.ankiConnect.media.fallbackDuration,
(value) => {
context.resolved.ankiConnect.media.fallbackDuration = value;
},
'Expected positive number.',
);
applyModernValue(
context,
media,
'maxMediaDuration',
'ankiConnect.media.maxMediaDuration',
asNonNegativeNumber,
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
(value) => {
context.resolved.ankiConnect.media.maxMediaDuration = value;
},
'Expected non-negative number.',
);
}
+1
View File
@@ -237,6 +237,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openRuntimeOptions',
'openJimaku',
'openTsukihime',
'openSubtitleGeneration',
'openSessionHelp',
'openControllerSelect',
'openControllerDebug',
+12
View File
@@ -1,5 +1,6 @@
import { ResolvedConfig } from '../../types/config';
import { ResolveContext } from './context';
import { resolveSubtitleGenerationConfig } from '../../shared/subtitle-generation';
import {
asBoolean,
asColor,
@@ -46,6 +47,17 @@ function applySubtitleHoverTokenCssCompatibility(
export function applySubtitleDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
resolved.subtitleGeneration = resolveSubtitleGenerationConfig(
src.subtitleGeneration,
(key, value, message) => {
const configPath = key === 'subtitleGeneration' ? key : `subtitleGeneration.${key}`;
const fallback =
key === 'subtitleGeneration'
? resolved.subtitleGeneration
: Object.entries(resolved.subtitleGeneration).find(([name]) => name === key)?.[1];
warn(configPath, value, fallback, message);
},
);
if (isObject(src.jimaku)) {
const apiKey = asString(src.jimaku.apiKey);
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
import { resolveSubtitleGenerationConfig } from '../../shared/subtitle-generation';
test('every downloadable multilingual model is accepted by config and offered in settings', () => {
const settings = buildConfigSettingsRegistry(resolveConfig({}).resolved).find(
(entry) => entry.configPath === 'subtitleGeneration.managedModel',
);
assert.deepEqual(
settings?.enumValues,
SUBTITLE_GENERATION_MODELS.map((model) => model.id),
);
for (const { id } of SUBTITLE_GENERATION_MODELS) {
const { resolved, warnings } = resolveConfig({ subtitleGeneration: { managedModel: id } });
assert.equal(resolved.subtitleGeneration.managedModel, id);
assert.equal(warnings.length, 0);
}
});
test('generation config rejects English-only, unknown, and prototype model names', () => {
for (const managedModel of ['tiny.en', 'small.en-q5_1', 'unknown', 'toString', '__proto__']) {
const warnings: string[] = [];
const resolved = resolveSubtitleGenerationConfig({ managedModel }, (key) => warnings.push(key));
assert.equal(resolved.managedModel, 'small');
assert.equal(warnings.length, 1);
}
});
test('generation config is resolved and its external model path is editable without restart', () => {
const { resolved, warnings } = resolveConfig({
subtitleGeneration: { modelPath: '/models/japanese.bin', managedModel: 'medium', threads: 8 },
});
assert.equal(resolved.subtitleGeneration.modelPath, '/models/japanese.bin');
assert.equal(resolved.subtitleGeneration.managedModel, 'medium');
assert.equal(warnings.length, 0);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'subtitleGeneration.modelPath',
);
assert.equal(field?.category, 'integrations');
assert.equal(field?.restartBehavior, 'hot-reload');
});
test('generation executable overrides default to empty and accept blank values without warnings', () => {
for (const subtitleGeneration of [
{},
{ whisperPath: '', ffmpegPath: '', ffprobePath: '' },
{ whisperPath: ' ', ffmpegPath: ' ', ffprobePath: ' ' },
]) {
const { resolved, warnings } = resolveConfig({ subtitleGeneration });
assert.equal(resolved.subtitleGeneration.whisperPath, '');
assert.equal(resolved.subtitleGeneration.ffmpegPath, '');
assert.equal(resolved.subtitleGeneration.ffprobePath, '');
assert.equal(warnings.length, 0);
}
});
test('subtitle generation shortcut can be customized or disabled', () => {
assert.equal(resolveConfig({}).resolved.shortcuts.openSubtitleGeneration, 'Ctrl+Shift+G');
assert.equal(
resolveConfig({ shortcuts: { openSubtitleGeneration: 'Ctrl+Alt+G' } }).resolved.shortcuts
.openSubtitleGeneration,
'Ctrl+Alt+G',
);
assert.equal(
resolveConfig({ shortcuts: { openSubtitleGeneration: null } }).resolved.shortcuts
.openSubtitleGeneration,
null,
);
});
test('dialogue detection paths are optional, validated, and editable in Settings', () => {
const { resolved, warnings } = resolveConfig({
subtitleGeneration: { vadModelPath: ' /models/silero.bin ', vadPath: ' /bin/vad ' },
});
assert.equal(resolved.subtitleGeneration.vadModelPath, '/models/silero.bin');
assert.equal(resolved.subtitleGeneration.vadPath, '/bin/vad');
assert.equal(warnings.length, 0);
for (const key of ['vadModelPath', 'vadPath'] as const) {
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === `subtitleGeneration.${key}`,
);
assert.equal(field?.category, 'integrations');
assert.equal(field?.restartBehavior, 'hot-reload');
assert.equal(resolveConfig({}).resolved.subtitleGeneration[key], '');
assert.equal(resolveConfig({ subtitleGeneration: { [key]: false } }).warnings.length, 1);
}
});
+1
View File
@@ -369,6 +369,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
+8 -1
View File
@@ -248,6 +248,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'mpv.aniskipButtonKey': 'AniSkip Button Key',
'anime.autoOpenJimaku': 'Auto-open Jimaku',
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
'ankiConnect.media.reviewTiming': 'Review Media Timing',
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
};
@@ -452,6 +453,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subtitleGeneration.')) {
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
@@ -624,6 +628,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openTsukihime' ||
leaf === 'openSubtitleGeneration' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
@@ -704,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.media.normalizeAudio' ||
path === 'ankiConnect.media.mirrorMpvVolume' ||
path === 'ankiConnect.media.reviewTiming' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
@@ -732,7 +738,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'anime.autoOpenJimaku' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
pathStartsWith(path, 'subsync') ||
pathStartsWith(path, 'subtitleGeneration')
) {
return 'hot-reload';
}
@@ -33,6 +33,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.ankiConnect.deck = 'Mining';
next.ankiConnect.media.normalizeAudio = !prev.ankiConnect.media.normalizeAudio;
next.ankiConnect.media.mirrorMpvVolume = !prev.ankiConnect.media.mirrorMpvVolume;
next.ankiConnect.media.reviewTiming = !prev.ankiConnect.media.reviewTiming;
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
@@ -69,6 +70,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
+1
View File
@@ -71,6 +71,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
@@ -156,6 +156,107 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
);
});
test('Hyprland placement keeps a recovery dialog above the input-catching overlay', () => {
for (const configProvider of ['hyprlang', 'lua']) {
for (const retryBounds of [false, true]) {
// Bottom to top, as when Hyprland opens its recovery dialog over playback.
const stack = ['0xmpv', '0xoverlay', '0xdialog'];
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
at: [10, 20],
size: [100, 100],
},
{
address: '0xdialog',
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
},
];
let clientReads = 0;
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
bounds: retryBounds ? { x: 0, y: 0, width: 1280, height: 720 } : undefined,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') {
clientReads += 1;
return JSON.stringify(clients);
}
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider });
if (args.join(' ').match(/alterzorder|alter_zorder/)) {
const address = args.join(' ').match(/address:(0x\w+)/)?.[1];
assert.ok(address);
stack.splice(stack.indexOf(address), 1);
stack.push(address);
}
return '';
},
});
assert.equal(status.dispatched, true);
assert.equal(clientReads, retryBounds ? 2 : 1);
assert.deepEqual(stack, ['0xmpv', '0xoverlay', '0xdialog'], configProvider);
}
}
});
test('Hyprland placement only promotes mapped dialogs on the placed window workspace', () => {
const calls: string[] = [];
const dialog = {
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
};
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
},
{ ...dialog, address: '0xhidden', hidden: true },
{ ...dialog, address: '0xunmapped', mapped: false },
{ ...dialog, address: '0xother', workspace: { id: 2 } },
{ ...dialog, address: '0xordinary', class: 'terminal' },
{ ...dialog, address: '0xinitial', class: '', initialClass: 'hyprland-dialog' },
];
for (const promote of [true, false]) {
calls.length = 0;
ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
promote,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') return JSON.stringify(clients);
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider: 'hyprlang' });
calls.push(args.join(' '));
return '';
},
});
assert.deepEqual(
calls,
promote
? [
'dispatch alterzorder top,address:0xoverlay',
'dispatch alterzorder top,address:0xinitial',
]
: [],
);
}
});
test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => {
const buildDispatches = buildHyprlandPlacementDispatches as (
client: Parameters<typeof buildHyprlandPlacementDispatches>[0],
+52 -4
View File
@@ -3,14 +3,17 @@ import { execFileSync } from 'node:child_process';
export interface HyprlandPlacementClient {
address?: string;
at?: [number, number];
class?: string;
floating?: boolean;
hidden?: boolean;
initialClass?: string;
initialTitle?: string;
mapped?: boolean;
pid?: number;
pinned?: boolean;
size?: [number, number];
title?: string;
workspace?: { id: number };
}
export interface HyprlandPlacementBounds {
@@ -25,7 +28,11 @@ export interface HyprlandPlacementDispatchOptions {
promote?: boolean;
}
type ExecFileSync = typeof execFileSync;
type ExecFileSync = (
file: string,
args: string[],
options: NonNullable<Parameters<typeof execFileSync>[2]>,
) => ReturnType<typeof execFileSync>;
export type HyprlandConfigProvider = 'hyprlang' | 'lua';
export function shouldAttemptHyprlandWindowPlacement(
@@ -154,6 +161,33 @@ function luaWindowDispatch(name: string, windowAddress: string, fields: string[]
];
}
// Compositor recovery dialogs must remain clickable even when an overlay still owns input.
function buildHyprlandDialogPromotionDispatches(
clients: HyprlandPlacementClient[],
placedClient: HyprlandPlacementClient,
configProvider: HyprlandConfigProvider,
): string[][] {
if (typeof placedClient.workspace?.id !== 'number') return [];
return clients.flatMap((client) => {
if (
!client.address ||
client.address === placedClient.address ||
client.mapped === false ||
client.hidden === true ||
client.workspace?.id !== placedClient.workspace?.id ||
(client.class !== 'hyprland-dialog' && client.initialClass !== 'hyprland-dialog')
) {
return [];
}
const windowAddress = `address:${client.address}`;
return [
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
];
});
}
function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] {
return luaWindowDispatch('set_prop', windowAddress, [
`prop = ${luaString(prop)}`,
@@ -331,12 +365,16 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
configProvider,
promote: options.promote,
});
if (options.promote !== false) {
dispatches.push(...buildHyprlandDialogPromotionDispatches(clients, client, configProvider));
}
for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
if (shouldVerifyBounds) {
try {
const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), {
const refreshedClients = readHyprlandPlacementClients(run);
const refreshedClient = findHyprlandWindowForPlacement(refreshedClients, {
pid: options.pid ?? process.pid,
title: options.title,
});
@@ -345,10 +383,20 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
targetBounds &&
clientMatchesPlacementBounds(refreshedClient, targetBounds) === false
) {
for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
const retryDispatches = buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
configProvider,
promote: options.promote,
})) {
});
if (options.promote !== false) {
retryDispatches.push(
...buildHyprlandDialogPromotionDispatches(
refreshedClients,
refreshedClient,
configProvider,
),
);
}
for (const args of retryDispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
}
@@ -306,9 +306,11 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, '', 1700000000, 1700000000, ?)`,
);
db.exec('BEGIN');
for (let index = 0; index < 501; index += 1) {
insertWord.run(`語${index}`, `語${index}`, index === 500 ? 10_000 : 1);
}
db.exec('COMMIT');
const charts = getVocabularyChartData(db);
+100 -1
View File
@@ -89,6 +89,7 @@ function createControllerConfigFixture() {
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
sourceKey: 'test-subtitles',
cues: [],
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
@@ -648,6 +649,83 @@ test('registerIpcHandlers exposes playback window activation request', async ()
assert.deepEqual(calls, ['activate']);
});
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const requests: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
resolveMediaTimingReview: async (request) => {
requests.push(request);
return { ok: true };
},
}),
registrar,
);
const handler = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewResolve);
assert.ok(handler);
assert.deepEqual(
await handler!({}, { reviewId: 'review-1', decision: { action: 'skip-media' } }),
{ ok: true },
);
assert.deepEqual(requests, [{ reviewId: 'review-1', decision: { action: 'skip-media' } }]);
});
test('registerIpcHandlers validates and forwards combined timing review text', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const requests: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
resolveMediaTimingReview: async (request) => {
requests.push(request);
return { ok: true };
},
}),
registrar,
);
const handler = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewResolve);
assert.ok(handler);
assert.deepEqual(
await handler!(
{},
{
reviewId: 'review-1',
decision: {
action: 'confirm',
startTime: 10,
endTime: 12,
text: '前の行 対象の行',
},
},
),
{ ok: true },
);
assert.deepEqual(requests, [
{
reviewId: 'review-1',
decision: {
action: 'confirm',
startTime: 10,
endTime: 12,
text: '前の行 対象の行',
},
},
]);
assert.deepEqual(
await handler!(
{},
{
reviewId: 'review-1',
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
},
),
{ ok: false, message: 'Timing review is unavailable.' },
);
assert.equal(requests.length, 1);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -959,7 +1037,13 @@ test('registerIpcHandlers accepts per-controller profile config updates', async
},
};
await saveHandler({}, update);
assert.deepEqual(controllerSaves, [update]);
assert.deepEqual(controllerSaves, [
{
...update,
// Validation uses a null prototype to safely store arbitrary profile IDs.
profiles: { __proto__: null, ...update.profiles },
},
]);
await assert.rejects(async () => {
await saveHandler(
@@ -1239,3 +1323,18 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
assert.deepEqual(calls, [21355]);
assert.deepEqual(searches, ['Re:ZERO']);
});
test('mpv discovery has its own request and does not change session bindings', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const snapshot = { keys: ['r'], blockedKeys: [] };
registerIpcHandlers(
createRegisterIpcDeps({ getMpvInputBindings: async () => snapshot }),
registrar,
);
const discovery = handlers.handle.get(IPC_CHANNELS.request.getMpvInputBindings);
const session = handlers.handle.get(IPC_CHANNELS.request.getSessionBindings);
assert.ok(discovery);
assert.ok(session);
assert.deepEqual(await discovery({}), snapshot);
assert.deepEqual(await session({}), []);
});
+139
View File
@@ -1,4 +1,5 @@
import electron from 'electron';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type {
ChangelogSnapshot,
@@ -19,6 +20,13 @@ import type {
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
import type {
MediaTimingReviewActionResult,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
parseMpvCommand,
@@ -82,6 +90,7 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -99,6 +108,16 @@ export interface IpcServiceDeps {
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
previewMediaTimingReview?: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
getMediaTimingReviewWaveform?: (
request: MediaTimingReviewWaveformRequest,
) => Promise<MediaTimingReviewWaveformResult>;
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
resolveMediaTimingReview?: (
request: MediaTimingReviewResolveRequest,
) => MediaTimingReviewActionResult | Promise<MediaTimingReviewActionResult>;
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -222,6 +241,72 @@ function parseOverlayNotificationActionPayload(
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
}
function parseMediaTimingReviewPreviewRequest(
payload: unknown,
): MediaTimingReviewPreviewRequest | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (
typeof record.reviewId !== 'string' ||
!record.reviewId ||
typeof record.startTime !== 'number' ||
!Number.isFinite(record.startTime) ||
typeof record.endTime !== 'number' ||
!Number.isFinite(record.endTime)
) {
return null;
}
return {
reviewId: record.reviewId,
startTime: record.startTime,
endTime: record.endTime,
};
}
function parseMediaTimingReviewWaveformRequest(
payload: unknown,
): MediaTimingReviewWaveformRequest | null {
return parseMediaTimingReviewPreviewRequest(payload);
}
function parseMediaTimingReviewResolveRequest(
payload: unknown,
): MediaTimingReviewResolveRequest | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (typeof record.reviewId !== 'string' || !record.reviewId) return null;
const decision = record.decision;
if (!decision || typeof decision !== 'object') return null;
const decisionRecord = decision as Record<string, unknown>;
if (
decisionRecord.action === 'use-original' ||
decisionRecord.action === 'skip-media' ||
decisionRecord.action === 'discard'
) {
return { reviewId: record.reviewId, decision: { action: decisionRecord.action } };
}
if (
decisionRecord.action === 'confirm' &&
typeof decisionRecord.startTime === 'number' &&
Number.isFinite(decisionRecord.startTime) &&
typeof decisionRecord.endTime === 'number' &&
Number.isFinite(decisionRecord.endTime) &&
(decisionRecord.text === undefined ||
(typeof decisionRecord.text === 'string' && decisionRecord.text.trim().length > 0))
) {
return {
reviewId: record.reviewId,
decision: {
action: 'confirm',
startTime: decisionRecord.startTime,
endTime: decisionRecord.endTime,
...(decisionRecord.text === undefined ? {} : { text: decisionRecord.text }),
},
};
}
return null;
}
export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
@@ -261,6 +346,7 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -278,6 +364,10 @@ export interface IpcDepsRuntimeOptions {
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -351,6 +441,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
},
handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings,
getMpvInputBindings: options.getMpvInputBindings,
getSessionBindings: options.getSessionBindings ?? (() => []),
getConfiguredShortcuts: options.getConfiguredShortcuts,
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
@@ -371,6 +462,10 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
previewMediaTimingReview: options.previewMediaTimingReview,
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
resolveMediaTimingReview: options.resolveMediaTimingReview,
getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption,
@@ -498,6 +593,46 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewPreview,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewPreviewRequest(payload);
if (!request || !deps.previewMediaTimingReview) {
return { ok: false, message: 'Timing preview is unavailable.' };
}
return await deps.previewMediaTimingReview(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewWaveform,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewWaveformRequest(payload);
if (!request || !deps.getMediaTimingReviewWaveform) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return await deps.getMediaTimingReviewWaveform(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
async (_event: unknown, reviewId: unknown) => {
if (typeof reviewId !== 'string' || !reviewId || !deps.stopMediaTimingReviewPreview) {
return { ok: false, message: 'Timing preview is unavailable.' };
}
return await deps.stopMediaTimingReviewPreview(reviewId);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewResolve,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewResolveRequest(payload);
if (!request || !deps.resolveMediaTimingReview) {
return { ok: false, message: 'Timing review is unavailable.' };
}
return await deps.resolveMediaTimingReview(request);
},
);
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings();
});
@@ -638,6 +773,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getKeybindings();
});
ipc.handle(IPC_CHANNELS.request.getMpvInputBindings, () => {
return deps.getMpvInputBindings?.() ?? { keys: [], blockedKeys: [] };
});
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
return deps.getSessionBindings?.() ?? [];
});
@@ -0,0 +1,291 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import net from 'node:net';
import { describe, test } from 'node:test';
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
describe('buildMediaTimingPreviewArgs', () => {
test('creates a hidden audio-only reusable mpv session', () => {
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
mediaPath: '/video/show.mkv',
audioTrackId: 3,
volume: 55,
});
assert.ok(args.includes('--no-video'));
assert.ok(args.includes('--force-window=no'));
assert.ok(args.includes('--idle=yes'));
assert.ok(args.includes('--pause=yes'));
assert.ok(args.includes('--input-ipc-server=/tmp/review.sock'));
assert.ok(args.includes('--aid=3'));
assert.ok(args.includes('--volume=55'));
assert.equal(args.at(-2), '--');
assert.equal(args.at(-1), '/video/show.mkv');
});
test('keeps source timestamps for cached remote windows', () => {
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
mediaPath: '/tmp/window.mkv',
absoluteTimestamps: true,
});
assert.ok(args.includes('--rebase-start-time=no'));
assert.equal(
buildMediaTimingPreviewArgs('/tmp/review.sock', { mediaPath: '/video/show.mkv' }).includes(
'--rebase-start-time=no',
),
false,
);
});
test('separates an option-like media path without adding optional audio arguments', () => {
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
mediaPath: '--fullscreen',
});
assert.equal(args.at(-2), '--');
assert.equal(args.at(-1), '--fullscreen');
assert.equal(
args.some((arg) => arg.startsWith('--aid=')),
false,
);
assert.equal(
args.some((arg) => arg.startsWith('--volume=')),
false,
);
});
});
test('preview session handles socket errors after connecting', async () => {
const socket = new net.Socket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
await session.start({ mediaPath: '/video/show.mkv' });
assert.doesNotThrow(() => socket.emit('error', new Error('pipe closed')));
await assert.rejects(session.play(1, 2), /not ready/);
session.dispose();
});
test('preview session keeps failed connection errors handled through destruction', async () => {
const socket = new EventEmitter() as EventEmitter & {
destroy: () => void;
};
socket.destroy = () => {
socket.emit('error', new Error('socket failed again while closing'));
};
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
const times = [0, 0, 0, 6_000];
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => {
queueMicrotask(() => socket.emit('error', new Error('connection failed')));
return socket as never;
},
now: () => times.shift() ?? 6_000,
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
});
test('preview session rejects a connection that finishes after disposal', async () => {
const socket = new net.Socket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => socket,
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
const pendingStart = session.start({ mediaPath: '-playlist' });
session.dispose();
socket.emit('connect');
await assert.rejects(pendingStart, /closed/);
assert.equal(socket.destroyed, true);
});
test('preview session shares one startup across concurrent start calls', async () => {
const socket = new net.Socket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
let spawnCount = 0;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => {
spawnCount += 1;
return child as never;
},
connectSocket: () => socket,
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
const firstStart = session.start({ mediaPath: '/video/show.mkv' });
const secondStart = session.start({ mediaPath: '/video/show.mkv' });
socket.emit('connect');
await Promise.all([firstStart, secondStart]);
assert.equal(spawnCount, 1);
session.dispose();
});
test('preview session can start again after a startup failure', async () => {
const socket = new net.Socket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
let spawnCount = 0;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => {
spawnCount += 1;
if (spawnCount === 1) throw new Error('spawn failed');
return child as never;
},
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /spawn failed/);
await session.start({ mediaPath: '/video/show.mkv' });
assert.equal(spawnCount, 2);
session.dispose();
});
test('preview session bounds a connection attempt that never settles', async () => {
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
let nowMs = 0;
let connectAttempts = 0;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => {
connectAttempts += 1;
const socket = new net.Socket();
socket.destroy = (() => {
socket.emit('error', new Error('socket failed while timing out'));
return socket;
}) as typeof socket.destroy;
return socket;
},
now: () => {
const current = nowMs;
nowMs += 1_000;
return current;
},
schedule: (callback) => setTimeout(callback, 0),
cancelSchedule: (timeout) => clearTimeout(timeout),
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
assert.equal(connectAttempts, 1);
});
function createFakeSocket() {
const socket = new EventEmitter() as EventEmitter & {
destroyed: boolean;
write: (data: string) => boolean;
end: () => void;
destroy: () => void;
off: EventEmitter['off'];
};
const writes: string[] = [];
socket.destroyed = false;
socket.write = (data) => {
writes.push(data);
return true;
};
socket.end = () => undefined;
socket.destroy = () => {
socket.destroyed = true;
};
return { socket, writes };
}
test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
const { socket, writes } = createFakeSocket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
let endedCount = 0;
session.onPlaybackEnded(() => {
endedCount += 1;
});
const property = (name: string, data: boolean): string =>
`${JSON.stringify({ event: 'property-change', name, data })}\n`;
await session.start({ mediaPath: '/video/show.mkv' });
assert.deepEqual(
writes.map((line) => JSON.parse(line).command),
[
['observe_property', 1, 'eof-reached'],
['observe_property', 2, 'pause'],
],
);
// The observers' initial replies describe the idle paused player, not a finished preview.
socket.emit('data', property('eof-reached', false) + property('pause', true));
assert.equal(endedCount, 0);
writes.length = 0;
await session.play(12.25, 14.5);
assert.deepEqual(
writes.map((line) => JSON.parse(line).command),
[
['set_property', 'pause', true],
['seek', 12.25, 'absolute+exact'],
['set_property', 'end', '14.500'],
['set_property', 'pause', false],
],
);
// Events may arrive split across chunks. The decoder passing `end` flips eof-reached while
// audio still drains; only the keep-open pause that follows marks the preview as finished.
socket.emit('data', property('eof-reached', false) + property('pause', false).slice(0, 20));
socket.emit('data', property('pause', false).slice(20) + property('eof-reached', true));
assert.equal(endedCount, 0);
socket.emit('data', property('pause', true));
assert.equal(endedCount, 1);
socket.emit('data', property('pause', true));
assert.equal(endedCount, 1);
// Stopping early pauses without an end signal, and a later real EOF is not a preview end.
await session.play(1, 2);
socket.emit('data', property('eof-reached', false) + property('pause', false));
await session.stop();
socket.emit('data', property('pause', true) + property('eof-reached', true));
assert.equal(endedCount, 1);
session.dispose();
});
+394
View File
@@ -0,0 +1,394 @@
import { spawn, type ChildProcess } from 'child_process';
import fs from 'fs';
import net, { type Socket } from 'net';
import os from 'os';
import path from 'path';
import { randomUUID } from 'crypto';
const CONNECT_TIMEOUT_MS = 5_000;
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
const CONNECT_RETRY_MS = 40;
/**
* mpv flips eof-reached as soon as the decoder passes `end`, while its audio buffer is still
* draining; keep-open then pauses once the buffer has played out. A preview has ended when
* both have happened.
*/
const EOF_OBSERVER_ID = 1;
const PAUSE_OBSERVER_ID = 2;
export interface MediaTimingPreviewStartOptions {
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
/** The file keeps source timestamps (a cached remote window); seek with the original times. */
absoluteTimestamps?: boolean;
}
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
interface MediaTimingPreviewDeps {
platform: NodeJS.Platform;
spawnProcess: (command: string, args: string[]) => PreviewProcess;
connectSocket: (socketPath: string) => Socket;
now: () => number;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
cancelSchedule: (timeout: ReturnType<typeof setTimeout>) => void;
removeSocketFile: (socketPath: string) => void;
createSocketPath: () => string;
}
export function buildMediaTimingPreviewArgs(
socketPath: string,
options: MediaTimingPreviewStartOptions,
): string[] {
const args = [
'--no-config',
'--no-video',
'--audio-display=no',
'--force-window=no',
'--idle=yes',
'--keep-open=yes',
'--pause=yes',
'--terminal=no',
'--msg-level=all=warn',
`--input-ipc-server=${socketPath}`,
];
if (typeof options.audioTrackId === 'number' && Number.isInteger(options.audioTrackId)) {
args.push(`--aid=${options.audioTrackId}`);
}
if (typeof options.volume === 'number' && Number.isFinite(options.volume)) {
args.push(`--volume=${Math.max(0, options.volume)}`);
}
if (options.absoluteTimestamps) {
args.push('--rebase-start-time=no');
}
args.push('--', options.mediaPath);
return args;
}
function createDefaultSocketPath(): string {
const suffix = `${process.pid}-${randomUUID()}`;
return process.platform === 'win32'
? `\\\\.\\pipe\\subminer-timing-preview-${suffix}`
: path.join(
// macOS limits Unix socket paths to 104 bytes, while its temp directory can be long.
process.platform === 'darwin' ? '/tmp' : os.tmpdir(),
`subminer-timing-preview-${suffix}.sock`,
);
}
function removePosixSocketFile(socketPath: string): void {
if (process.platform === 'win32') return;
try {
fs.unlinkSync(socketPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
export class MediaTimingPreviewSession {
private readonly deps: MediaTimingPreviewDeps;
private socketPath: string | null = null;
private socket: Socket | null = null;
private process: PreviewProcess | null = null;
private startupError: Error | null = null;
private startPromise: Promise<void> | null = null;
private retryWait: {
timeout: ReturnType<typeof setTimeout>;
resolve: () => void;
} | null = null;
private disposed = false;
private readBuffer = '';
private playing = false;
private eofReached = false;
private paused = true;
private readonly endedListeners = new Set<() => void>();
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
this.deps = {
platform: process.platform,
spawnProcess: (command, args) => spawn(command, args, { stdio: 'ignore' }),
connectSocket: (socketPath) => net.createConnection(socketPath),
now: Date.now,
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
cancelSchedule: (timeout) => clearTimeout(timeout),
removeSocketFile: removePosixSocketFile,
createSocketPath: createDefaultSocketPath,
...deps,
};
}
async start(options: MediaTimingPreviewStartOptions): Promise<void> {
if (this.disposed) throw new Error('Preview session is closed');
if (this.socket) return;
if (this.startPromise) return await this.startPromise;
const startPromise = this.startOnce(options);
this.startPromise = startPromise;
try {
await startPromise;
} catch (error) {
this.releaseResources();
throw error;
} finally {
if (this.startPromise === startPromise) this.startPromise = null;
}
}
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
const mediaPath = options.mediaPath.trim();
if (!mediaPath) throw new Error('No media source is available for preview');
const socketPath = this.deps.createSocketPath();
this.socketPath = socketPath;
if (this.deps.platform !== 'win32') {
this.deps.removeSocketFile(socketPath);
}
const command = options.executablePath?.trim() || 'mpv';
this.startupError = null;
const child = this.deps.spawnProcess(
command,
buildMediaTimingPreviewArgs(socketPath, { ...options, mediaPath }),
);
this.process = child;
child.once('error', (error) => {
if (this.process !== child) return;
this.startupError = error;
});
child.once('exit', () => {
if (this.process !== child) return;
if (!this.socket && !this.disposed && !this.startupError) {
this.startupError = new Error('The hidden mpv preview player exited during startup');
}
this.socket?.destroy();
this.socket = null;
this.process = null;
});
await this.connectWithRetry(socketPath);
}
/**
* Plays [startTime, endTime) once. mpv stops itself at `end` and, thanks to keep-open,
* pauses after draining the audio device, so the listener hears the whole clip even on
* high-latency outputs. onPlaybackEnded fires when mpv reports the end was reached.
*/
async play(startTime: number, endTime: number): Promise<void> {
if (!this.socket || this.socket.destroyed) {
throw new Error('Preview player is not ready');
}
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
throw new Error('Preview timing is invalid');
}
this.playing = false;
this.send(['set_property', 'pause', true]);
this.send(['seek', startTime, 'absolute+exact']);
// The option parser wants a time string; a raw JSON number is not accepted for `end`.
this.send(['set_property', 'end', endTime.toFixed(3)]);
this.send(['set_property', 'pause', false]);
// Only the seek's eof-reached=false and the later keep-open pause count for this play.
this.eofReached = false;
this.paused = false;
this.playing = true;
}
async stop(): Promise<void> {
this.playing = false;
if (!this.socket || this.socket.destroyed) return;
this.send(['set_property', 'pause', true]);
}
onPlaybackEnded(listener: () => void): void {
this.endedListeners.add(listener);
}
private finishPlayback(): void {
if (!this.playing) return;
this.playing = false;
for (const listener of this.endedListeners) listener();
}
private handleSocketData(chunk: Buffer | string): void {
this.readBuffer += chunk.toString();
let newline = this.readBuffer.indexOf('\n');
while (newline !== -1) {
const line = this.readBuffer.slice(0, newline).trim();
this.readBuffer = this.readBuffer.slice(newline + 1);
newline = this.readBuffer.indexOf('\n');
if (!line) continue;
let message: unknown;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (
typeof message === 'object' &&
message !== null &&
'event' in message &&
message.event === 'property-change' &&
'name' in message &&
'data' in message
) {
this.handlePropertyChange(message.name, message.data);
}
}
}
private handlePropertyChange(name: unknown, data: unknown): void {
if (name === 'eof-reached') this.eofReached = data === true;
else if (name === 'pause') this.paused = data === true;
else return;
if (this.playing && this.eofReached && this.paused) this.finishPlayback();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.releaseResources();
}
private releaseResources(): void {
this.cancelRetryWait();
try {
this.send(['quit']);
} catch {
// The process may already have exited.
}
this.socket?.end();
this.socket?.destroy();
this.socket = null;
const child = this.process;
this.process = null;
child?.kill();
if (this.socketPath && this.deps.platform !== 'win32') {
try {
this.deps.removeSocketFile(this.socketPath);
} catch {
// mpv may still be releasing the socket. The OS temp directory owns cleanup.
}
}
this.socketPath = null;
}
private send(command: Array<string | number | boolean>): void {
if (!this.socket || this.socket.destroyed) {
throw new Error('Preview player is not connected');
}
this.socket.write(`${JSON.stringify({ command })}\n`);
}
private async connectWithRetry(socketPath: string): Promise<void> {
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
while (!this.disposed && this.deps.now() < deadline) {
if (this.startupError) {
throw this.startupError;
}
try {
const remainingMs = deadline - this.deps.now();
if (remainingMs <= 0) break;
const socket = await this.connectOnce(
socketPath,
Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs),
);
if (this.disposed) {
socket.destroy();
throw new Error('Preview session is closed');
}
this.socket = socket;
this.readBuffer = '';
socket.on('data', (chunk: Buffer | string) => {
if (this.socket === socket) this.handleSocketData(chunk);
});
socket.once('close', () => this.finishPlayback());
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
return;
} catch {
if (this.disposed) {
throw new Error('Preview session is closed');
}
const remainingMs = deadline - this.deps.now();
if (remainingMs <= 0) break;
await this.waitForRetry(Math.min(CONNECT_RETRY_MS, remainingMs));
}
}
if (this.startupError) {
throw this.startupError;
}
if (this.disposed) {
throw new Error('Preview session is closed');
}
throw new Error('Timed out starting the hidden mpv preview player');
}
private waitForRetry(delayMs: number): Promise<void> {
return new Promise<void>((resolve) => {
const timeout = this.deps.schedule(() => {
if (this.retryWait?.timeout === timeout) this.retryWait = null;
resolve();
}, delayMs);
this.retryWait = { timeout, resolve };
});
}
private cancelRetryWait(): void {
const pending = this.retryWait;
this.retryWait = null;
if (!pending) return;
this.deps.cancelSchedule(pending.timeout);
pending.resolve();
}
private connectOnce(socketPath: string, timeoutMs: number): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | null = null;
let settled = false;
const clearAttemptTimeout = (): void => {
if (timeout !== null) this.deps.cancelSchedule(timeout);
timeout = null;
};
const socket = this.deps.connectSocket(socketPath);
const onConnect = (): void => {
if (settled) return;
settled = true;
clearAttemptTimeout();
socket.off('error', onError);
socket.on('error', () => {
socket.destroy();
if (this.socket === socket) this.socket = null;
});
socket.once('close', () => {
if (this.socket === socket) this.socket = null;
});
resolve(socket);
};
const onError = (error: Error): void => {
if (settled) return;
settled = true;
clearAttemptTimeout();
socket.off('connect', onConnect);
socket.on('error', () => {});
socket.destroy();
reject(error);
};
socket.once('connect', onConnect);
socket.once('error', onError);
timeout = this.deps.schedule(() => {
if (settled) return;
settled = true;
timeout = null;
socket.off('connect', onConnect);
socket.off('error', onError);
socket.on('error', () => {});
socket.destroy();
reject(new Error('Timed out connecting to the hidden mpv preview player'));
}, timeoutMs);
});
}
}
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSpeechWaveformArgs,
computeWaveformPeaks,
generateSpeechWaveform,
} from './media-timing-waveform';
function pcm(samples: number[]): Buffer {
const result = Buffer.alloc(samples.length * 2);
samples.forEach((sample, index) => result.writeInt16LE(sample, index * 2));
return result;
}
test('speech waveform maps the selected FFmpeg stream and visible range', () => {
const args = buildSpeechWaveformArgs(
{
mediaPath: '/video/show.mkv',
startTime: 8,
endTime: 15,
audioStreamIndex: 3,
},
'center',
);
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-t') + 2), [
'-ss',
'8',
'-i',
'/video/show.mkv',
'-t',
'7',
]);
assert.deepEqual(args.slice(args.indexOf('-map'), args.indexOf('-map') + 2), ['-map', '0:3']);
assert.match(args[args.indexOf('-af') + 1] ?? '', /c0=FC/);
});
test('speech waveform seeks cached windows by source timestamps', () => {
const args = buildSpeechWaveformArgs(
{
mediaPath: { path: '/tmp/window.mkv', absoluteTimestamps: true, singleResolvedStream: true },
startTime: 8,
endTime: 15,
},
'downmix',
);
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-t') + 2), [
'-ss',
'8',
'-seek_timestamp',
'1',
'-i',
'/tmp/window.mkv',
'-t',
'7',
]);
assert.equal(args.includes('-map'), false);
});
test('waveform levels rise with loudness and top out at the reference level', () => {
const peaks = computeWaveformPeaks(pcm([0, 1_000, -2_000, 4_000, -8_000, 16_000]), 3);
assert.equal(peaks.length, 3);
assert.equal(peaks[0], 0);
assert.ok((peaks[1] ?? 0) > 0);
assert.ok((peaks[1] ?? 0) < (peaks[2] ?? 0));
assert.equal(peaks[2], 1);
});
test('waveform flattens steady background noise and keeps speech bursts tall', () => {
// 20 slices of steady noise at a fixed level with an 18 dB louder "speech" burst in the middle.
const noise = 1_000;
const samples: number[] = [];
for (let slice = 0; slice < 20; slice += 1) {
const level = slice >= 8 && slice < 12 ? noise * 8 : noise;
for (let sample = 0; sample < 50; sample += 1) {
samples.push(sample % 2 === 0 ? level : -level);
}
}
const peaks = computeWaveformPeaks(pcm(samples), 20);
for (const [index, peak] of peaks.entries()) {
if (index >= 8 && index < 12) assert.equal(peak, 1);
else assert.equal(peak, 0);
}
});
test('waveform stays flat when the whole range is a single steady level', () => {
const peaks = computeWaveformPeaks(
pcm(Array.from({ length: 400 }, (_, i) => (i % 2 ? 900 : -900))),
40,
);
assert.ok(peaks.every((peak) => peak === 0));
});
test('speech waveform uses a mono downmix when the source has no center activity', async () => {
const calls: string[][] = [];
const peaks = await generateSpeechWaveform(
{ mediaPath: '/video/show.mkv', startTime: 0, endTime: 2 },
async (args) => {
calls.push(args);
return calls.length === 1 ? pcm([0, 0, 0, 0]) : pcm([0, 4_000, -8_000, 16_000]);
},
);
assert.equal(calls.length, 2);
assert.match(calls[1]?.[calls[1].indexOf('-af') + 1] ?? '', /channel_layouts=mono/);
assert.equal(Math.max(...peaks), 1);
});
test('speech waveform keeps an active center channel without doing a second decode', async () => {
let calls = 0;
await generateSpeechWaveform(
{ mediaPath: '/video/show.mkv', startTime: 0, endTime: 2 },
async () => {
calls += 1;
return pcm([0, 4_000, -8_000, 16_000]);
},
);
assert.equal(calls, 1);
});
+185
View File
@@ -0,0 +1,185 @@
import { spawn } from 'node:child_process';
import { normalizeMediaInput, type MediaInput } from '../../media-input';
const WAVEFORM_SAMPLE_RATE = 8_000;
const WAVEFORM_POINT_COUNT = 480;
const WAVEFORM_TIMEOUT_MS = 15_000;
const MAX_WAVEFORM_BYTES = 16 * 1024 * 1024;
// Keep the band where speech intelligibility lives; bass, drums, and hum sit below it.
const SPEECH_FILTER = 'highpass=f=250,lowpass=f=3500';
const NOISE_FLOOR_PERCENTILE = 0.2;
const REFERENCE_PERCENTILE = 0.95;
const NOISE_GATE_DB = 3;
const MIN_DISPLAY_RANGE_DB = 12;
const SILENCE_DB = -100;
const CENTER_CHANNEL_FILTER = `pan=mono|c0=FC,${SPEECH_FILTER}`;
const DOWNMIX_FILTER = `aformat=channel_layouts=mono,${SPEECH_FILTER}`;
export interface SpeechWaveformOptions {
mediaPath: MediaInput;
startTime: number;
endTime: number;
audioStreamIndex?: number;
}
type RunFfmpeg = (args: string[]) => Promise<Buffer>;
export function buildSpeechWaveformArgs(
options: SpeechWaveformOptions,
mode: 'center' | 'downmix',
): string[] {
const duration = options.endTime - options.startTime;
const input = normalizeMediaInput(options.mediaPath);
const args = [
'-hide_banner',
'-nostdin',
'-loglevel',
'error',
'-ss',
String(options.startTime),
...input.inputArgs,
'-i',
input.path,
'-t',
String(duration),
];
if (
options.audioStreamIndex !== undefined &&
Number.isInteger(options.audioStreamIndex) &&
options.audioStreamIndex >= 0
) {
args.push('-map', `0:${options.audioStreamIndex}`);
}
args.push(
'-vn',
'-sn',
'-dn',
'-af',
mode === 'center' ? CENTER_CHANNEL_FILTER : DOWNMIX_FILTER,
'-ac',
'1',
'-ar',
String(WAVEFORM_SAMPLE_RATE),
'-f',
's16le',
'pipe:1',
);
return args;
}
function runFfmpeg(args: string[]): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const chunks: Buffer[] = [];
let byteLength = 0;
let stderr = '';
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
reject(new Error(`FFmpeg waveform analysis timed out after ${WAVEFORM_TIMEOUT_MS}ms`));
}, WAVEFORM_TIMEOUT_MS);
const settle = (callback: () => void): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback();
};
child.stdout.on('data', (chunk: Buffer) => {
if (settled) return;
byteLength += chunk.byteLength;
if (byteLength > MAX_WAVEFORM_BYTES) {
settle(() => {
child.kill('SIGKILL');
reject(new Error('The visible waveform range is too large to analyze.'));
});
return;
}
chunks.push(chunk);
});
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
if (stderr.length < 4_000) stderr += String(chunk);
});
child.once('error', (error) => settle(() => reject(error)));
child.once('close', (code) => {
settle(() => {
if (code === 0) {
resolve(Buffer.concat(chunks, byteLength));
return;
}
reject(new Error(stderr.trim() || `FFmpeg exited with status ${code ?? 'unknown'}`));
});
});
});
}
function percentile(sortedValues: number[], fraction: number): number {
const index = Math.min(sortedValues.length - 1, Math.floor(sortedValues.length * fraction));
return sortedValues[index] ?? SILENCE_DB;
}
/**
* Turns mono PCM into 0..1 display heights. Each point is the RMS level of its slice in
* dB, measured against the clip's own noise floor (a low percentile of the slices), so
* constant background noise draws flat and sustained speech stands out. Peak sampling
* would instead follow music transients and lift the floor to nearly speech height.
*/
export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_COUNT): number[] {
const sampleCount = Math.floor(pcm.byteLength / 2);
if (sampleCount === 0 || pointCount <= 0) return [];
const resolvedPointCount = Math.min(pointCount, sampleCount);
const levelsDb = Array.from({ length: resolvedPointCount }, () => SILENCE_DB);
for (let point = 0; point < resolvedPointCount; point += 1) {
const sampleStart = Math.floor((point * sampleCount) / resolvedPointCount);
const sampleEnd = Math.max(
sampleStart + 1,
Math.floor(((point + 1) * sampleCount) / resolvedPointCount),
);
let energy = 0;
for (let sample = sampleStart; sample < sampleEnd; sample += 1) {
const value = pcm.readInt16LE(sample * 2) / 32_768;
energy += value * value;
}
const rms = Math.sqrt(energy / (sampleEnd - sampleStart));
levelsDb[point] = rms > 0 ? Math.max(SILENCE_DB, 20 * Math.log10(rms)) : SILENCE_DB;
}
const sortedLevels = [...levelsDb].sort((left, right) => left - right);
const floorDb = percentile(sortedLevels, NOISE_FLOOR_PERCENTILE) + NOISE_GATE_DB;
const referenceDb = Math.max(
percentile(sortedLevels, REFERENCE_PERCENTILE),
floorDb + MIN_DISPLAY_RANGE_DB,
);
return levelsDb.map(
(levelDb) =>
Math.round(Math.min(1, Math.max(0, (levelDb - floorDb) / (referenceDb - floorDb))) * 1_000) /
1_000,
);
}
function hasAudibleSamples(pcm: Buffer): boolean {
for (let offset = 0; offset + 1 < pcm.byteLength; offset += 2) {
if (Math.abs(pcm.readInt16LE(offset)) >= 164) return true;
}
return false;
}
export async function generateSpeechWaveform(
options: SpeechWaveformOptions,
execute: RunFfmpeg = runFfmpeg,
): Promise<number[]> {
try {
const centerPcm = await execute(buildSpeechWaveformArgs(options, 'center'));
if (hasAudibleSamples(centerPcm)) return computeWaveformPeaks(centerPcm);
} catch {
// Sources without a named center channel can reject the center-only filter.
}
const downmixPcm = await execute(buildSpeechWaveformArgs(options, 'downmix'));
return computeWaveformPeaks(downmixPcm);
}
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -37,6 +37,15 @@ export function handleOverlayWindowBeforeInputEvent(options: {
if (options.kind === 'modal') return false;
if (!options.windowVisible) return false;
// The renderer decides whether Copy targets selected sidebar text or the live cue.
if (
(options.input.control || options.input.meta) &&
!options.input.alt &&
!options.input.shift &&
(options.input.code === 'KeyC' || options.input.key.toLowerCase() === 'c')
)
return false;
if (isKeyboardModeToggleInput(options.input)) {
options.preventDefault();
options.sendKeyboardModeToggleRequested();
+29
View File
@@ -90,6 +90,35 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
assert.deepEqual(calls, []);
});
test('native Copy reaches the renderer before the current-subtitle fallback', () => {
for (const modifier of [{ control: true }, { meta: true }]) {
const handled = handleOverlayWindowBeforeInputEvent({
kind: 'visible',
windowVisible: true,
input: {
type: 'keyDown',
key: 'c',
code: 'KeyC',
isAutoRepeat: false,
isComposing: false,
shift: false,
control: false,
alt: false,
meta: false,
location: 0,
modifiers: [],
...modifier,
},
preventDefault: () => assert.fail('Copy must reach Chromium'),
sendKeyboardModeToggleRequested: () => assert.fail('Unexpected mode toggle'),
sendLookupWindowToggleRequested: () => assert.fail('Unexpected lookup toggle'),
tryHandleOverlayShortcutLocalFallback: () => assert.fail('Renderer owns Copy'),
forwardTabToMpv: () => assert.fail('Unexpected mpv input'),
});
assert.equal(handled, false);
}
});
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
const calls: string[] = [];
@@ -0,0 +1,253 @@
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 {
buildRemoteMediaWindowArgs,
RemoteMediaWindowCache,
REMOTE_MEDIA_WINDOW_MAX_SECONDS,
type RemoteMediaWindowCacheOptions,
} from './remote-media-window-cache';
const SOURCE = {
path: 'https://jellyfin.example/Videos/abc/stream?static=true',
audioStreamIndex: 2,
};
type ExecFileStub = NonNullable<RemoteMediaWindowCacheOptions['execFile']>;
function createStub(options: { fail?: boolean; empty?: boolean; defer?: boolean } = {}) {
const calls: string[][] = [];
const pendingCallbacks: Array<() => void> = [];
const execFile: ExecFileStub = (_file, args, _options, callback) => {
calls.push([...args]);
const finish = (): void => {
const outputPath = args.at(-1);
assert.ok(outputPath);
if (options.fail) {
callback(Object.assign(new Error('boom'), { code: 1 }));
return;
}
if (!options.empty) {
fs.writeFileSync(outputPath, 'mkv', 'utf8');
}
callback(null);
};
if (options.defer) {
pendingCallbacks.push(finish);
} else {
queueMicrotask(finish);
}
};
return {
calls,
execFile,
flush: () => {
for (const finish of pendingCallbacks.splice(0)) finish();
},
};
}
async function withCache(
stubOptions: Parameters<typeof createStub>[0],
cacheOptions: Omit<RemoteMediaWindowCacheOptions, 'execFile' | 'tempDir'>,
run: (cache: RemoteMediaWindowCache, stub: ReturnType<typeof createStub>) => Promise<void>,
): Promise<void> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-window-test-'));
const stub = createStub(stubOptions);
const cache = new RemoteMediaWindowCache({
tempDir,
execFile: stub.execFile,
idleTtlMs: 0,
logDebug: () => undefined,
...cacheOptions,
});
try {
await run(cache, stub);
} finally {
cache.cleanup();
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function argValue(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
return index === -1 ? undefined : args[index + 1];
}
test('buildRemoteMediaWindowArgs stream-copies the window with source timestamps intact', () => {
const args = buildRemoteMediaWindowArgs(
{ ...SOURCE, inputOptions: { reconnect: true, headers: { Referer: 'https://a.example/' } } },
{ startTime: 22.75, endTime: 33 },
'/tmp/window.mkv',
);
const inputIndex = args.indexOf('-i');
assert.equal(args[inputIndex + 1], SOURCE.path);
assert.ok(args.indexOf('-reconnect') < inputIndex);
assert.ok(args.indexOf('-headers') < inputIndex);
assert.equal(argValue(args, '-ss'), '22.75');
assert.equal(argValue(args, '-t'), '10.25');
assert.ok(args.indexOf('-t') < inputIndex);
assert.deepEqual(args.slice(args.indexOf('-map'), args.indexOf('-map') + 4), [
'-map',
'0:v:0?',
'-map',
'0:2',
]);
assert.equal(argValue(args, '-c'), 'copy');
assert.ok(args.includes('-copyts'));
assert.ok(args.includes('-start_at_zero'));
assert.equal(argValue(args, '-f'), 'matroska');
assert.equal(args.at(-1), '/tmp/window.mkv');
});
test('buildRemoteMediaWindowArgs keeps every audio stream when none is selected', () => {
const args = buildRemoteMediaWindowArgs(
{ path: SOURCE.path, audioStreamIndex: null },
{ startTime: 0, endTime: 5 },
'/tmp/window.mkv',
);
assert.equal(args[args.lastIndexOf('-map') + 1], '0:a');
});
test('acquire downloads once and reuses the window for covered ranges', async () => {
await withCache({}, {}, async (cache, stub) => {
const window = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
assert.equal(stub.calls.length, 1);
assert.equal(argValue(stub.calls[0]!, '-ss'), '9.75');
assert.equal(argValue(stub.calls[0]!, '-t'), '5.25');
assert.equal(window.startTime, 9.75);
assert.equal(window.endTime, 15);
assert.equal(window.audioStreamIndex, 2);
assert.ok(fs.existsSync(window.path));
assert.deepEqual(window.media, {
path: window.path,
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
});
assert.equal(await cache.acquire(SOURCE, { startTime: 11, endTime: 15 }), window);
assert.equal(await cache.lookup(SOURCE, { startTime: 12, endTime: 12 }), window);
assert.equal(
await cache.lookup(
{ path: SOURCE.path, audioStreamIndex: null },
{ startTime: 12, endTime: 13 },
),
window,
);
assert.equal(stub.calls.length, 1);
});
});
test('lookup never downloads and misses on other ranges, sources, or audio streams', async () => {
await withCache({}, {}, async (cache, stub) => {
assert.equal(await cache.lookup(SOURCE, { startTime: 10, endTime: 14 }), null);
assert.equal(stub.calls.length, 0);
await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
assert.equal(await cache.lookup(SOURCE, { startTime: 14, endTime: 16 }), null);
assert.equal(
await cache.lookup(
{ path: 'https://other.example/stream', audioStreamIndex: 2 },
{
startTime: 11,
endTime: 12,
},
),
null,
);
assert.equal(
await cache.lookup(
{ path: SOURCE.path, audioStreamIndex: 3 },
{ startTime: 11, endTime: 12 },
),
null,
);
assert.equal(stub.calls.length, 1);
});
});
test('acquire widens to the union of the old window and replaces the old file', async () => {
await withCache({}, {}, async (cache, stub) => {
const first = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
const second = await cache.acquire(SOURCE, { startTime: 8, endTime: 12 });
assert.equal(stub.calls.length, 2);
assert.equal(argValue(stub.calls[1]!, '-ss'), '7.75');
assert.equal(second.startTime, 7.75);
assert.equal(second.endTime, 15);
assert.notEqual(second.path, first.path);
assert.equal(fs.existsSync(first.path), false);
assert.ok(fs.existsSync(second.path));
assert.equal(cache.currentWindow, second);
});
});
test('acquire shares an in-flight download between concurrent callers', async () => {
await withCache({ defer: true }, {}, async (cache, stub) => {
const first = cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
await Promise.resolve();
const second = cache.acquire(SOURCE, { startTime: 11, endTime: 13 });
const lookup = cache.lookup(SOURCE, { startTime: 12, endTime: 12 });
await Promise.resolve();
assert.equal(stub.calls.length, 1);
stub.flush();
const [a, b, c] = await Promise.all([first, second, lookup]);
assert.equal(a, b);
assert.equal(a, c);
assert.equal(stub.calls.length, 1);
});
});
test('acquire rejects on ffmpeg failure, leaves no file, and can retry', async () => {
await withCache({ fail: true }, {}, async (cache, stub) => {
await assert.rejects(
cache.acquire(SOURCE, { startTime: 10, endTime: 14 }),
/FFmpeg media window failed: boom/,
);
assert.equal(cache.currentWindow, null);
assert.equal(await cache.lookup(SOURCE, { startTime: 10, endTime: 14 }), null);
await assert.rejects(cache.acquire(SOURCE, { startTime: 10, endTime: 14 }));
assert.equal(stub.calls.length, 2);
});
await withCache({ empty: true }, {}, async (cache) => {
await assert.rejects(
cache.acquire(SOURCE, { startTime: 10, endTime: 14 }),
/exited without creating a media window/,
);
});
});
test('acquire refuses invalid and oversized ranges without spawning ffmpeg', async () => {
await withCache({}, {}, async (cache, stub) => {
await assert.rejects(cache.acquire(SOURCE, { startTime: 10, endTime: 10 }), /invalid/);
await assert.rejects(cache.acquire(SOURCE, { startTime: -1, endTime: 10 }), /invalid/);
await assert.rejects(
cache.acquire(SOURCE, { startTime: 0, endTime: REMOTE_MEDIA_WINDOW_MAX_SECONDS + 1 }),
/too long/,
);
assert.equal(stub.calls.length, 0);
});
});
test('the window is deleted after the idle timeout and on cleanup', async () => {
await withCache({}, { idleTtlMs: 20 }, async (cache) => {
const window = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(cache.currentWindow, null);
assert.equal(fs.existsSync(window.path), false);
const again = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
cache.cleanup();
assert.equal(fs.existsSync(again.path), false);
assert.equal(fs.existsSync(path.dirname(again.path)), false);
});
});
@@ -0,0 +1,377 @@
import { execFile as nodeExecFile, type ExecFileException } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createLogger } from '../../logger';
import { normalizeMediaInput, type MediaInput, type MediaInputOptions } from '../../media-input';
const log = createLogger('media-window');
export const REMOTE_MEDIA_WINDOW_TIMEOUT_MS = 120_000;
export const REMOTE_MEDIA_WINDOW_MAX_SECONDS = 180;
const HEAD_SLACK_SECONDS = 0.25;
const TAIL_SLACK_SECONDS = 1;
const DEFAULT_IDLE_TTL_MS = 10 * 60_000;
const COVERAGE_EPSILON_SECONDS = 0.01;
export interface RemoteMediaWindowSource {
path: string;
inputOptions?: MediaInputOptions;
/** FFmpeg stream index to keep; `null`/undefined keeps every audio stream. */
audioStreamIndex?: number | null;
}
export interface RemoteMediaWindowRange {
startTime: number;
endTime: number;
}
export interface RemoteMediaWindow {
path: string;
startTime: number;
endTime: number;
sourcePath: string;
audioStreamIndex: number | null;
/** Input descriptor for FFmpeg reads; timestamps stay absolute so callers keep source times. */
media: MediaInput;
}
type WindowExecFile = (
file: string,
args: readonly string[],
options: { timeout: number },
callback: (error: ExecFileException | null) => void,
) => void;
export interface RemoteMediaWindowCacheOptions {
tempDir?: string;
execFile?: WindowExecFile;
idleTtlMs?: number;
logDebug?: (message: string) => void;
}
interface PendingFetch extends RemoteMediaWindowRange {
sourcePath: string;
audioStreamIndex: number | null;
promise: Promise<RemoteMediaWindow>;
}
export function isRemoteMediaWindowSourcePath(value: string): boolean {
return /^https?:\/\//i.test(value.trim());
}
function describeSourceForDebugLog(sourcePath: string): string {
try {
return `remote:${new URL(sourcePath).hostname.toLowerCase() || 'unknown'}`;
} catch {
return 'remote:unknown';
}
}
function isUsableRange(range: RemoteMediaWindowRange, allowEmpty: boolean): boolean {
return (
Number.isFinite(range.startTime) &&
Number.isFinite(range.endTime) &&
range.startTime >= 0 &&
(allowEmpty ? range.endTime >= range.startTime : range.endTime > range.startTime)
);
}
function audioStreamMatches(
windowIndex: number | null,
requested: number | null | undefined,
): boolean {
return requested == null || windowIndex === requested;
}
function covers(
candidate: RemoteMediaWindowRange & { sourcePath: string; audioStreamIndex: number | null },
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): boolean {
return (
candidate.sourcePath === source.path &&
audioStreamMatches(candidate.audioStreamIndex, source.audioStreamIndex) &&
candidate.startTime <= range.startTime + COVERAGE_EPSILON_SECONDS &&
candidate.endTime >= range.endTime - COVERAGE_EPSILON_SECONDS
);
}
/**
* Stream-copies `[startTime, endTime]` of a remote source into a local Matroska file.
* `-copyts -start_at_zero` keeps the source timestamps, so later reads seek with the
* original times via `-seek_timestamp 1` (see `MediaInput.absoluteTimestamps`).
*/
export function buildRemoteMediaWindowArgs(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
outputPath: string,
): string[] {
const input = normalizeMediaInput({ path: source.path, inputOptions: source.inputOptions });
const audioMap =
typeof source.audioStreamIndex === 'number' && Number.isInteger(source.audioStreamIndex)
? `0:${source.audioStreamIndex}`
: '0:a';
return [
'-hide_banner',
'-nostdin',
'-loglevel',
'error',
'-ss',
String(range.startTime),
'-t',
String(range.endTime - range.startTime),
...input.inputArgs,
'-i',
input.path,
'-map',
'0:v:0?',
'-map',
audioMap,
'-c',
'copy',
'-sn',
'-dn',
'-copyts',
'-start_at_zero',
'-f',
'matroska',
'-y',
outputPath,
];
}
/**
* Holds one downloaded window of the current remote stream so the timing review,
* audio extraction, and screenshot all read the same local bytes instead of each
* re-fetching the clip over HTTP. A new window replaces the old one; the file is
* deleted after `idleTtlMs` without use, on `clear()`, or on `cleanup()`.
*/
export class RemoteMediaWindowCache {
private readonly tempDir: string;
private readonly execFile: WindowExecFile;
private readonly idleTtlMs: number;
private readonly logDebug: (message: string) => void;
private current: RemoteMediaWindow | null = null;
private pending: PendingFetch | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private sequence = 0;
constructor(options: RemoteMediaWindowCacheOptions = {}) {
this.tempDir = options.tempDir ?? path.join(os.tmpdir(), 'subminer-media-windows');
this.execFile = options.execFile ?? nodeExecFile;
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
this.logDebug = options.logDebug ?? ((message) => log.debug(message));
}
get currentWindow(): RemoteMediaWindow | null {
return this.current;
}
/** Returns a ready or in-flight window covering the range; never starts a download. */
async lookup(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow | null> {
if (!isUsableRange(range, true)) return null;
if (this.current && covers(this.current, source, range)) {
this.touch();
return this.current;
}
const pending = this.pending;
if (pending && covers(pending, source, range)) {
try {
const window = await pending.promise;
this.touch();
return window;
} catch {
return null;
}
}
return null;
}
/** Returns a window covering the range, downloading (and widening) one when needed. */
async acquire(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow> {
if (!isUsableRange(range, false)) {
throw new Error('Media window range is invalid.');
}
if (range.endTime - range.startTime > REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
throw new Error('Media window range is too long to download.');
}
for (;;) {
const hit = await this.lookup(source, range);
if (hit) return hit;
const pending = this.pending;
if (!pending) break;
// Another caller is already downloading; wait for it, then re-check coverage.
await pending.promise.catch(() => null);
}
return this.fetch(source, this.planFetchRange(source, range));
}
clear(): void {
this.cancelIdleTimer();
const current = this.current;
this.current = null;
if (current) this.removeFile(current.path);
}
cleanup(): void {
this.clear();
try {
fs.rmSync(this.tempDir, { recursive: true, force: true });
} catch (error) {
log.error('Failed to cleanup media window directory:', error);
}
}
private planFetchRange(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): RemoteMediaWindowRange {
let startTime = Math.max(0, range.startTime - HEAD_SLACK_SECONDS);
let endTime = range.endTime + TAIL_SLACK_SECONDS;
const current = this.current;
if (
current &&
current.sourcePath === source.path &&
audioStreamMatches(current.audioStreamIndex, source.audioStreamIndex)
) {
// Keep what was already downloaded when the review timeline grows in one direction.
const unionStart = Math.min(startTime, current.startTime);
const unionEnd = Math.max(endTime, current.endTime);
if (unionEnd - unionStart <= REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
startTime = unionStart;
endTime = unionEnd;
}
}
return { startTime, endTime };
}
private fetch(
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow> {
fs.mkdirSync(this.tempDir, { recursive: true });
this.sequence += 1;
const outputPath = path.join(this.tempDir, `window_${Date.now()}_${this.sequence}.mkv`);
const audioStreamIndex =
typeof source.audioStreamIndex === 'number' ? source.audioStreamIndex : null;
const description = describeSourceForDebugLog(source.path);
const startedAt = Date.now();
this.logDebug(
`[media-window] fetch start ${description} start=${range.startTime} end=${range.endTime} audioStream=${audioStreamIndex ?? 'all'}`,
);
const promise = new Promise<RemoteMediaWindow>((resolve, reject) => {
this.execFile(
'ffmpeg',
buildRemoteMediaWindowArgs(source, range, outputPath),
{ timeout: REMOTE_MEDIA_WINDOW_TIMEOUT_MS },
(error) => {
const elapsedMs = Math.max(0, Date.now() - startedAt);
const size = error ? 0 : this.fileSize(outputPath);
if (error || size === 0) {
this.removeFile(outputPath);
const reason = error
? error.code === 'ENOENT'
? 'FFmpeg not found. Install FFmpeg to enable media generation.'
: `FFmpeg media window failed: ${error.message}`
: 'FFmpeg exited without creating a media window.';
this.logDebug(`[media-window] fetch failed ${description} elapsedMs=${elapsedMs}`);
reject(new Error(reason));
return;
}
const window: RemoteMediaWindow = {
path: outputPath,
startTime: range.startTime,
endTime: range.endTime,
sourcePath: source.path,
audioStreamIndex,
media: {
path: outputPath,
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
},
};
this.logDebug(
`[media-window] fetch complete ${description} elapsedMs=${elapsedMs} bytes=${size}`,
);
this.replaceCurrent(window);
resolve(window);
},
);
});
const pending: PendingFetch = {
sourcePath: source.path,
audioStreamIndex,
startTime: range.startTime,
endTime: range.endTime,
promise,
};
this.pending = pending;
promise
.catch(() => undefined)
.then(() => {
if (this.pending === pending) this.pending = null;
});
return promise;
}
private replaceCurrent(window: RemoteMediaWindow): void {
const previous = this.current;
this.current = window;
if (previous && previous.path !== window.path) this.removeFile(previous.path);
this.touch();
}
private touch(): void {
this.cancelIdleTimer();
if (this.idleTtlMs <= 0 || !this.current) return;
const timer = setTimeout(() => {
if (this.idleTimer === timer) this.idleTimer = null;
this.clear();
}, this.idleTtlMs);
timer.unref?.();
this.idleTimer = timer;
}
private cancelIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
}
private fileSize(filePath: string): number {
try {
return fs.statSync(filePath).size;
} catch {
return 0;
}
}
private removeFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
log.debug(`Failed to remove media window ${filePath}:`, (error as Error).message);
}
}
}
}
let sharedCache: RemoteMediaWindowCache | null = null;
/** Process-wide cache so the review modal and card media generation share one download. */
export function getSharedRemoteMediaWindowCache(): RemoteMediaWindowCache {
sharedCache ??= new RemoteMediaWindowCache();
return sharedCache;
}
@@ -41,6 +41,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openTsukihime: () => calls.push('tsukihime'),
openSubtitleGeneration: () => calls.push('subtitle-generation'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
},
@@ -88,3 +89,9 @@ test('dispatchSessionAction opens the character dictionary manager', async () =>
assert.deepEqual(calls, ['character-dictionary-manager']);
});
test('dispatchSessionAction opens subtitle generation without opening the sidebar', async () => {
const { calls, deps } = createDeps();
await dispatchSessionAction({ actionId: 'openSubtitleGeneration' }, deps);
assert.deepEqual(calls, ['subtitle-generation']);
});
+4
View File
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
openControllerDebug: () => void;
openJimaku: () => void;
openTsukihime: () => void;
openSubtitleGeneration: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
openAnimeBrowser: () => boolean | void | Promise<boolean | void>;
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
case 'openTsukihime':
deps.openTsukihime();
return;
case 'openSubtitleGeneration':
deps.openSubtitleGeneration();
return;
case 'openYoutubePicker':
await deps.openYoutubeTrackPicker();
return;
@@ -5,6 +5,7 @@ import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { resolveConfiguredShortcuts } from '../utils/shortcut-config';
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './session-bindings';
import { parseSessionActionDispatchRequest } from '../../shared/ipc/validators';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
@@ -23,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -37,6 +39,50 @@ function createKeybinding(key: string, command: Keybinding['command']): Keybindi
return { key, command };
}
test('subtitle generation shortcut compiles for overlay and mpv without conflicting with field grouping', () => {
for (const platform of ['linux', 'darwin', 'win32'] as const) {
const result = compileSessionBindings({
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
keybindings: DEFAULT_KEYBINDINGS,
platform,
});
const binding = result.bindings.find(
(entry) =>
entry.actionType === 'session-action' && entry.actionId === 'openSubtitleGeneration',
);
assert.ok(binding);
assert.deepEqual(binding.key, { code: 'KeyG', modifiers: ['ctrl', 'shift'] });
assert.equal(
result.warnings.some(
(warning) =>
warning.path === 'shortcuts.openSubtitleGeneration' ||
warning.conflictingPaths?.includes('shortcuts.openSubtitleGeneration'),
),
false,
);
assert.ok(
result.bindings.some(
(entry) =>
entry.actionType === 'session-action' && entry.actionId === 'triggerFieldGrouping',
),
);
const artifact = buildPluginSessionBindingsArtifact({
bindings: [binding],
warnings: [],
numericSelectionTimeoutMs: 3000,
});
const pluginBinding = artifact.bindings[0];
assert.ok(pluginBinding?.actionType === 'session-action');
assert.deepEqual(pluginBinding.cliArgs, [
'--session-action',
'{"actionId":"openSubtitleGeneration"}',
]);
assert.deepEqual(parseSessionActionDispatchRequest({ actionId: 'openSubtitleGeneration' }), {
actionId: 'openSubtitleGeneration',
});
}
});
test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
+5 -4
View File
@@ -56,6 +56,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openTsukihime', actionId: 'openTsukihime' },
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
@@ -211,7 +212,7 @@ function parseAccelerator(
};
}
function parseDomKeyString(
export function parseSessionBindingKey(
key: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
@@ -439,7 +440,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
if (statsToggleKey) {
const parsed = parseDomKeyString(statsToggleKey, input.platform);
const parsed = parseSessionBindingKey(statsToggleKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
@@ -466,7 +467,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
if (statsMarkWatchedKey) {
const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform);
const parsed = parseSessionBindingKey(statsMarkWatchedKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
@@ -494,7 +495,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
const parsed = parseSessionBindingKey(binding.key, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
@@ -54,6 +54,20 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
);
});
test('transfer cache keys are restricted to temp helpers and cannot contain paths', () => {
const key = 'a'.repeat(64);
for (const mode of [['--make-temp'], ['--remove-temp', '/tmp/subminer-sync-x']]) {
const parsed = parseSyncCliTokens(['sync', ...mode, '--transfer-cache', key]);
assert.equal(parsed.kind, 'run');
if (parsed.kind === 'run') assert.equal(parsed.args.syncTransferCacheKey, key);
assert.equal(
parseSyncCliTokens(['sync', ...mode, '--transfer-cache', '../../bad']).kind,
'error',
);
}
assert.equal(parseSyncCliTokens(['sync', 'host', '--transfer-cache', key]).kind, 'error');
});
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
assert.equal(parseSyncCliTokens([]).kind, 'error');
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
+12
View File
@@ -1,4 +1,5 @@
import type { SyncFlowArgs } from './sync-flow';
import { isTransferCacheKey } from './transfer-cache';
export const SYNC_CLI_FLAG = '--sync-cli';
@@ -43,6 +44,7 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
let json = false;
let makeTemp = false;
let removeTemp = '';
let transferCacheKey = '';
let remoteCmd = '';
let dbPath = '';
let logLevel = 'warn';
@@ -51,6 +53,7 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
['--snapshot', (value) => (snapshot = value.trim())],
['--merge', (value) => (merge = value.trim())],
['--remove-temp', (value) => (removeTemp = value.trim())],
['--transfer-cache', (value) => (transferCacheKey = value.trim())],
['--remote-cmd', (value) => (remoteCmd = value.trim())],
['--db', (value) => (dbPath = value.trim())],
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
@@ -93,6 +96,13 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
}
if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' };
if (transferCacheKey && (!isTransferCacheKey(transferCacheKey) || (!makeTemp && !removeTemp))) {
return {
kind: 'error',
message:
'--transfer-cache requires a 64-character lowercase hex key and --make-temp or --remove-temp.',
};
}
if ((push || pull) && !host) {
return { kind: 'error', message: 'Sync --push and --pull require a host.' };
}
@@ -137,6 +147,7 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
syncCheck: check,
syncMakeTemp: makeTemp,
syncRemoveTempPath: removeTemp,
syncTransferCacheKey: transferCacheKey,
logLevel,
},
};
@@ -161,6 +172,7 @@ export function syncCliUsage(): string {
' --check Test the SSH connection and remote SubMiner availability',
' --db <file> Override the local stats database path',
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
' --transfer-cache <key> Reuse/save a received snapshot with temp helpers (internal)',
' -f, --force Skip the running-app safety check',
' --json Emit machine-readable NDJSON progress output',
' --log-level <level> Log level',
@@ -66,7 +66,7 @@ function mergedOccurrences(dbPath: string): Array<{ word: string; seenMs: number
for (const legacyOccurrences of [false, true]) {
const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer';
test(`sync merge carries occurrence timestamps in from ${label}`, () => {
test(`sync merge carries occurrence timestamps in from ${label}`, { timeout: 15_000 }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-'));
try {
const local = buildDb(dir, 'local.sqlite', {
@@ -0,0 +1,238 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { createSnapshotTransfer, runRsync } from './snapshot-transfer';
import { createTransferCache, transferCacheKey } from './transfer-cache';
type TransferDeps = NonNullable<Parameters<typeof createSnapshotTransfer>[2]>;
function commandResult(status = 0, stderr = ''): ReturnType<TransferDeps['runRsync']> {
return { status, stderr, stdout: '', pid: 0, output: [null, '', stderr], signal: null };
}
function makeDeps(overrides: Partial<TransferDeps> = {}): TransferDeps {
return {
platform: 'linux',
runRsync: () => commandResult(),
runSsh: () => ({ status: 0, stdout: '', stderr: '' }),
runScp: () => assert.fail('Unexpected scp fallback'),
...overrides,
};
}
test('snapshot transfer falls back when rsync is unavailable or an endpoint is Windows', () => {
for (const scenario of ['local-missing', 'remote-missing', 'local-windows', 'remote-windows']) {
const copies: string[][] = [];
const transfer = createSnapshotTransfer(
'macbook',
scenario === 'remote-windows' ? 'windows-cmd' : 'posix',
makeDeps({
platform: scenario === 'local-windows' ? 'win32' : 'linux',
runRsync: () => commandResult(scenario === 'local-missing' ? 1 : 0),
runSsh: () => ({ status: scenario === 'remote-missing' ? 127 : 0, stdout: '', stderr: '' }),
runScp: (from, to) => copies.push([from, to]),
}),
);
assert.equal(transfer.kind, 'scp', scenario);
transfer.copy({
direction: 'download',
localPath: '/local.sqlite',
remotePath: '/remote.sqlite',
});
transfer.copy({
direction: 'upload',
localPath: '/local.sqlite',
remotePath: '/remote.sqlite',
});
assert.deepEqual(copies, [
['macbook:/remote.sqlite', '/local.sqlite'],
['/local.sqlite', 'macbook:/remote.sqlite'],
]);
}
});
test('failed rsync transfers report errors without silently retrying through scp', () => {
const transfer = createSnapshotTransfer(
'macbook',
'posix',
makeDeps({
runRsync: (args) => commandResult(args.includes('--version') ? 0 : 23, 'Permission denied'),
}),
);
assert.throws(
() =>
transfer.copy({
direction: 'upload',
localPath: '/local.sqlite',
remotePath: '/remote.sqlite',
}),
/rsync upload failed for macbook: Permission denied/,
);
assert.throws(() => createSnapshotTransfer('-oProxyCommand=bad', 'posix', makeDeps()), /option/);
});
const hasRsync = process.platform !== 'win32' && spawnSync('rsync', ['--version']).status === 0;
test(
'rsync forces SSH, preserves its environment, and fails on a process timeout',
{ skip: process.platform === 'win32' },
() => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-rsync-process-test-'));
const previousPath = process.env.PATH;
const previousRsh = process.env.RSYNC_RSH;
try {
process.env.PATH = `${dir}${path.delimiter}${previousPath ?? ''}`;
process.env.RSYNC_RSH = 'unexpected-transport';
const executable = path.join(dir, 'rsync');
fs.writeFileSync(
executable,
'#!/bin/sh\nprintf "%s\\n" "$@" "$RSYNC_RSH" "$RSYNC_OLD_ARGS"\n',
{ mode: 0o700 },
);
const result = runRsync(['--version']);
assert.equal(result.status, 0);
assert.deepEqual(result.stdout.trim().split('\n'), [
'--rsh=ssh',
'--version',
'unexpected-transport',
'1',
]);
fs.writeFileSync(executable, '#!/bin/sh\nexec /bin/sleep 5\n');
const transfer = createSnapshotTransfer(
'macbook',
'posix',
makeDeps({
runRsync: (args) => (args.includes('--version') ? commandResult() : runRsync(args, 50)),
}),
);
assert.throws(
() =>
transfer.copy({
direction: 'download',
localPath: '/local.sqlite',
remotePath: '/remote.sqlite',
}),
/rsync download timed out for macbook/,
);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
if (previousRsh === undefined) delete process.env.RSYNC_RSH;
else process.env.RSYNC_RSH = previousRsh;
fs.rmSync(dir, { recursive: true, force: true });
}
},
);
for (const direction of ['download', 'upload'] as const) {
test(
`rsync ${direction} reuses snapshot blocks and preserves the basis`,
{ skip: !hasRsync },
() => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-transfer-test-'));
try {
const localDir = path.join(dir, 'local');
const remoteDir = path.join(dir, "remote space ' $(false)");
fs.mkdirSync(localDir);
fs.mkdirSync(remoteDir);
// Emulate SSH's remote shell with real rsync processes, without sshd.
const remoteShell = path.join(dir, 'remote-shell');
fs.writeFileSync(remoteShell, '#!/bin/sh\nshift\nexec /bin/sh -c "$*"\n', { mode: 0o700 });
const localPath = path.join(
localDir,
direction === 'download' ? 'incoming' : '',
'snapshot.sqlite',
);
const remotePath = path.join(
remoteDir,
direction === 'upload' ? 'incoming' : '',
'snapshot.sqlite',
);
const source = direction === 'download' ? remotePath : localPath;
const destination = direction === 'download' ? localPath : remotePath;
const basis = path.join(path.dirname(destination), '..', 'snapshot.sqlite');
// Incompressible data ensures savings come from matching blocks.
const original = randomBytes(4 * 1024 * 1024);
fs.writeFileSync(basis, original);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, original);
const updated = Buffer.from(original);
updated.fill(42, 65536, 69632);
fs.writeFileSync(source, updated);
let stats = '';
const transfer = createSnapshotTransfer(
'test-peer',
'posix',
makeDeps({
runRsync: (args) => {
const result = spawnSync(
'rsync',
[
`--rsh=${remoteShell}`,
...args.map((arg) => (arg === '--quiet' ? '--stats' : arg)),
],
{ encoding: 'utf8', env: { ...process.env, RSYNC_OLD_ARGS: '1', LC_ALL: 'C' } },
);
stats = result.stdout;
return result;
},
}),
);
assert.equal(transfer.kind, 'rsync');
transfer.copy({ direction, localPath, remotePath });
assert.deepEqual(fs.readFileSync(destination), updated);
assert.deepEqual(fs.readFileSync(basis), original);
const matched = /Matched data: ([\d,]+) (?:bytes|B)/.exec(stats)?.[1];
assert.ok(matched, stats);
assert.ok(Number(matched.replaceAll(',', '')) > original.length * 0.95, stats);
const coldDir = path.join(dir, 'cold');
fs.mkdirSync(coldDir);
const coldDestination = path.join(coldDir, 'incoming', 'snapshot.sqlite');
transfer.copy({
direction,
localPath: direction === 'download' ? coldDestination : localPath,
remotePath: direction === 'upload' ? coldDestination : remotePath,
});
assert.deepEqual(fs.readFileSync(coldDestination), updated);
// A later sync starts in a new directory and reuses the prior peer's
// received file even when the source has grown since that transfer.
const cache = createTransferCache(path.join(dir, 'cache'));
const key = transferCacheKey('peer');
cache.remember(key, direction === 'download' ? localDir : remoteDir);
const nextDir = path.join(dir, 'next');
cache.seed(key, nextDir);
const grown = Buffer.concat([updated, randomBytes(4096)]);
fs.writeFileSync(source, grown);
const nextDestination = path.join(nextDir, 'incoming', 'snapshot.sqlite');
transfer.copy({
direction,
localPath: direction === 'download' ? nextDestination : localPath,
remotePath: direction === 'upload' ? nextDestination : remotePath,
});
assert.deepEqual(fs.readFileSync(nextDestination), grown);
const cachedMatches = /Matched data: ([\d,]+) (?:bytes|B)/.exec(stats)?.[1];
assert.ok(cachedMatches, stats);
assert.ok(Number(cachedMatches.replaceAll(',', '')) > updated.length * 0.95, stats);
// A retry must replace stale content even if size and mtime agree.
updated.fill(43, 131072, 135168);
fs.writeFileSync(source, updated);
const timestamp = new Date(1_700_000_000_000);
fs.utimesSync(source, timestamp, timestamp);
fs.utimesSync(destination, timestamp, timestamp);
transfer.copy({ direction, localPath, remotePath });
assert.deepEqual(fs.readFileSync(destination), updated);
assert.deepEqual(fs.readFileSync(basis), original);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
},
);
}
@@ -0,0 +1,85 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { assertSafeSshHost, runScp, runSsh, shellQuote, type RemoteShellFlavor } from './ssh';
const RSYNC_OPTIONS = ['--compress', '--checksum'];
export function runRsync(args: string[], timeoutMs = 30 * 60_000) {
return spawnSync('rsync', ['--rsh=ssh', ...args], {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'],
timeout: timeoutMs,
killSignal: 'SIGKILL',
// Quote remote paths ourselves for both modern rsync and macOS openrsync.
env: { ...process.env, RSYNC_OLD_ARGS: '1' },
});
}
interface TransferDeps {
platform: NodeJS.Platform;
runRsync: typeof runRsync;
runSsh: typeof runSsh;
runScp: typeof runScp;
}
export interface SnapshotTransfer {
kind: 'rsync' | 'scp';
copy: (request: {
direction: 'download' | 'upload';
localPath: string;
remotePath: string;
}) => void;
}
/**
* For rsync, both paths name snapshot.sqlite, with the destination inside an
* incoming/ directory seeded from the transfer cache. rsync creates incoming/
* when there is no cached basis and verifies the reconstructed file.
* Missing rsync and Windows endpoints use compressed scp with ordinary paths.
*/
export function createSnapshotTransfer(
host: string,
flavor: RemoteShellFlavor,
deps: TransferDeps = { platform: process.platform, runRsync, runSsh, runScp },
): SnapshotTransfer {
assertSafeSshHost(host);
const canUseRsync =
deps.platform !== 'win32' &&
flavor === 'posix' &&
deps.runRsync([...RSYNC_OPTIONS, '--version']).status === 0 &&
deps.runSsh(host, `rsync ${RSYNC_OPTIONS.join(' ')} --version`, {
batchMode: true,
connectTimeoutSeconds: 10,
timeoutMs: 15_000,
}).status === 0;
return {
kind: canUseRsync ? 'rsync' : 'scp',
copy: ({ direction, localPath, remotePath }) => {
// A directory destination lets rsync create incoming/ on either end,
// including peers running older SubMiner versions.
const local =
canUseRsync && direction === 'download' ? `${path.dirname(localPath)}/` : localPath;
const remoteTarget =
canUseRsync && direction === 'upload' ? `${path.posix.dirname(remotePath)}/` : remotePath;
const remote = `${host}:${canUseRsync ? shellQuote(remoteTarget) : remoteTarget}`;
const [from, to] =
direction === 'download' ? ([remote, local] as const) : ([local, remote] as const);
if (!canUseRsync) {
deps.runScp(from, to);
return;
}
// --checksum prevents a same-size, same-mtime snapshot being skipped.
// Without --inplace, rsync replaces the staged basis only after the
// reconstructed file passes its transfer checksum.
const result = deps.runRsync([...RSYNC_OPTIONS, '--quiet', '--', from, to]);
if (result.error && 'code' in result.error && result.error.code === 'ETIMEDOUT') {
throw new Error(`rsync ${direction} timed out for ${host}`);
}
if (result.error) throw new Error(`Failed to run rsync: ${result.error.message}`);
if (result.status !== 0) {
throw new Error(`rsync ${direction} failed for ${host}: ${result.stderr.trim()}`);
}
},
};
}
+1 -1
View File
@@ -74,7 +74,7 @@ function assertSafeScpEndpoint(endpoint: string): void {
export function runScp(from: string, to: string): void {
assertSafeScpEndpoint(from);
assertSafeScpEndpoint(to);
const result = spawnSync('scp', ['-q', from, to], {
const result = spawnSync('scp', ['-C', '-q', from, to], {
encoding: 'utf8',
stdio: ['inherit', 'inherit', 'inherit'],
});
+169 -7
View File
@@ -25,6 +25,7 @@ function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlow
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncTransferCacheKey: '',
logLevel: 'warn',
...overrides,
},
@@ -46,7 +47,8 @@ function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'posix',
resolveRemoteSubminerCommand: () => 'subminer',
runScp: () => {},
createSnapshotTransfer: () => ({ kind: 'scp', copy: () => {} }),
transferCache: { seed: () => {}, remember: () => {} },
runSsh: () => ok(),
canConnectUnixSocket: async () => false,
realpathSync: (candidate) => candidate,
@@ -116,9 +118,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
calls.push(`ssh:${command}`);
return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok();
},
runScp: (from, to) => {
createSnapshotTransfer: scpTransfer((from, to) => {
calls.push(`scp:${from}->${to}`);
},
}),
});
await runSyncFlow(
@@ -146,6 +148,19 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a
);
});
function scpTransfer(
copy: (from: string, to: string) => void,
): SyncFlowDeps['createSnapshotTransfer'] {
return (host) => ({
kind: 'scp',
copy: ({ direction, localPath, remotePath }) => {
const remote = `${host}:${remotePath}`;
if (direction === 'download') copy(remote, localPath);
else copy(localPath, remote);
},
});
}
function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return makeDeps({
createDbSnapshot: (_dbPath, outPath) => {
@@ -164,10 +179,10 @@ function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): S
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
return ok();
},
runScp: (from, to) => {
createSnapshotTransfer: scpTransfer((from, to) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
}),
...overrides,
});
}
@@ -248,6 +263,153 @@ test('runHostSync pull only snapshots remotely and merges locally', async () =>
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
for (const direction of ['push', 'pull', 'both'] as const) {
test(`runHostSync ${direction} snapshots sources and maintains receiver caches`, async () => {
const calls: string[] = [];
const copies: string[] = [];
const cacheCalls: string[] = [];
await runSyncFlow(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: direction,
}),
makeHostDeps(calls, {
transferCache: {
seed: (key) => cacheCalls.push(`seed:${key}`),
remember: (key) => cacheCalls.push(`remember:${key}`),
},
createSnapshotTransfer: () => ({
kind: 'rsync',
copy: ({ direction: copyDirection, localPath, remotePath }) => {
assert.equal(
calls.some((call) => call.startsWith('snapshot:')),
direction !== 'pull',
);
assert.equal(
calls.some((call) => call.includes(' sync --snapshot ')),
direction !== 'push',
);
if (copyDirection === 'upload')
assert.equal(fs.readFileSync(localPath, 'utf8'), 'snapshot');
assert.equal(path.posix.basename(remotePath), 'snapshot.sqlite');
assert.equal(
path.posix.basename(path.posix.dirname(remotePath)),
copyDirection === 'upload' ? 'incoming' : 'subminer-sync-remote',
);
copies.push(copyDirection);
},
}),
}),
);
assert.deepEqual(
copies,
direction === 'both'
? ['download', 'upload']
: direction === 'pull'
? ['download']
: ['upload'],
);
assert.equal(calls.includes('local-merge'), direction !== 'push');
assert.equal(cacheCalls.length, direction === 'push' ? 0 : 2);
if (cacheCalls.length) assert.equal(cacheCalls[0]?.slice(5), cacheCalls[1]?.slice(9));
const remoteCacheCalls = calls.filter((call) => call.includes('--transfer-cache'));
assert.equal(remoteCacheCalls.length, direction === 'pull' ? 0 : 2);
if (remoteCacheCalls.length) {
assert.ok(remoteCacheCalls[0]?.includes('--make-temp'));
assert.ok(remoteCacheCalls[1]?.includes('--remove-temp'));
assert.equal(
remoteCacheCalls[0]?.split('--transfer-cache ')[1],
remoteCacheCalls[1]?.split('--transfer-cache ')[1],
);
}
assert.equal(
calls.some((call) => call.includes(' sync --merge ')),
direction !== 'pull',
);
});
}
test('runHostSync does not merge an incomplete transfer and removes its temp files', async () => {
const calls: string[] = [];
let localTmpDir = '';
await assert.rejects(
() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps(calls, {
transferCache: {
seed: () => {},
remember: () => assert.fail('Failed sync must not update cache'),
},
mkdtempSync: (prefix) => {
localTmpDir = fs.mkdtempSync(prefix);
return localTmpDir;
},
createSnapshotTransfer: () => ({
kind: 'rsync',
copy: () => {
throw new Error('connection lost');
},
}),
}),
),
/connection lost/,
);
assert.ok(!calls.includes('local-merge'));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.includes(' sync --remove-temp ')));
assert.equal(fs.existsSync(localTmpDir), false);
assert.ok(
!calls.some((call) => call.includes('--remove-temp') && call.includes('--transfer-cache')),
);
});
for (const stderr of [
'Unknown sync option: --transfer-cache',
"error: unknown option '--transfer-cache'\n\nUsage: subminer sync [options] [host]",
]) {
test(`runHostSync falls back when the peer reports ${stderr.split('\n')[0]}`, async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps(calls, {
createSnapshotTransfer: () => ({ kind: 'rsync', copy: () => {} }),
runSsh: (_host, command) => {
calls.push(command);
if (command.includes('--transfer-cache')) return { status: 2, stdout: '', stderr };
return command.includes('--make-temp') ? ok('/tmp/subminer-sync-remote') : ok();
},
}),
);
assert.equal(calls.filter((call) => call.includes('--make-temp')).length, 2);
assert.ok(
calls.some((call) => call.includes('--remove-temp') && !call.includes('--transfer-cache')),
);
});
}
test('runHostSync does not retry unrelated remote temp failures', async () => {
const calls: string[] = [];
await assert.rejects(
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps(calls, {
createSnapshotTransfer: () => ({
kind: 'rsync',
copy: () => assert.fail('Must not transfer'),
}),
runSsh: (_host, command) => {
calls.push(command);
return { status: 1, stdout: '', stderr: 'Permission denied' };
},
}),
),
/Could not create a temporary directory on media-box.*\nPermission denied/,
);
assert.equal(calls.filter((call) => call.includes('--make-temp')).length, 1);
});
test('runSyncFlow --json emits NDJSON progress events and a final result', async () => {
const lines: string[] = [];
const remoteSummary = {
@@ -436,10 +598,10 @@ test('runHostSync speaks Windows shells: app command, double quotes, temp protoc
if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`);
return ok();
},
runScp: (from, to) => {
createSnapshotTransfer: scpTransfer((from, to) => {
scpCalls.push(`${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
}),
});
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }), deps);
+62 -13
View File
@@ -3,6 +3,8 @@ import path from 'node:path';
import { formatMergeSummary } from './merge';
import { quoteForRemoteShell } from './ssh';
import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
import type { createSnapshotTransfer } from './snapshot-transfer';
import { transferCacheKey, type createTransferCache } from './transfer-cache';
import {
parseSyncProgressLine,
type SyncMergeSummary,
@@ -22,6 +24,7 @@ export interface SyncFlowArgs {
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string;
syncTransferCacheKey: string;
logLevel: string;
}
@@ -31,7 +34,7 @@ export interface SyncFlowContext {
}
/**
* Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB
* Process/IO seams the sync flow needs stubbed in tests: SSH/transfers, the DB
* snapshot/merge engine, filesystem, and progress/bookkeeping output. The
* app's --sync-cli mode (src/main/sync-cli.ts) provides the only production
* binding; pure helpers are imported directly.
@@ -51,7 +54,8 @@ export interface SyncFlowDeps {
flavor: RemoteShellFlavor,
runRemote?: (host: string, remoteCommand: string) => RemoteRunResult,
) => string;
runScp: (from: string, to: string) => void;
createSnapshotTransfer: typeof createSnapshotTransfer;
transferCache: ReturnType<typeof createTransferCache>;
runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
realpathSync: (candidate: string) => string;
@@ -95,12 +99,17 @@ function assertRemovableSyncTempDir(target: string): string {
return resolved;
}
function runMakeTempMode(deps: SyncFlowDeps): void {
deps.consoleLog(makeSyncTempDir(deps.mkdtempSync));
function runMakeTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
const dir = makeSyncTempDir(deps.mkdtempSync);
if (context.args.syncTransferCacheKey)
deps.transferCache.seed(context.args.syncTransferCacheKey, dir);
deps.consoleLog(dir);
}
function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath);
if (context.args.syncTransferCacheKey)
deps.transferCache.remember(context.args.syncTransferCacheKey, target);
deps.rmSync(target, { recursive: true, force: true });
}
@@ -260,9 +269,10 @@ function cleanupRemote(
remoteTmpDir: string,
quote: (value: string) => string,
deps: SyncFlowDeps,
cacheFlag = '',
): void {
if (!path.posix.basename(remoteTmpDir).startsWith(SYNC_TEMP_PREFIX)) return;
deps.runSsh(host, `${remoteCmd} sync --remove-temp ${quote(remoteTmpDir)}`);
deps.runSsh(host, `${remoteCmd} sync --remove-temp ${quote(remoteTmpDir)}${cacheFlag}`);
}
/**
@@ -309,19 +319,37 @@ export async function runHostSync(
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
const transfer = deps.createSnapshotTransfer(host, flavor);
const quote = (value: string) => quoteForRemoteShell(flavor, value);
if (args.logLevel === 'debug') {
console.error(`Remote subminer command (${flavor}): ${remoteCmd}`);
}
const localTmpDir = makeSyncTempDir(deps.mkdtempSync);
const localCacheKey = transferCacheKey(`download\0${dbPath}\0${host}`);
const remoteCacheKey = transferCacheKey(`upload\0${os.hostname()}\0${dbPath}`);
let remoteCacheFlag = transfer.kind === 'rsync' ? ` --transfer-cache ${remoteCacheKey}` : '';
let syncSucceeded = false;
let remoteTmpDir = '';
let pulledSummary: SyncMergeSummary | null = null;
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
// main().catch() reports the message the same way fail() would.
const mktemp = deps.runSsh(host, `${remoteCmd} sync --make-temp`);
if (transfer.kind === 'rsync' && shouldPull)
deps.transferCache.seed(localCacheKey, localTmpDir);
let mktemp = deps.runSsh(
host,
`${remoteCmd} sync --make-temp${shouldPush ? remoteCacheFlag : ''}`,
);
if (
mktemp.status !== 0 &&
(mktemp.stderr.includes('Unknown sync option: --transfer-cache') ||
mktemp.stderr.includes("error: unknown option '--transfer-cache'"))
) {
remoteCacheFlag = '';
mktemp = deps.runSsh(host, `${remoteCmd} sync --make-temp`);
}
remoteTmpDir = mktemp.status === 0 ? parseRemoteTempDir(mktemp.stdout) : '';
if (!remoteTmpDir) {
throw new Error(
@@ -331,7 +359,7 @@ export async function runHostSync(
const forceFlag = args.syncForce ? ' --force' : '';
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
const localSnapshot = path.join(localTmpDir, 'snapshot.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.emitEvent({
@@ -355,19 +383,30 @@ export async function runHostSync(
}
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
const pulledSnapshot = path.join(
localTmpDir,
transfer.kind === 'rsync' ? 'incoming/snapshot.sqlite' : 'remote.sqlite',
);
if (shouldPull) {
deps.emitEvent({
type: 'stage',
stage: 'download',
message: `Copying snapshot from ${host}`,
});
deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
transfer.copy({
direction: 'download',
remotePath: remoteSnapshot,
localPath: pulledSnapshot,
});
}
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
const incomingSnapshot = `${remoteTmpDir}/${transfer.kind === 'rsync' ? 'incoming/snapshot.sqlite' : 'incoming.sqlite'}`;
if (shouldPush) {
deps.emitEvent({ type: 'stage', stage: 'upload', message: `Copying snapshot to ${host}` });
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
transfer.copy({
direction: 'upload',
localPath: localSnapshot,
remotePath: incomingSnapshot,
});
}
if (shouldPull) {
@@ -416,6 +455,7 @@ export async function runHostSync(
}
}
syncSucceeded = true;
deps.consoleLog('\nSync complete.');
deps.recordHostSyncResult(host, 'success', formatHostSyncDetail(direction, pulledSummary));
} catch (error) {
@@ -430,10 +470,19 @@ export async function runHostSync(
}
throw error;
} finally {
if (syncSucceeded && transfer.kind === 'rsync' && shouldPull)
deps.transferCache.remember(localCacheKey, localTmpDir);
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteCmd, remoteTmpDir, quote, deps);
cleanupRemote(
host,
remoteCmd,
remoteTmpDir,
quote,
deps,
syncSucceeded && shouldPush ? remoteCacheFlag : '',
);
} catch {
// best effort
}
@@ -451,7 +500,7 @@ export async function runSyncFlow(
try {
if (args.syncMakeTemp) {
runMakeTempMode(deps);
runMakeTempMode(context, deps);
} else if (args.syncRemoveTempPath) {
runRemoveTempMode(context, deps);
} else {
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createTransferCache, transferCacheKey } from './transfer-cache';
test('transfer cache isolates peers and active transfers while replacing previous snapshots', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cache-test-'));
try {
const cacheDir = path.join(root, 'cache');
const cache = createTransferCache(cacheDir);
const key = transferCacheKey('peer');
const first = path.join(root, 'first');
fs.mkdirSync(path.join(first, 'incoming'), { recursive: true });
const incoming = path.join(first, 'incoming', 'snapshot.sqlite');
fs.writeFileSync(incoming, 'first received snapshot');
cache.remember(key, first);
const second = path.join(root, 'second');
cache.seed(key, second);
fs.writeFileSync(incoming, 'next received snapshot');
cache.remember(key, first);
assert.equal(
fs.readFileSync(path.join(second, 'incoming', 'snapshot.sqlite'), 'utf8'),
'first received snapshot',
);
const third = path.join(root, 'third');
cache.seed(key, third);
assert.equal(
fs.readFileSync(path.join(third, 'incoming', 'snapshot.sqlite'), 'utf8'),
'next received snapshot',
);
assert.deepEqual(fs.readdirSync(cacheDir), [`${key}.sqlite`]);
const other = path.join(root, 'other');
cache.seed(transferCacheKey('other peer'), other);
assert.equal(fs.existsSync(path.join(other, 'incoming', 'snapshot.sqlite')), false);
assert.throws(() => cache.seed('../outside', other), /Invalid/);
assert.throws(() => cache.remember('../outside', first), /Invalid/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('unavailable cache storage and missing incoming snapshots do not prevent sync', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cache-test-'));
try {
const unavailable = path.join(root, 'file');
fs.writeFileSync(unavailable, 'not a directory');
const cache = createTransferCache(unavailable);
const key = transferCacheKey('peer');
const temp = path.join(root, 'transfer');
assert.doesNotThrow(() => cache.seed(key, temp));
assert.doesNotThrow(() => cache.remember(key, temp));
fs.writeFileSync(path.join(temp, 'incoming', 'snapshot.sqlite'), 'received');
assert.doesNotThrow(() => cache.remember(key, temp));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,61 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { getDefaultConfigDir } from '../../../shared/setup-state';
export function transferCacheKey(peer: string): string {
return createHash('sha256').update(peer).digest('hex');
}
export function isTransferCacheKey(value: string): boolean {
return /^[a-f0-9]{64}$/.test(value);
}
/**
* Keep one previously received snapshot per peer as an rsync basis. Copies
* isolate active transfers from concurrent cache replacements. A missing or
* unusable cache only costs bandwidth; it must never prevent a sync.
*/
export function createTransferCache(
directory = path.join(getDefaultConfigDir(), 'sync-transfer-cache'),
) {
function cachePath(key: string): string {
if (!isTransferCacheKey(key)) throw new Error('Invalid sync transfer cache key');
return path.join(directory, `${key}.sqlite`);
}
return {
seed(key: string, tempDir: string): void {
const source = cachePath(key);
const incoming = path.join(tempDir, 'incoming', 'snapshot.sqlite');
try {
fs.mkdirSync(path.dirname(incoming), { recursive: true, mode: 0o700 });
fs.copyFileSync(source, incoming, fs.constants.COPYFILE_FICLONE);
} catch {
// A cold transfer sends a complete compressed snapshot.
}
},
remember(key: string, tempDir: string): void {
const target = cachePath(key);
const incoming = path.join(tempDir, 'incoming', 'snapshot.sqlite');
let staging = '';
try {
if (!fs.existsSync(incoming)) return;
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
staging = fs.mkdtempSync(path.join(directory, '.write-'));
const snapshot = path.join(staging, 'snapshot.sqlite');
fs.copyFileSync(incoming, snapshot, fs.constants.COPYFILE_FICLONE);
fs.renameSync(snapshot, target);
} catch {
// An older basis is still valid. Never publish a partially copied file.
} finally {
try {
if (staging) fs.rmSync(staging, { recursive: true, force: true });
} catch {
// Cache cleanup is optional too.
}
}
},
};
}
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
test('long coverage cuts at nearby speech starts instead of leaving a quiet lead-in', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 544.418, endSeconds: 581.581 }],
[563.928],
[555.07, 567.23, 579.91],
);
assert.deepEqual(chunks, [
{ startSeconds: 544.418, endSeconds: 567.48 },
{ startSeconds: 566.98, endSeconds: 581.581 },
]);
});
test('short audible passages stay intact even with several detected speech starts', () => {
assert.deepEqual(
splitSpeechPassages(
[{ startSeconds: 876.897, endSeconds: 897.812 }],
[],
[876.9, 881.15, 893.95, 897.99],
),
[{ startSeconds: 876.897, endSeconds: 897.812 }],
);
});
test('speech anchors outside retained coverage cannot extend a chunk across a silent gap', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 100, endSeconds: 142 }],
[118],
[90, 142, 144],
);
assert.deepEqual(chunks, [
{ startSeconds: 100, endSeconds: 118.25 },
{ startSeconds: 117.75, endSeconds: 138.25 },
{ startSeconds: 137.75, endSeconds: 142 },
]);
});
test('long speech splits near a pause with context on both sides and no lost audio', () => {
assert.deepEqual(splitSpeechPassages([{ startSeconds: 100, endSeconds: 145 }], [105, 118, 137]), [
{ startSeconds: 100, endSeconds: 118.25 },
{ startSeconds: 117.75, endSeconds: 137.25 },
{ startSeconds: 136.75, endSeconds: 145 },
]);
});
test('uninterrupted speech retains overlapping context without crossing omitted gaps', () => {
const chunks = splitSpeechPassages([
{ startSeconds: 0, endSeconds: 60 },
{ startSeconds: 100, endSeconds: 100.15 },
]);
assert.deepEqual(chunks, [
{ startSeconds: 0, endSeconds: 20.25 },
{ startSeconds: 19.75, endSeconds: 40.25 },
{ startSeconds: 39.75, endSeconds: 60 },
{ startSeconds: 100, endSeconds: 100.15 },
]);
});
test('speech fitting one Whisper window stays intact instead of cutting a sentence at 20 seconds', () => {
const passage = { startSeconds: 251.71, endSeconds: 275.01 };
assert.deepEqual(splitSpeechPassages([passage], [271.327]), [passage]);
});
test('chunk stitching ignores punctuation differences without merging separate repetitions', () => {
const cues = [{ startTime: 19.7, endTime: 21.2, text: 'ありがとう' }];
appendSpeechChunkCues(cues, [
{ startTime: 19.8, endTime: 21.3, text: 'ありがとう。' },
{ startTime: 22, endTime: 23, text: 'ありがとう!' },
]);
assert.deepEqual(cues, [
{ startTime: 19.7, endTime: 21.3, text: 'ありがとう' },
{ startTime: 22, endTime: 23, text: 'ありがとう!' },
]);
});
test('chunk stitching removes matching overlap cues but retains repeated dialogue', () => {
const cues = [{ startTime: 19.7, endTime: 20.2, text: 'はい' }];
appendSpeechChunkCues(cues, [
{ startTime: 19.8, endTime: 20.3, text: 'はい' },
{ startTime: 21, endTime: 21.5, text: 'はい' },
{ startTime: 21.4, endTime: 22, text: 'はい' },
]);
assert.deepEqual(cues, [
{ startTime: 19.7, endTime: 20.3, text: 'はい' },
{ startTime: 21, endTime: 21.5, text: 'はい' },
{ startTime: 21.4, endTime: 22, text: 'はい' },
]);
});
test('chunk stitching matches repeated text to the greatest overlap without leaving a duplicate', () => {
const cues = [
{ startTime: 10, endTime: 14, text: 'はい' },
{ startTime: 13, endTime: 20, text: 'はい' },
];
appendSpeechChunkCues(cues, [
{ startTime: 12, endTime: 21, text: 'はい' },
{ startTime: 22, endTime: 23, text: 'はい' },
]);
assert.deepEqual(cues, [
{ startTime: 10, endTime: 14, text: 'はい' },
{ startTime: 12, endTime: 21, text: 'はい' },
{ startTime: 22, endTime: 23, text: 'はい' },
]);
});
@@ -0,0 +1,87 @@
import type { SubtitleCue } from './subtitle-cue-parser';
import { SPEECH_PASSAGE_SECONDS, type SpeechPassage } from './subtitle-generation-speech';
const CHUNK_CONTEXT_SECONDS = 0.25;
const WHISPER_WINDOW_SECONDS = 30;
const PAUSE_SEARCH_SECONDS = 5;
// Prefer detected speech starts, then quiet pauses. Context stays inside retained audio.
export function splitSpeechPassages(
passages: readonly SpeechPassage[],
pauses: readonly number[] = [],
speechStarts: readonly number[] = [],
): SpeechPassage[] {
return passages.flatMap((passage) => {
if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS)
return [{ ...passage }];
const chunks: SpeechPassage[] = [];
let boundary = passage.startSeconds;
while (boundary < passage.endSeconds) {
const target = boundary + SPEECH_PASSAGE_SECONDS;
let end = Math.min(target, passage.endSeconds);
if (target < passage.endSeconds) {
// Starting in a long quiet lead-in can make Whisper place the next line
// several seconds early. A nearby VAD start gives the next chunk an anchor.
let nearestSpeechStart: number | undefined;
for (const time of speechStarts) {
if (
time >= target - PAUSE_SEARCH_SECONDS &&
time <= target + PAUSE_SEARCH_SECONDS &&
time < passage.endSeconds &&
(nearestSpeechStart === undefined ||
Math.abs(time - target) < Math.abs(nearestSpeechStart - target))
)
nearestSpeechStart = time;
}
let latestPause: number | undefined;
for (const time of pauses) {
if (
time >= target - PAUSE_SEARCH_SECONDS &&
time <= target &&
(latestPause === undefined || time > latestPause)
)
latestPause = time;
}
end = nearestSpeechStart ?? latestPause ?? end;
}
chunks.push({
startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS),
endSeconds: Math.min(passage.endSeconds, end + CHUNK_CONTEXT_SECONDS),
});
boundary = end;
}
return chunks;
});
}
// Deduplicate only matching text substantially overlapping cues from earlier chunks.
// Repeated words within the current chunk or at separate times remain separate.
export function appendSpeechChunkCues(cues: SubtitleCue[], incoming: readonly SubtitleCue[]): void {
const previousCount = cues.length;
const matched = new Set<SubtitleCue>();
for (const cue of incoming) {
const text = cue.text.replace(/[\s\p{P}]+/gu, '');
let duplicate: SubtitleCue | undefined;
let greatestOverlap = 0;
for (const [index, previous] of cues.entries()) {
if (index >= previousCount) break;
if (!text || matched.has(previous) || previous.text.replace(/[\s\p{P}]+/gu, '') !== text)
continue;
const overlap =
Math.min(previous.endTime, cue.endTime) - Math.max(previous.startTime, cue.startTime);
const shorterDuration = Math.min(
previous.endTime - previous.startTime,
cue.endTime - cue.startTime,
);
if (overlap > greatestOverlap && overlap >= shorterDuration / 2) {
duplicate = previous;
greatestOverlap = overlap;
}
}
if (duplicate) {
duplicate.startTime = Math.min(duplicate.startTime, cue.startTime);
duplicate.endTime = Math.max(duplicate.endTime, cue.endTime);
matched.add(duplicate);
} else cues.push({ ...cue });
}
}
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation-coverage';
async function analyze(lines: string[], progress = 'out_time_us=20000000\n') {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-coverage-test-'));
try {
const ffmpegPath = path.join(directory, 'ffmpeg');
await writeFile(
ffmpegPath,
`#!${process.execPath}
process.stderr.write(${JSON.stringify(lines.join('\n') + '\n')});
process.stdout.write(${JSON.stringify(progress)});
`,
{ mode: 0o755 },
);
return await findAudiblePassages({ ffmpegPath, wavPath: 'audio.wav' });
} finally {
await rm(directory, { recursive: true, force: true });
}
}
test('audible coverage retains the full timeline when there is no confident silence', async () => {
assert.deepEqual(await analyze([]), [{ startSeconds: 0, endSeconds: 20 }]);
});
test('audible coverage omits silence while padding nearby audio without exceeding the timeline', async () => {
assert.deepEqual(
await analyze([
'[silencedetect] silence_start: 0',
'[silencedetect] silence_end: 2 | silence_duration: 2',
'[silencedetect] silence_start: 8',
'[silencedetect] silence_end: 12 | silence_duration: 4',
'[silencedetect] silence_start: 18',
]),
[
{ startSeconds: 1.65, endSeconds: 8.35 },
{ startSeconds: 11.65, endSeconds: 18.35 },
],
);
assert.deepEqual(await analyze(['[silencedetect] silence_end: 10.5 | silence_duration: 0.5']), [
{ startSeconds: 0, endSeconds: 20 },
]);
});
test('entirely silent audio has no audible passages', async () => {
assert.deepEqual(await analyze(['[silencedetect] silence_start: 0']), []);
assert.deepEqual(await analyze(['[silencedetect] silence_end: 20 | silence_duration: 20']), []);
});
test('missing analysis duration fails instead of silently dropping audio', async () => {
await assert.rejects(analyze([], ''), /valid duration/);
});
test('merging coverage preserves quiet VAD speech and does not mutate detector results', () => {
const speech = [{ startSeconds: 10, endSeconds: 11 }];
assert.deepEqual(
mergeSpeechPassages([
...speech,
{ startSeconds: 0, endSeconds: 5 },
{ startSeconds: 4, endSeconds: 8 },
{ startSeconds: 11, endSeconds: 12 },
]),
[
{ startSeconds: 0, endSeconds: 8 },
{ startSeconds: 10, endSeconds: 12 },
],
);
assert.deepEqual(speech, [{ startSeconds: 10, endSeconds: 11 }]);
});
@@ -0,0 +1,78 @@
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
import type { SpeechPassage } from './subtitle-generation-speech';
const AUDIO_PADDING_SECONDS = 0.35;
export function mergeSpeechPassages(passages: readonly SpeechPassage[]): SpeechPassage[] {
const merged: SpeechPassage[] = [];
for (const passage of [...passages].sort((a, b) => a.startSeconds - b.startSeconds)) {
const previous = merged.at(-1);
if (previous && passage.startSeconds <= previous.endSeconds)
previous.endSeconds = Math.max(previous.endSeconds, passage.endSeconds);
else merged.push({ ...passage });
}
return merged;
}
// VAD rejection is not proof of silence. Preserve audible gaps for Whisper to evaluate.
export async function findAudiblePassages(input: {
ffmpegPath: string;
wavPath: string;
signal?: AbortSignal;
}): Promise<SpeechPassage[]> {
const silences: SpeechPassage[] = [];
let duration = 0;
let trailingSilence: number | undefined;
await runSubtitleGenerationProcess({
command: input.ffmpegPath,
args: [
'-nostdin',
'-hide_banner',
'-nostats',
'-i',
input.wavPath,
'-af',
'silencedetect=noise=-50dB:d=0.5',
'-progress',
'pipe:1',
'-f',
'null',
'-',
],
signal: input.signal,
onLine: (line) => {
const progress = /^out_time_us=(\d+)$/.exec(line);
if (progress) duration = Math.max(duration, Number(progress[1]) / 1_000_000);
const start = /silence_start: (\S+)/.exec(line);
if (start && Number.isFinite(Number(start[1]))) trailingSilence = Number(start[1]);
const end = /silence_end: (\S+) \| silence_duration: (\S+)/.exec(line);
if (!end) return;
const endSeconds = Number(end[1]);
const length = Number(end[2]);
if (Number.isFinite(endSeconds) && Number.isFinite(length) && length > 0) {
silences.push({ startSeconds: Math.max(0, endSeconds - length), endSeconds });
trailingSilence = undefined;
}
},
});
if (!Number.isFinite(duration) || duration <= 0)
throw new Error('Audio analysis did not report a valid duration.');
if (trailingSilence !== undefined)
silences.push({ startSeconds: trailingSilence, endSeconds: duration });
const audible: SpeechPassage[] = [];
let cursor = 0;
for (const silence of mergeSpeechPassages(silences)) {
if (cursor >= duration) break;
if (silence.startSeconds > cursor)
audible.push({ startSeconds: cursor, endSeconds: Math.min(duration, silence.startSeconds) });
cursor = Math.max(cursor, silence.endSeconds);
}
if (cursor < duration) audible.push({ startSeconds: cursor, endSeconds: duration });
return mergeSpeechPassages(
audible.map((passage) => ({
startSeconds: Math.max(0, passage.startSeconds - AUDIO_PADDING_SECONDS),
endSeconds: Math.min(duration, passage.endSeconds + AUDIO_PADDING_SECONDS),
})),
);
}
@@ -0,0 +1,151 @@
import { access, readFile, rm } from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
import type { SubtitleGenerationToolPaths } from './subtitle-generation-tools';
import { formatTimestamp } from './subtitle-generation-srt';
import {
parseSpeechPassages,
speechPassageCues,
SPEECH_PASSAGE_SECONDS,
} from './subtitle-generation-speech';
import type { SubtitleCue } from './subtitle-cue-parser';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
import { findSpeechPauses } from './subtitle-generation-pauses';
import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation-coverage';
export async function transcribeSubtitleDialogue(input: {
config: SubtitleGenerationConfig;
tools: SubtitleGenerationToolPaths & { vad: string };
modelPath: string;
wavPath: string;
directory: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
await access(vadModelPath, constants.R_OK);
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
const segmentLines: string[] = [];
await runSubtitleGenerationProcess({
command: input.tools.vad,
args: [
'-f',
input.wavPath,
'-vm',
vadModelPath,
'-t',
String(input.config.threads),
'-vt',
'0.3',
'--vad-min-speech-duration-ms',
'100',
'--vad-min-silence-duration-ms',
'500',
'-vp',
'350',
'-vmsd',
String(SPEECH_PASSAGE_SECONDS),
'-np',
],
signal: input.signal,
// Capture structured result lines separately from the bounded process log.
onLine: (line) => {
if (line.startsWith('Detected ') || line.startsWith('Speech segment '))
segmentLines.push(line);
},
});
const speech = parseSpeechPassages(segmentLines.join('\n'));
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' });
const audible = await findAudiblePassages({
ffmpegPath: input.tools.ffmpeg,
wavPath: input.wavPath,
signal: input.signal,
});
const detected = mergeSpeechPassages([...speech, ...audible]);
if (detected.length === 0) throw new Error('No spoken dialogue detected.');
const pauses = detected.some(
(passage) => passage.endSeconds - passage.startSeconds > SPEECH_PASSAGE_SECONDS,
)
? await findSpeechPauses({
ffmpegPath: input.tools.ffmpeg,
wavPath: input.wavPath,
signal: input.signal,
})
: [];
const passages = splitSpeechPassages(
detected,
pauses,
speech.map((passage) => passage.startSeconds),
);
const cues: SubtitleCue[] = [];
for (const [index, passage] of passages.entries()) {
const base = path.join(input.directory, `speech-${index}`);
input.onProgress?.({
stage: 'transcribe',
percent: Math.floor((index / passages.length) * 100),
message: `Transcribing dialogue passage ${index + 1} of ${passages.length}...`,
});
await runSubtitleGenerationProcess({
command: input.tools.ffmpeg,
args: [
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
'-ss',
String(passage.startSeconds),
'-i',
input.wavPath,
'-t',
String(passage.endSeconds - passage.startSeconds),
'-ac',
'1',
'-ar',
'16000',
'-c:a',
'pcm_s16le',
`${base}.wav`,
],
signal: input.signal,
});
// -mc 0 limits text context, but does not isolate decoder state across input files.
// A fresh process prevents earlier passages from corrupting later transcriptions.
await runSubtitleGenerationProcess({
command: input.tools.whisper,
args: [
'-m',
input.modelPath,
'-l',
'ja',
'-t',
String(input.config.threads),
'-mc',
'0',
'-sns',
'-osrt',
'-f',
`${base}.wav`,
'-of',
base,
],
signal: input.signal,
});
input.signal?.throwIfAborted();
appendSpeechChunkCues(cues, speechPassageCues(await readFile(`${base}.srt`, 'utf8'), passage));
await rm(`${base}.wav`);
}
if (cues.length === 0) throw new Error('Whisper recognized no dialogue in the detected speech.');
return cues
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime)
.map(
(cue, index) =>
`${index + 1}\n${formatTimestamp(cue.startTime * 1000)} --> ${formatTimestamp(cue.endTime * 1000)}\n${cue.text}\n`,
)
.join('\n');
}
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { downloadSubtitleGenerationArtifact } from './subtitle-generation-download';
import {
downloadSubtitleGenerationVadModel,
resolveSubtitleGenerationVadModel,
} from './subtitle-generation-vad-model';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
test('verified model publication preserves existing files and cleans up failed or cancelled downloads', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-model-download-'));
const originalFetch = globalThis.fetch;
const bytes = new TextEncoder().encode('fixture model');
const input = {
url: 'https://example.test/model',
size: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
destination: path.join(directory, 'model.bin'),
label: 'test model',
};
try {
globalThis.fetch = Object.assign(async () => new Response(bytes), originalFetch);
await downloadSubtitleGenerationArtifact(input);
assert.equal(await readFile(input.destination, 'utf8'), 'fixture model');
await assert.rejects(downloadSubtitleGenerationArtifact(input), /EEXIST/);
await assert.rejects(
downloadSubtitleGenerationArtifact({
...input,
destination: path.join(directory, 'bad.bin'),
sha256: 'wrong',
}),
/integrity/,
);
const controller = new AbortController();
await assert.rejects(
downloadSubtitleGenerationArtifact({
...input,
destination: path.join(directory, 'cancelled.bin'),
signal: controller.signal,
onProgress: ({ percent }) => {
if (percent === 99) controller.abort();
},
}),
);
assert.deepEqual(await readdir(directory), ['model.bin']);
} finally {
globalThis.fetch = originalFetch;
await rm(directory, { recursive: true, force: true });
}
});
test('VAD setup recognizes existing paths and never replaces an invalid external model', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-vad-model-'));
try {
const config = { ...DEFAULT_SUBTITLE_GENERATION_CONFIG };
assert.equal((await resolveSubtitleGenerationVadModel(config, directory)).kind, 'missing');
config.vadModelPath = path.join(directory, 'external.bin');
await assert.rejects(
downloadSubtitleGenerationVadModel({ config, modelDirectory: directory }),
/Cannot read/,
);
await writeFile(config.vadModelPath, 'external model');
assert.equal((await resolveSubtitleGenerationVadModel(config, directory)).kind, 'external');
assert.equal(
await downloadSubtitleGenerationVadModel({ config, modelDirectory: directory }),
config.vadModelPath,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, open, rm } from 'node:fs/promises';
import path from 'node:path';
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
export async function downloadSubtitleGenerationArtifact(input: {
url: string;
size: number;
sha256: string;
destination: string;
label: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
input.signal?.throwIfAborted();
await mkdir(path.dirname(input.destination), { recursive: true });
const temporaryDirectory = await mkdtemp(
path.join(path.dirname(input.destination), '.download-'),
);
const temporaryPath = path.join(temporaryDirectory, 'model.bin');
input.onProgress?.({ stage: 'download', percent: 0, message: `Downloading ${input.label}...` });
try {
const response = await fetch(input.url, { signal: input.signal });
if (!response.ok || !response.body) {
throw new Error(`Model download failed: HTTP ${response.status}`);
}
const file = await open(temporaryPath, 'wx');
const reader = response.body.getReader();
const digest = createHash('sha256');
let received = 0;
let previousPercent = -1;
try {
while (true) {
input.signal?.throwIfAborted();
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > input.size) throw new Error('Downloaded model exceeds expected size.');
digest.update(value);
await file.writeFile(value);
const percent = Math.min(99, Math.floor((received / input.size) * 100));
if (percent !== previousPercent) {
previousPercent = percent;
input.onProgress?.({
stage: 'download',
percent,
message: `Downloading ${input.label}...`,
});
}
}
if (received !== input.size || digest.digest('hex') !== input.sha256) {
throw new Error('Downloaded model failed integrity verification. Try downloading again.');
}
await file.sync();
} finally {
await reader.cancel().catch(() => undefined);
await file.close();
}
input.signal?.throwIfAborted();
await publishSubtitleGenerationFile(temporaryPath, input.destination);
input.onProgress?.({ stage: 'download', percent: 100, message: `${input.label} is ready.` });
return input.destination;
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
@@ -0,0 +1,32 @@
import { constants } from 'node:fs';
import { copyFile, link } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
export function expandSubtitleGenerationPath(value: string): string {
if (value === '~') return homedir();
if (value.startsWith('~/') || value.startsWith('~\\'))
return path.join(homedir(), value.slice(2));
return value;
}
// Prefer atomic publication. Filesystems without hard links still get exclusive creation.
export async function publishSubtitleGenerationFile(
source: string,
destination: string,
): Promise<void> {
try {
await link(source, destination);
} catch (error) {
if (
!(error instanceof Error) ||
!('code' in error) ||
(error.code !== 'ENOTSUP' &&
error.code !== 'EOPNOTSUPP' &&
error.code !== 'EPERM' &&
error.code !== 'EXDEV')
)
throw error;
await copyFile(source, destination, constants.COPYFILE_EXCL);
}
}
@@ -0,0 +1,90 @@
import { access, open, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import { getSubtitleGenerationModel } from '../../shared/subtitle-generation-model-catalog';
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
import { downloadSubtitleGenerationArtifact } from './subtitle-generation-download';
import type {
SubtitleGenerationConfig,
SubtitleGenerationModelStatus,
SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
export function isMissingFile(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
async function modelCompatibilityError(modelPath: string): Promise<string | undefined> {
const file = await open(modelPath, 'r');
try {
// whisper_model_load reads GGML magic, then n_vocab. is_multilingual uses n_vocab >= 51865.
const header = Buffer.alloc(8);
const { bytesRead } = await file.read(header, 0, header.length, 0);
if (
bytesRead !== header.length ||
header.readUInt32LE(0) !== 0x67676d6c ||
header.readInt32LE(4) <= 0
) {
return 'Unsupported model format. Choose a whisper.cpp GGML .bin model.';
}
if (header.readInt32LE(4) < 51865) {
return 'This Whisper model is English-only. Japanese subtitle generation requires a multilingual model.';
}
return undefined;
} finally {
await file.close();
}
}
export async function resolveSubtitleGenerationModel(
config: SubtitleGenerationConfig,
modelDirectory: string,
): Promise<SubtitleGenerationModelStatus> {
const external = config.modelPath.trim();
const modelPath = external
? path.resolve(expandSubtitleGenerationPath(external))
: path.resolve(modelDirectory, `ggml-${config.managedModel}.bin`);
try {
const info = await stat(modelPath);
if (!info.isFile() || info.size === 0) {
return { kind: 'invalid', path: modelPath, message: 'Model must be a nonempty file.' };
}
await access(modelPath, constants.R_OK);
if (!external && info.size !== getSubtitleGenerationModel(config.managedModel).size) {
return { kind: 'invalid', path: modelPath, message: 'Managed model has an unexpected size.' };
}
const compatibilityError = await modelCompatibilityError(modelPath);
if (compatibilityError)
return { kind: 'invalid', path: modelPath, message: compatibilityError };
return { kind: external ? 'external' : 'managed', path: modelPath };
} catch (error) {
if (!external && isMissingFile(error)) return { kind: 'missing', path: modelPath };
return {
kind: 'invalid',
path: modelPath,
message: `Cannot read model: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
export async function downloadSubtitleGenerationModel(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
input.signal?.throwIfAborted();
const current = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
if (current.kind === 'external' || current.kind === 'managed') return current.path;
if (current.kind === 'invalid') throw new Error(current.message);
const model = getSubtitleGenerationModel(input.config.managedModel);
return downloadSubtitleGenerationArtifact({
url: `https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-${input.config.managedModel}.bin`,
size: model.size,
sha256: model.sha256,
destination: current.path,
label: 'Whisper model',
onProgress: input.onProgress,
signal: input.signal,
});
}
@@ -0,0 +1,35 @@
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
// Each completed silencedetect line contains both the end and duration of a quiet interval.
export async function findSpeechPauses(input: {
ffmpegPath: string;
wavPath: string;
signal?: AbortSignal;
}): Promise<number[]> {
const pauses: number[] = [];
await runSubtitleGenerationProcess({
command: input.ffmpegPath,
args: [
'-nostdin',
'-hide_banner',
'-nostats',
'-i',
input.wavPath,
'-af',
'silencedetect=noise=-35dB:d=0.12',
'-f',
'null',
'-',
],
signal: input.signal,
onLine: (line) => {
const match = /silence_end: (\S+) \| silence_duration: (\S+)/.exec(line);
if (!match) return;
const end = Number(match[1]);
const duration = Number(match[2]);
if (Number.isFinite(end) && Number.isFinite(duration) && duration > 0 && end >= duration)
pauses.push(end - duration / 2);
},
});
return pauses.sort((a, b) => a - b);
}
@@ -0,0 +1,67 @@
import { spawn } from 'node:child_process';
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
const OUTPUT_LIMIT = 64 * 1024;
// Keep partial lines between chunks: ffmpeg and whisper both report progress on stderr.
export function runSubtitleGenerationProcess(input: {
command: string;
args: string[];
signal?: AbortSignal;
onLine?: (line: string) => void;
}): Promise<string> {
input.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const child = spawn(expandSubtitleGenerationPath(input.command), input.args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let killTimer: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
child.kill('SIGTERM');
killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
killTimer.unref();
};
input.signal?.addEventListener('abort', abort, { once: true });
if (input.signal?.aborted) abort();
const cleanup = () => {
input.signal?.removeEventListener('abort', abort);
clearTimeout(killTimer);
};
for (const [stream, isStdout] of [
[child.stdout, true],
[child.stderr, false],
] as const) {
let pending = '';
stream.setEncoding('utf8');
stream.on('data', (chunk: string) => {
if (isStdout) stdout = (stdout + chunk).slice(-OUTPUT_LIMIT);
else stderr = (stderr + chunk).slice(-OUTPUT_LIMIT);
const lines = (pending + chunk).split(/[\r\n]/);
pending = (lines.pop() ?? '').slice(-OUTPUT_LIMIT);
for (const line of lines) input.onLine?.(line);
});
stream.on('end', () => {
if (pending) input.onLine?.(pending);
});
}
child.once('error', (error) => {
cleanup();
reject(
new Error(
'code' in error && error.code === 'ENOENT'
? `${input.command} was not found. Install it or set its path under subtitleGeneration in Settings.`
: `Could not run ${input.command}: ${error.message}`,
),
);
});
child.once('close', (code) => {
cleanup();
if (input.signal?.aborted) reject(new Error('Subtitle generation cancelled.'));
else if (code !== 0) {
reject(new Error(`${input.command} exited with status ${code}: ${stderr.trim()}`));
} else resolve(stdout);
});
});
}
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSpeechPassages, speechPassageCues } from './subtitle-generation-speech';
test('speech passages convert centiseconds, group nearby speech, and retain long gaps', () => {
assert.deepEqual(
parseSpeechPassages(
[
'Detected 4 speech segments:',
'Speech segment 0: start = 0.00, end = 300.00',
'Speech segment 1: start = 350.00, end = 900.00',
'Speech segment 2: start = 10000.00, end = 11900.00',
'Speech segment 3: start = 11950.00, end = 12500.00',
].join('\n'),
),
[
{ startSeconds: 0, endSeconds: 9 },
{ startSeconds: 100, endSeconds: 119 },
{ startSeconds: 119.5, endSeconds: 125 },
],
);
});
test('speech passages retain long merged detector segments for pause-aware splitting', () => {
assert.deepEqual(
parseSpeechPassages(
[
'Detected 3 speech segments:',
'Speech segment 0: start = 33714.00, end = 36756.00',
'Speech segment 1: start = 40000.00, end = 46000.00',
'Speech segment 2: start = 50000.00, end = 50100.00',
].join('\n'),
),
[
{ startSeconds: 337.14, endSeconds: 367.56 },
{ startSeconds: 400, endSeconds: 460 },
{ startSeconds: 500, endSeconds: 501 },
],
);
});
test('speech detector distinguishes no speech from missing, malformed, or truncated output', () => {
assert.deepEqual(parseSpeechPassages('Detected 0 speech segments:'), []);
assert.deepEqual(
parseSpeechPassages(
'Detected 1 speech segments:\nSpeech segment 0: start = 79896.00, end = 82218.00',
),
[{ startSeconds: 798.96, endSeconds: 822.18 }],
);
for (const output of [
'',
'Detected 1 speech segments:',
'Detected 1 speech segments:\nSpeech segment 1: start = 100.00, end = 200.00',
'Detected 1 speech segments:\nSpeech segment 0: start = 200.00, end = 100.00',
'Detected 1 speech segments:\nSpeech segment 0: start = NaN, end = 100.00',
'Detected 2 speech segments:\nSpeech segment 0: start = 0.00, end = 200.00\nSpeech segment 1: start = 100.00, end = 300.00',
])
assert.throws(() => parseSpeechPassages(output), /Speech detector/);
});
test('passage cue times cannot extend into omitted audio or accumulate offsets', () => {
const srt =
'1\n00:00:00,000 --> 00:00:01,000\nはい\n\n2\n00:00:01,000 --> 00:01:40,000\nはい\n\n3\n00:01:41,000 --> 00:01:42,000\n幻覚\n';
assert.deepEqual(
speechPassageCues(srt, { startSeconds: 1200.25, endSeconds: 1203.75 }).map(
({ startTime, endTime, text }) => ({ startTime, endTime, text }),
),
[
{ startTime: 1200.25, endTime: 1201.25, text: 'はい' },
{ startTime: 1201.25, endTime: 1203.75, text: 'はい' },
],
);
assert.deepEqual(speechPassageCues('', { startSeconds: 0, endSeconds: 1 }), []);
assert.throws(
() => speechPassageCues('broken SRT', { startSeconds: 0, endSeconds: 1 }),
/malformed/,
);
});
@@ -0,0 +1,66 @@
import { parseSrtCues, type SubtitleCue } from './subtitle-cue-parser';
export interface SpeechPassage {
startSeconds: number;
endSeconds: number;
}
export const SPEECH_PASSAGE_SECONDS = 20;
// The standalone whisper.cpp detector reports centiseconds, unlike its diagnostic logs.
export function parseSpeechPassages(output: string): SpeechPassage[] {
const count = /^Detected (\d+) speech segments:$/m.exec(output);
if (!count) throw new Error('Speech detector did not report its segment count.');
const passages: SpeechPassage[] = [];
for (const line of output.split(/\r?\n/)) {
if (!line.startsWith('Speech segment ')) continue;
const match = /^Speech segment (\d+): start = (\d+(?:\.\d+)?), end = (\d+(?:\.\d+)?)$/.exec(
line,
);
if (!match) throw new Error('Speech detector returned a malformed segment.');
const index = Number(match[1]);
const startSeconds = Number(match[2]) / 100;
const endSeconds = Number(match[3]) / 100;
if (
index !== passages.length ||
!Number.isFinite(startSeconds) ||
!Number.isFinite(endSeconds) ||
endSeconds <= startSeconds ||
startSeconds < (passages.at(-1)?.endSeconds ?? 0)
) {
throw new Error('Speech detector returned unordered or invalid segment timing.');
}
passages.push({ startSeconds, endSeconds });
}
if (passages.length !== Number(count[1]))
throw new Error('Speech detector output is incomplete.');
const grouped: SpeechPassage[] = [];
for (const passage of passages) {
const previous = grouped.at(-1);
if (
previous &&
passage.startSeconds - previous.endSeconds <= 1 &&
passage.endSeconds - previous.startSeconds <= SPEECH_PASSAGE_SECONDS
) {
previous.endSeconds = passage.endSeconds;
} else grouped.push({ ...passage });
}
return grouped;
}
// Clamp to the audio actually supplied to Whisper. A cue cannot cross an omitted gap.
export function speechPassageCues(srt: string, passage: SpeechPassage): SubtitleCue[] {
const duration = passage.endSeconds - passage.startSeconds;
const cues = parseSrtCues(srt);
if (srt.trim() && cues.length === 0)
throw new Error('Whisper returned malformed subtitles for a speech passage.');
return cues.flatMap((cue) => {
const start = Math.max(0, cue.startTime);
const end = Math.min(duration, cue.endTime);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
return [
{ ...cue, startTime: passage.startSeconds + start, endTime: passage.startSeconds + end },
];
});
}
@@ -0,0 +1,7 @@
export function formatTimestamp(milliseconds: number): string {
const rounded = Math.max(0, Math.round(milliseconds));
const hours = Math.floor(rounded / 3600000);
const minutes = Math.floor((rounded % 3600000) / 60000);
const seconds = Math.floor((rounded % 60000) / 1000);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(rounded % 1000).padStart(3, '0')}`;
}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
import {
requireSubtitleGenerationTools,
resolveSubtitleGenerationTools,
} from './subtitle-generation-tools';
async function fixture(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-generation-tools-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function executable(directory: string, name: string): Promise<string> {
const file = path.join(directory, name);
await writeFile(file, '#!/bin/sh\n', { mode: 0o755 });
return file;
}
test('tools resolve from PATH, honor overrides, and only require the detector in dialogue mode', () =>
fixture(async (directory) => {
const bin = path.join(directory, 'bin');
await mkdir(bin);
for (const name of ['ffmpeg', 'ffprobe', 'whisper-cli']) await executable(bin, name);
const detector = await executable(bin, 'vad-speech-segments');
const customWhisper = await executable(directory, 'my-whisper');
await writeFile(path.join(directory, 'not-executable'), '', { mode: 0o644 });
const env = { PATH: bin };
const found = await resolveSubtitleGenerationTools(DEFAULT_SUBTITLE_GENERATION_CONFIG, env);
assert.deepEqual(found, {
ffmpeg: { kind: 'found', path: path.join(bin, 'ffmpeg') },
ffprobe: { kind: 'found', path: path.join(bin, 'ffprobe') },
whisper: { kind: 'found', path: path.join(bin, 'whisper-cli') },
vad: null,
});
assert.equal(requireSubtitleGenerationTools(found).vad, null);
const dialogue = await resolveSubtitleGenerationTools(
{ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/models/vad.bin' },
env,
);
assert.deepEqual(dialogue.vad, { kind: 'found', path: detector });
const overridden = await resolveSubtitleGenerationTools(
{
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
whisperPath: customWhisper,
ffmpegPath: path.join(directory, 'not-executable'),
},
env,
);
assert.deepEqual(overridden.whisper, { kind: 'found', path: customWhisper });
assert.equal(overridden.ffmpeg.kind, 'missing');
assert.throws(
() => requireSubtitleGenerationTools(overridden),
/not-executable \(subtitleGeneration\.ffmpegPath\) is not an executable file/,
);
}));
test('missing tools name the executable, the installer, and the setting', () =>
fixture(async (directory) => {
const tools = await resolveSubtitleGenerationTools(
{ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/models/vad.bin' },
{ PATH: directory },
);
assert.deepEqual(tools.whisper, {
kind: 'missing',
message:
'whisper-cli was not found on PATH. Install whisper.cpp or set subtitleGeneration.whisperPath in Settings.',
});
assert.deepEqual(tools.vad, {
kind: 'missing',
message:
"whisper-vad-speech-segments was not found on PATH. Install whisper.cpp's speech segment detector or set subtitleGeneration.vadPath in Settings.",
});
assert.throws(() => requireSubtitleGenerationTools(tools), /ffmpeg was not found on PATH/);
}));
@@ -0,0 +1,125 @@
import { access, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationToolStatus,
SubtitleGenerationTools,
} from '../../shared/subtitle-generation';
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
/** Executable paths ready to spawn. `vad` is null when dialogue mode is off. */
export interface SubtitleGenerationToolPaths {
ffmpeg: string;
ffprobe: string;
whisper: string;
vad: string | null;
}
const TOOL_LOOKUPS = {
ffmpeg: { setting: 'ffmpegPath', names: ['ffmpeg'], install: 'Install FFmpeg' },
ffprobe: { setting: 'ffprobePath', names: ['ffprobe'], install: 'Install FFmpeg' },
whisper: { setting: 'whisperPath', names: ['whisper-cli'], install: 'Install whisper.cpp' },
vad: {
setting: 'vadPath',
names: ['whisper-vad-speech-segments', 'vad-speech-segments'],
install: "Install whisper.cpp's speech segment detector",
},
} as const;
async function isExecutableFile(filePath: string): Promise<boolean> {
try {
if (!(await stat(filePath)).isFile()) return false;
await access(filePath, constants.X_OK);
return true;
} catch {
return false;
}
}
function executableNames(name: string, env: NodeJS.ProcessEnv): string[] {
if (process.platform !== 'win32' || path.extname(name)) return [name];
const extensions = (env.PATHEXT ?? '.EXE;.CMD;.BAT')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
async function findOnPath(names: readonly string[], env: NodeJS.ProcessEnv): Promise<string> {
const directories = (env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
for (const directory of directories) {
for (const name of names) {
for (const candidate of executableNames(name, env)) {
const filePath = path.join(directory, candidate);
if (await isExecutableFile(filePath)) return filePath;
}
}
}
return '';
}
async function resolveTool(
tool: keyof typeof TOOL_LOOKUPS,
config: SubtitleGenerationConfig,
env: NodeJS.ProcessEnv,
): Promise<SubtitleGenerationToolStatus> {
const lookup = TOOL_LOOKUPS[tool];
const override = config[lookup.setting].trim();
if (override) {
const expanded = expandSubtitleGenerationPath(override);
const found =
path.dirname(expanded) === '.'
? await findOnPath([expanded], env)
: (await isExecutableFile(expanded))
? path.resolve(expanded)
: '';
return found
? { kind: 'found', path: found }
: {
kind: 'missing',
message: `${override} (subtitleGeneration.${lookup.setting}) is not an executable file.`,
};
}
const found = await findOnPath(lookup.names, env);
return found
? { kind: 'found', path: found }
: {
kind: 'missing',
message: `${lookup.names[0]} was not found on PATH. ${lookup.install} or set subtitleGeneration.${lookup.setting} in Settings.`,
};
}
/** Locate every executable a generation run needs, before any model download or audio work. */
export async function resolveSubtitleGenerationTools(
config: SubtitleGenerationConfig,
env: NodeJS.ProcessEnv = process.env,
): Promise<SubtitleGenerationTools> {
const [ffmpeg, ffprobe, whisper, vad] = await Promise.all([
resolveTool('ffmpeg', config, env),
resolveTool('ffprobe', config, env),
resolveTool('whisper', config, env),
config.vadModelPath.trim() ? resolveTool('vad', config, env) : null,
]);
return { ffmpeg, ffprobe, whisper, vad };
}
function foundPath(tool: SubtitleGenerationToolStatus): string {
if (tool.kind === 'missing') throw new Error(tool.message);
return tool.path;
}
/** Throw the first missing tool's message, otherwise narrow to spawnable paths. */
export function requireSubtitleGenerationTools(
tools: SubtitleGenerationTools,
): SubtitleGenerationToolPaths {
return {
ffmpeg: foundPath(tools.ffmpeg),
ffprobe: foundPath(tools.ffprobe),
whisper: foundPath(tools.whisper),
vad: tools.vad ? foundPath(tools.vad) : null,
};
}
@@ -0,0 +1,63 @@
import { access, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationModelStatus,
SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import { expandSubtitleGenerationPath } from './subtitle-generation-files';
import { isMissingFile } from './subtitle-generation-models';
import { downloadSubtitleGenerationArtifact } from './subtitle-generation-download';
export async function resolveSubtitleGenerationVadModel(
config: SubtitleGenerationConfig,
modelDirectory: string,
): Promise<SubtitleGenerationModelStatus> {
const external = config.vadModelPath.trim();
const modelPath = external
? path.resolve(expandSubtitleGenerationPath(external))
: path.resolve(modelDirectory, SUBTITLE_GENERATION_VAD_MODEL.filename);
try {
const info = await stat(modelPath);
if (
!info.isFile() ||
info.size === 0 ||
(!external && info.size !== SUBTITLE_GENERATION_VAD_MODEL.size)
)
return {
kind: 'invalid',
path: modelPath,
message: 'Speech detection model has an invalid size.',
};
await access(modelPath, constants.R_OK);
return { kind: external ? 'external' : 'managed', path: modelPath };
} catch (error) {
if (!external && isMissingFile(error)) return { kind: 'missing', path: modelPath };
return {
kind: 'invalid',
path: modelPath,
message: `Cannot read speech detection model: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
export async function downloadSubtitleGenerationVadModel(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
input.signal?.throwIfAborted();
const current = await resolveSubtitleGenerationVadModel(input.config, input.modelDirectory);
if (current.kind === 'invalid') throw new Error(current.message);
if (current.kind !== 'missing') return current.path;
return downloadSubtitleGenerationArtifact({
...SUBTITLE_GENERATION_VAD_MODEL,
destination: current.path,
label: 'Silero speech detection model',
onProgress: input.onProgress,
signal: input.signal,
});
}
@@ -0,0 +1,454 @@
import assert from 'node:assert/strict';
import { constants } from 'node:fs';
import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
DEFAULT_SUBTITLE_GENERATION_CONFIG,
resolveSubtitleGenerationConfig,
type SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
import {
downloadSubtitleGenerationModel,
ensureWritableDirectory,
generateJapaneseSubtitles,
resolveSubtitleGenerationModel,
} from './subtitle-generation';
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
async function fixture(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-generation-test-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function executable(directory: string, name: string, body: string) {
const file = path.join(directory, name);
await writeFile(file, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return file;
}
function modelHeader(vocabularySize = 51865): Buffer {
const header = Buffer.alloc(8);
header.writeUInt32LE(0x67676d6c, 0);
header.writeInt32LE(vocabularySize, 4);
return header;
}
async function generationFixture(directory: string) {
const modelPath = path.join(directory, 'external.bin');
const mediaPath = path.join(directory, 'episode.mkv');
const callsPath = path.join(directory, 'calls.jsonl');
await writeFile(modelPath, modelHeader());
await writeFile(mediaPath, 'local media');
const record = `require('node:fs').appendFileSync(${JSON.stringify(callsPath)}, JSON.stringify(process.argv.slice(2)) + '\\n');`;
const ffprobePath = await executable(
directory,
'ffprobe',
`${record}\nprocess.stdout.write(JSON.stringify({streams: [{index:1,codec_type:'audio',start_time:'10',tags:{language:'eng'}},{index:3,codec_type:'audio',start_time:'12.5',duration:'20',tags:{language:'jpn'}}],format:{start_time:'10',duration:'25'}}));`,
);
const ffmpegPath = await executable(
directory,
'ffmpeg',
`${record}
if (process.argv.at(-1) === '-') {
process.stderr.write('[silencedetect] silence_end: 25 | silence_duration: 25\\n');
process.stdout.write('out_time_us=25000000\\nprogress=end\\n');
} else {
require('node:fs').writeFileSync(process.argv.at(-1), 'wav');
process.stdout.write('out_time_');
setTimeout(() => process.stdout.write('us=10000000\\nprogress=end\\n'), 10);
}`,
);
const whisperPath = await executable(
directory,
'whisper-cli',
`${record}\nconst args=process.argv.slice(2); require('node:fs').writeFileSync(args[args.indexOf('-of')+1]+'.srt', '1\\n00:00:01,000 --> 00:00:02,000\\nこんにちは\\n'); process.stderr.write('whisper_print_progress_callback: progress = '); setTimeout(() => process.stderr.write('55%\\n'), 10);`,
);
return {
config: {
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
modelPath,
ffprobePath,
ffmpegPath,
whisperPath,
},
mediaPath,
modelDirectory: path.join(directory, 'models'),
callsPath,
};
}
test('config parser accepts supported models and rejects unsafe threads and wrong field types', () => {
const warnings: string[] = [];
const result = resolveSubtitleGenerationConfig(
{ modelPath: '/tmp/whisper.bin', threads: 0, whisperPath: 42, managedModel: 'large-v3-turbo' },
(key) => warnings.push(key),
);
assert.equal(result.modelPath, '/tmp/whisper.bin');
assert.equal(result.managedModel, 'large-v3-turbo');
assert.equal(result.threads, DEFAULT_SUBTITLE_GENERATION_CONFIG.threads);
assert.deepEqual(warnings, ['whisperPath', 'threads']);
});
test('external model path wins and invalid external models never fall back to download', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
assert.deepEqual(await resolveSubtitleGenerationModel(input.config, input.modelDirectory), {
kind: 'external',
path: input.config.modelPath,
});
const invalid = { ...input.config, modelPath: path.join(directory, 'missing.bin') };
assert.equal(
(await resolveSubtitleGenerationModel(invalid, input.modelDirectory)).kind,
'invalid',
);
await assert.rejects(
downloadSubtitleGenerationModel({ ...input, config: invalid }),
/Cannot read model/,
);
assert.equal(
(
await resolveSubtitleGenerationModel(
{ ...input.config, modelPath: '' },
input.modelDirectory,
)
).kind,
'missing',
);
}));
test('English-only and incompatible external models are rejected before transcription', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
await writeFile(input.config.modelPath, modelHeader(51864));
const englishOnly = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
assert.equal(englishOnly.kind, 'invalid');
assert.ok('message' in englishOnly);
assert.match(englishOnly.message, /English-only/);
await assert.rejects(generateJapaneseSubtitles(input), /requires a multilingual model/);
await writeFile(input.config.modelPath, 'not a GGML model');
await assert.rejects(generateJapaneseSubtitles(input), /Unsupported model format/);
await writeFile(input.config.modelPath, modelHeader().subarray(0, 4));
await assert.rejects(generateJapaneseSubtitles(input), /Unsupported model format/);
await assert.rejects(readFile(input.callsPath), /ENOENT/);
}));
test('generation picks Japanese audio, restores timeline offsets, reports split progress, and preserves existing output', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const existing = path.join(directory, 'episode.ja.generated.srt');
await writeFile(existing, 'user subtitles');
const progress: SubtitleGenerationProgress[] = [];
const result = await generateJapaneseSubtitles({
...input,
onProgress: (event) => progress.push(event),
});
assert.equal(result, path.join(directory, 'episode.ja.generated.1.srt'));
assert.equal(await readFile(existing, 'utf8'), 'user subtitles');
assert.match(await readFile(result, 'utf8'), /00:00:03,500 --> 00:00:04,500\nこんにちは/);
const calls = (await readFile(input.callsPath, 'utf8'))
.trim()
.split('\n')
.map((line): unknown => JSON.parse(line));
assert.ok(Array.isArray(calls[1]));
assert.ok(calls[1].includes('0:3'));
assert.ok(Array.isArray(calls[2]));
assert.ok(calls[2].includes('ja'));
assert.ok(calls[2].includes('-osrt'));
assert.ok(progress.some((event) => event.stage === 'extract' && event.percent === 50));
assert.ok(progress.some((event) => event.stage === 'transcribe' && event.percent === 55));
assert.deepEqual(
(await readdir(directory)).filter((file) => file.startsWith('.subminer-')),
[],
);
}));
test('explicit audio stream and output path are respected without overwriting existing files', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const outputPath = path.join(directory, 'chosen.srt');
const result = await generateJapaneseSubtitles({ ...input, audioStreamIndex: 1, outputPath });
assert.equal(result, outputPath);
assert.match(await readFile(result, 'utf8'), /00:00:01,000 --> 00:00:02,000/);
await assert.rejects(generateJapaneseSubtitles({ ...input, outputPath }), /already exists/);
await assert.rejects(
generateJapaneseSubtitles({ ...input, audioStreamIndex: 99 }),
/stream 99 was not found/,
);
}));
test('dialogue generation isolates Whisper state between passages and preserves media timing', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'speech detector model');
const vadPath = await executable(
directory,
'vad',
"process.stdout.write('Detected 2 speech segments:\\nSpeech segment 0: start = 1000.00, end = 1100.00\\nSpeech segment 1: start = 10000.00, end = 10100.00\\n');",
);
const whisperPath = await executable(
directory,
'dialogue-whisper',
`const args = process.argv.slice(2);
let files = 0;
for (let i = 0; i < args.length; i++) if (args[i] === '-of') {
// Reproduce a decoder that degenerates when reused for another audio file.
const text = files++ === 0 ? 'はい' : 'お' + 'ぉ'.repeat(40) + 'ぇ'.repeat(178);
require('node:fs').writeFileSync(args[i + 1] + '.srt', '1\\n00:00:00,000 --> 00:01:39,000\\n' + text + '\\n');
}`,
);
const output = await generateJapaneseSubtitles({
...input,
config: { ...input.config, vadModelPath, vadPath, whisperPath },
});
assert.equal(
await readFile(output, 'utf8'),
'1\n00:00:12,500 --> 00:00:13,500\nはい\n\n2\n00:01:42,500 --> 00:01:43,500\nはい\n',
);
}));
test('dialogue generation uses quiet pauses and stitches overlapping chunks on the media timeline', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'speech detector model');
const vadPath = await executable(
directory,
'vad',
`const assert = require('node:assert/strict');
const args = process.argv.slice(2);
assert.equal(args[args.indexOf('--vad-min-speech-duration-ms') + 1], '100');
assert.equal(args[args.indexOf('-vp') + 1], '350');
process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 1000.00, end = 4500.00\\n');`,
);
const ffmpegPath = await executable(
directory,
'pause-ffmpeg',
`const args = process.argv.slice(2);
if (args.includes('silencedetect=noise=-50dB:d=0.5')) {
process.stderr.write('[silencedetect] silence_end: 45 | silence_duration: 45\\n');
process.stdout.write('out_time_us=45000000\\n');
} else if (args.includes('-af')) {
process.stderr.write('[silencedetect] silence_end: 28.1 | silence_duration: 0.2\\n');
} else {
require('node:fs').writeFileSync(args.at(-1), 'wav');
}`,
);
const whisperPath = await executable(
directory,
'overlap-whisper',
`const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) if (args[i] === '-of') {
const time = args[i + 1].endsWith('speech-0')
? '00:00:17,800 --> 00:00:18,250'
: '00:00:00,100 --> 00:00:00,650';
require('node:fs').writeFileSync(args[i + 1] + '.srt', '1\\n' + time + '\\nはい\\n');
}`,
);
const output = await generateJapaneseSubtitles({
...input,
config: { ...input.config, vadModelPath, vadPath, ffmpegPath, whisperPath },
});
assert.equal(await readFile(output, 'utf8'), '1\n00:00:30,300 --> 00:00:30,900\nはい\n');
}));
test('silent audio with no detected speech stops generation without transcription', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'speech detector model');
const vadPath = await executable(
directory,
'vad',
"process.stdout.write('Detected 0 speech segments:\\n');",
);
await assert.rejects(
generateJapaneseSubtitles({ ...input, config: { ...input.config, vadModelPath, vadPath } }),
/No spoken dialogue detected/,
);
assert.equal((await readFile(input.callsPath, 'utf8')).trim().split('\n').length, 3);
assert.deepEqual(
(await readdir(directory)).filter((file) => file.endsWith('.srt')),
[],
);
}));
test('dialogue generation retains audible audio rejected by VAD', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'speech detector model');
const vadPath = await executable(
directory,
'vad',
"process.stdout.write('Detected 0 speech segments:\\n');",
);
const ffmpegPath = await executable(
directory,
'audible-ffmpeg',
`
const args = process.argv.slice(2);
if (args.at(-1) === '-') {
process.stdout.write('out_time_us=19000000\\nprogress=end\\n');
} else {
require('node:fs').writeFileSync(args.at(-1), 'wav');
}`,
);
const output = await generateJapaneseSubtitles({
...input,
config: { ...input.config, vadModelPath, vadPath, ffmpegPath },
});
assert.match(await readFile(output, 'utf8'), /00:00:03,500 --> 00:00:04,500\nこんにちは/);
}));
test('empty executable paths find tools on PATH and explicit overrides take precedence', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const previousPath = process.env.PATH;
process.env.PATH = directory;
try {
const config = {
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
modelPath: input.config.modelPath,
};
const result = await generateJapaneseSubtitles({ ...input, config });
assert.match(await readFile(result, 'utf8'), /こんにちは/);
await assert.rejects(
generateJapaneseSubtitles({
...input,
config: { ...config, ffprobePath: path.join(directory, 'missing-override') },
}),
/missing-override \(subtitleGeneration\.ffprobePath\) is not an executable file/,
);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
}
}));
test('generation rejects remote media, missing models, and missing tools before starting a subprocess', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
await assert.rejects(
generateJapaneseSubtitles({ ...input, mediaPath: 'https://example.com/movie.mkv' }),
/local media file/,
);
await assert.rejects(
generateJapaneseSubtitles({ ...input, config: { ...input.config, modelPath: '' } }),
/No Whisper model found/,
);
await assert.rejects(
generateJapaneseSubtitles({
...input,
config: {
...input.config,
vadModelPath: path.join(directory, 'vad.bin'),
vadPath: path.join(directory, 'missing-detector'),
},
}),
/missing-detector \(subtitleGeneration\.vadPath\) is not an executable file/,
);
await assert.rejects(readFile(input.callsPath), /ENOENT/);
}));
test(
'directory permission preflight rejects a write-only destination',
{
skip: process.platform === 'win32' || process.getuid?.() === 0,
},
() =>
fixture(async (directory) => {
const writeOnly = path.join(directory, 'write-only');
await mkdir(writeOnly);
try {
await chmod(writeOnly, 0o200);
await access(writeOnly, constants.W_OK);
await assert.rejects(ensureWritableDirectory(writeOnly), /write-only is not writable/);
} finally {
await chmod(writeOnly, 0o755);
}
}),
);
test(
'generation rejects an unwritable destination before extracting audio',
{
skip: process.platform === 'win32' || process.getuid?.() === 0,
},
() =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const readOnly = path.join(directory, 'read-only');
await mkdir(readOnly, { mode: 0o555 });
try {
await assert.rejects(
generateJapaneseSubtitles({ ...input, outputPath: path.join(readOnly, 'out.srt') }),
/read-only is not writable/,
);
await assert.rejects(readFile(input.callsPath), /ENOENT/);
} finally {
await chmod(readOnly, 0o755);
}
}),
);
test('process cancellation terminates work and bounds diagnostic output', () =>
fixture(async (directory) => {
const slow = await executable(
directory,
'slow',
"process.stdout.write('ready\\n');setInterval(()=>{},1000);",
);
const controller = new AbortController();
await assert.rejects(
runSubtitleGenerationProcess({
command: slow,
args: [],
signal: controller.signal,
onLine: () => controller.abort(),
}),
/cancelled/,
);
const failed = await executable(
directory,
'failed',
"process.stderr.write('x'.repeat(100000));process.exitCode=7;",
);
await assert.rejects(
runSubtitleGenerationProcess({ command: failed, args: [] }),
(error: unknown) =>
error instanceof Error &&
error.message.length < 66000 &&
error.message.includes('status 7'),
);
}));
test('download uses the pinned model revision and removes files that fail integrity', () =>
fixture(async (directory) => {
const originalFetch = globalThis.fetch;
globalThis.fetch = Object.assign(async (request: string | URL | Request) => {
assert.equal(
request,
'https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-small.bin',
);
return new Response('not a model');
}, originalFetch);
try {
await assert.rejects(
downloadSubtitleGenerationModel({
config: DEFAULT_SUBTITLE_GENERATION_CONFIG,
modelDirectory: directory,
}),
/integrity verification/,
);
assert.deepEqual(await readdir(directory), []);
} finally {
globalThis.fetch = originalFetch;
}
}));
+315
View File
@@ -0,0 +1,315 @@
import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
import { isMissingFile, resolveSubtitleGenerationModel } from './subtitle-generation-models';
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
import { formatTimestamp } from './subtitle-generation-srt';
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
import {
requireSubtitleGenerationTools,
resolveSubtitleGenerationTools,
} from './subtitle-generation-tools';
export {
downloadSubtitleGenerationModel,
resolveSubtitleGenerationModel,
} from './subtitle-generation-models';
export { resolveSubtitleGenerationTools } from './subtitle-generation-tools';
function numericTime(value: unknown): number | undefined {
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function parseAudioProbe(raw: string, selectedIndex: number | undefined) {
const value: unknown = JSON.parse(raw);
if (
typeof value !== 'object' ||
value === null ||
!('streams' in value) ||
!Array.isArray(value.streams)
) {
throw new Error('ffprobe did not return media streams.');
}
const streams = value.streams.flatMap((stream: unknown) => {
if (
typeof stream !== 'object' ||
stream === null ||
!('codec_type' in stream) ||
stream.codec_type !== 'audio' ||
!('index' in stream) ||
typeof stream.index !== 'number' ||
!Number.isInteger(stream.index) ||
stream.index < 0
)
return [];
const tags = 'tags' in stream ? stream.tags : undefined;
const language =
typeof tags === 'object' && tags !== null && 'language' in tags ? tags.language : undefined;
return [
{
index: stream.index,
start: 'start_time' in stream ? numericTime(stream.start_time) : undefined,
duration: 'duration' in stream ? numericTime(stream.duration) : undefined,
japanese: language === 'ja' || language === 'jpn',
},
];
});
const selected =
selectedIndex === undefined
? (streams.find((stream) => stream.japanese) ?? streams[0])
: streams.find((stream) => stream.index === selectedIndex);
if (!selected)
throw new Error(
selectedIndex === undefined
? 'No audio track found.'
: `Audio stream ${selectedIndex} was not found.`,
);
const format = 'format' in value ? value.format : undefined;
const formatStart =
typeof format === 'object' && format !== null && 'start_time' in format
? (numericTime(format.start_time) ?? 0)
: 0;
const duration =
selected.duration ??
(typeof format === 'object' && format !== null && 'duration' in format
? numericTime(format.duration)
: undefined);
// mpv rebases media timestamps to the container start. Extraction rebases the selected audio.
return { index: selected.index, offset: (selected.start ?? formatStart) - formatStart, duration };
}
function shiftSubtitleTimestamps(srt: string, offsetSeconds: number): string {
let cueCount = 0;
const result = srt.replace(
/(\d{2,}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2,}):(\d{2}):(\d{2}),(\d{3})/g,
(
_match,
sh: string,
sm: string,
ss: string,
sms: string,
eh: string,
em: string,
es: string,
ems: string,
) => {
cueCount += 1;
const start = Number(sh) * 3600000 + Number(sm) * 60000 + Number(ss) * 1000 + Number(sms);
const end = Number(eh) * 3600000 + Number(em) * 60000 + Number(es) * 1000 + Number(ems);
return `${formatTimestamp(start + offsetSeconds * 1000)} --> ${formatTimestamp(end + offsetSeconds * 1000)}`;
},
);
if (cueCount === 0)
throw new Error(
'Whisper produced no subtitle cues. The audio may contain no recognized speech.',
);
return result;
}
async function ensureAvailableOutput(outputPath: string): Promise<void> {
try {
await stat(outputPath);
} catch (error) {
if (isMissingFile(error)) return;
throw error;
}
throw new Error(`Subtitle output already exists: ${outputPath}`);
}
// Fail before extraction and transcription when the destination cannot take the file.
export async function ensureWritableDirectory(directory: string): Promise<void> {
try {
await access(directory, constants.W_OK | constants.X_OK);
} catch {
throw new Error(`Cannot save subtitles: ${directory} is not writable.`);
}
}
async function writeSubtitles(input: {
mediaPath: string;
outputPath?: string;
contents: string;
signal?: AbortSignal;
}): Promise<string> {
const parsed = path.parse(input.mediaPath);
const directory = input.outputPath ? path.dirname(path.resolve(input.outputPath)) : parsed.dir;
const temporaryDirectory = await mkdtemp(path.join(directory, '.subminer-subtitles-'));
try {
const staged = path.join(temporaryDirectory, 'subtitles.srt');
await writeFile(staged, input.contents, { flag: 'wx' });
for (let suffix = 0; ; suffix += 1) {
input.signal?.throwIfAborted();
const destination = input.outputPath
? path.resolve(input.outputPath)
: path.join(directory, `${parsed.name}.ja.generated${suffix ? `.${suffix}` : ''}.srt`);
try {
await publishSubtitleGenerationFile(staged, destination);
return destination;
} catch (error) {
if (
!input.outputPath &&
error instanceof Error &&
'code' in error &&
error.code === 'EEXIST'
)
continue;
throw error;
}
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
export async function generateJapaneseSubtitles(input: {
config: SubtitleGenerationConfig;
modelDirectory: string;
mediaPath: string;
audioStreamIndex?: number;
outputPath?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
input.signal?.throwIfAborted();
if (/^[a-z][a-z\d+.-]*:\/\//i.test(input.mediaPath))
throw new Error('Subtitle generation requires a local media file.');
const mediaPath = path.resolve(input.mediaPath);
if (!(await stat(mediaPath)).isFile())
throw new Error('Subtitle generation requires a local media file.');
if (input.outputPath) await ensureAvailableOutput(path.resolve(input.outputPath));
await ensureWritableDirectory(
input.outputPath ? path.dirname(path.resolve(input.outputPath)) : path.dirname(mediaPath),
);
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
if (model.kind === 'missing')
throw new Error(
'No Whisper model found. Download a model or configure an existing model path.',
);
if (model.kind === 'invalid') throw new Error(model.message);
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
const probe = await runSubtitleGenerationProcess({
command: tools.ffprobe,
args: [
'-v',
'error',
'-show_entries',
'stream=index,codec_type,start_time,duration:stream_tags=language:format=start_time,duration',
'-of',
'json',
mediaPath,
],
signal: input.signal,
});
const audio = parseAudioProbe(probe, input.audioStreamIndex);
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
try {
const wavPath = path.join(temporaryDirectory, 'audio.wav');
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
input.onProgress?.({ stage: 'extract', percent: 0, message: 'Extracting audio...' });
await runSubtitleGenerationProcess({
command: tools.ffmpeg,
args: [
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
'-i',
mediaPath,
'-map',
`0:${audio.index}`,
'-vn',
'-af',
'asetpts=PTS-STARTPTS',
'-ac',
'1',
'-ar',
'16000',
'-c:a',
'pcm_s16le',
'-progress',
'pipe:1',
'-nostats',
wavPath,
],
signal: input.signal,
onLine: (line) => {
const match = /^out_time_us=(\d+)$/.exec(line);
if (match && audio.duration && audio.duration > 0) {
input.onProgress?.({
stage: 'extract',
percent: Math.min(100, Math.floor(Number(match[1]) / 10000 / audio.duration)),
message: 'Extracting audio...',
});
}
},
});
input.onProgress?.({
stage: 'transcribe',
percent: 0,
message: 'Generating Japanese subtitles...',
});
let srt: string;
if (tools.vad !== null) {
srt = await transcribeSubtitleDialogue({
config: input.config,
tools: { ...tools, vad: tools.vad },
modelPath: model.path,
wavPath,
directory: temporaryDirectory,
signal: input.signal,
onProgress: input.onProgress,
});
} else {
await runSubtitleGenerationProcess({
command: tools.whisper,
args: [
'-m',
model.path,
'-f',
wavPath,
'-l',
'ja',
'-t',
String(input.config.threads),
'-osrt',
'-of',
subtitleBase,
'-pp',
],
signal: input.signal,
onLine: (line) => {
const match = /progress\s*=\s*(\d+(?:\.\d+)?)%/.exec(line);
if (match)
input.onProgress?.({
stage: 'transcribe',
percent: Math.min(100, Number(match[1])),
message: 'Generating Japanese subtitles...',
});
},
});
srt = await readFile(`${subtitleBase}.srt`, 'utf8');
}
input.signal?.throwIfAborted();
input.onProgress?.({ stage: 'write', message: 'Saving Japanese subtitles...' });
const contents = shiftSubtitleTimestamps(srt, audio.offset);
const outputPath = await writeSubtitles({
mediaPath,
outputPath: input.outputPath,
contents,
signal: input.signal,
});
input.onProgress?.({ stage: 'write', percent: 100, message: 'Japanese subtitles are ready.' });
return outputPath;
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
@@ -396,7 +396,8 @@ function createInjectedScriptVm(store: ReplayMessageStore): (script: string) =>
Set,
String,
});
return async (script: string) => await vm.runInContext(script, context);
// Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
}
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
@@ -64,7 +64,8 @@ export async function runInjectedYomitanScript(
script: string,
handler: (action: string, params: unknown) => unknown,
): Promise<unknown> {
return await vm.runInNewContext(script, createYomitanScriptSandbox(handler));
// Clone results into the host realm, matching Electron's process boundary.
return structuredClone(await vm.runInNewContext(script, createYomitanScriptSandbox(handler)));
}
// Persistent page context shared across executeJavaScript calls, matching the
@@ -75,7 +76,8 @@ function createPersistentYomitanScriptRunner(
handler: (action: string, params: unknown) => unknown,
): (script: string) => Promise<unknown> {
const context = vm.createContext(createYomitanScriptSandbox(handler));
return async (script: string) => await vm.runInContext(script, context);
// Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
}
// Deps whose parser window executes every injected script (profile metadata,
+2
View File
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openTsukihime: string | null | undefined;
openSubtitleGeneration: string | null | undefined;
openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined;
@@ -67,6 +68,7 @@ export function resolveConfiguredShortcuts(
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
+51 -17
View File
@@ -585,24 +585,58 @@ test('shouldDetachBackgroundLaunch only for first background invocation', () =>
test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', () => {
const calls: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => {
calls.push(`name:${name}`);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-paths-'));
const configDir = path.posix.join(tempDir, 'SubMiner');
try {
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => {
calls.push(`name:${name}`);
},
setPath: (key, value) => {
calls.push(`path:${key}:${value}`);
},
},
setPath: (key, value) => {
calls.push(`path:${key}:${value}`);
{
platform: 'linux',
homeDir: tempDir,
xdgConfigHome: tempDir,
existsSync: (candidate) =>
candidate === path.posix.join(tempDir, 'subminer', 'config.jsonc'),
},
},
{
platform: 'linux',
homeDir: '/home/tester',
xdgConfigHome: '/tmp/xdg',
existsSync: (candidate) => candidate === '/tmp/xdg/subminer/config.jsonc',
},
);
);
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
assert.equal(userDataPath, configDir);
assert.deepEqual(calls, ['name:SubMiner', `path:userData:${configDir}`]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('configureEarlyAppPaths creates a fresh macOS config directory before Electron uses it', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-first-launch-'));
const configDir = path.posix.join(homeDir, '.config', 'SubMiner');
try {
const app = {
setName: () => {},
setPath: (_key: 'userData', value: string) => {
assert.equal(value, configDir);
assert.equal(fs.statSync(value).isDirectory(), true);
},
};
const options = { platform: 'darwin', homeDir, xdgConfigHome: '' } satisfies Parameters<
typeof configureEarlyAppPaths
>[1];
assert.equal(fs.existsSync(path.join(homeDir, '.config')), false);
configureEarlyAppPaths(app, options);
const configPath = path.join(configDir, 'config.jsonc');
const existingConfig = '{"logging":{"level":"debug"}}\n';
fs.writeFileSync(configPath, existingConfig);
configureEarlyAppPaths(app, options);
assert.equal(fs.readFileSync(configPath, 'utf8'), existingConfig);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
+2
View File
@@ -260,6 +260,8 @@ export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPath
existsSync: options?.existsSync ?? fs.existsSync,
});
// The entry process requests its singleton lock before main-process config bootstrap.
fs.mkdirSync(userDataPath, { recursive: true });
app.setName(APP_NAME);
app.setPath('userData', userDataPath);
+173 -14
View File
@@ -33,6 +33,7 @@ import {
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
@@ -236,7 +237,10 @@ import {
createCycleSecondarySubModeRuntimeHandler,
} from './main/runtime/domains/mpv';
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitle,
} from './main/runtime/primary-subtitle-text';
import {
createBuildCopyCurrentSubtitleMainDepsHandler,
createBuildHandleMineSentenceDigitMainDepsHandler,
@@ -386,6 +390,7 @@ import {
detectCommandLineLauncher,
installBun as installCommandLineBun,
installLauncher as installCommandLineLauncher,
refreshManagedCommandLineLauncher,
} from './main/runtime/command-line-launcher';
import {
createWindowsMpvLaunchDeps,
@@ -460,11 +465,23 @@ import {
} from './main/early-single-instance';
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
import { getSharedRemoteMediaWindowCache } from './core/services/remote-media-window-cache';
import { resolveMediaGenerationInput } from './anki-integration/media-source';
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
import {
collectMediaTimingContextLines,
createMediaTimingReviewRuntime,
} from './main/runtime/media-timing-review';
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
@@ -1438,6 +1455,10 @@ const createCommandLineLauncherRuntimeOptions = () => ({
cwd: process.cwd(),
resourcesPath: process.resourcesPath,
appExePath: process.execPath,
appVersion: app.getVersion(),
bundledBunPath: app.isPackaged
? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun')
: undefined,
});
const firstRunSetupService = createFirstRunSetupService({
platform: process.platform,
@@ -1523,7 +1544,7 @@ const firstRunSetupService = createFirstRunSetupService({
},
installCommandLineLauncher: async () => {
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
const ok = snapshot.status === 'ready' || snapshot.status === 'installed_bun_missing';
const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path';
return {
ok,
installPath: snapshot.installPath,
@@ -1837,28 +1858,31 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
}
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
const canonical = resolveCanonicalPrimarySubtitle({
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
// event, so a finished caption row lingering beside a fresh line would end up on the
// card. The parsed view also carries the cue's own timings for the clip range.
const resolved = resolvePrimarySubtitle({
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
// Same validity bar as the live capture path: an unusable resolved span must fall
// back rather than hand mining an empty line or an inverted range.
const canonicalText = canonical?.text.trim();
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
if (
!canonical ||
!canonicalText ||
!Number.isFinite(canonical.startTime) ||
!Number.isFinite(canonical.endTime) ||
canonical.endTime <= canonical.startTime
!resolved ||
!resolvedText ||
!Number.isFinite(resolved.startTime) ||
!Number.isFinite(resolved.endTime) ||
resolved.endTime <= resolved.startTime
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return {
source: 'overlay',
text: canonicalText,
startTime: canonical.startTime,
endTime: canonical.endTime,
text: resolvedText,
startTime: resolved.startTime,
endTime: resolved.endTime,
capturedAtMs: Date.now(),
};
}
@@ -2982,6 +3006,49 @@ function createOverlayHostedModalOpenDeps(): {
};
}
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
getMpvExecutablePath: () =>
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
resolveMediaSource: async () => {
const resolved = await resolveMediaGenerationInput(appState.mpvClient, 'audio', {
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
remoteCacheMode: shouldRequireYoutubeMediaCacheForCurrentPlayback() ? 'required' : 'optional',
});
return resolved
? {
path: resolved.path,
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
singleResolvedStream: resolved.singleResolvedStream,
}
: null;
},
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
getSubtitleContextLines: (range) =>
collectMediaTimingContextLines({
cues: appState.activeParsedSubtitleCues,
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
startTime: range.startTime,
endTime: range.endTime,
}),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
}
}
},
showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
});
function openOverlayHostedModalWithOsd(
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
unavailableMessage: string,
@@ -3029,6 +3096,14 @@ function openTsukihimeOverlay(): void {
);
}
function openSubtitleGenerationOverlay(): void {
openOverlayHostedModalWithOsd(
openSubtitleGenerationModal,
'Subtitle generation overlay unavailable.',
'Failed to open subtitle generation overlay.',
);
}
function openSessionHelpOverlay(): void {
openOverlayHostedModalWithOsd(
openSessionHelpModalRuntime,
@@ -4167,6 +4242,7 @@ const {
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop();
@@ -5319,6 +5395,7 @@ function initializeOverlayRuntime(): void {
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
syncOverlayMpvSubtitleSuppression();
}
@@ -5452,7 +5529,7 @@ const { getChangelogSnapshot } = createChangelogRuntime({
logWarn: (message) => logger.warn(message),
});
const { getUpdateService } = createUpdateServiceRuntime({
const { getUpdateService, takePendingLauncherMigrationPath } = createUpdateServiceRuntime({
userDataPath: USER_DATA_PATH,
getUpdatesConfig: () => configService.getConfig().updates,
logInfo: (message) => logger.info(message),
@@ -5665,6 +5742,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openJimaku: () => openJimakuOverlay(),
openTsukihime: () => openTsukihimeOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
openControllerDebug: () => openControllerDebugOverlay(),
@@ -5730,6 +5808,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
},
mainDeps: {
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
focusMainWindow: () => {
@@ -5763,6 +5845,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
},
onOverlayModalClosed: (modal, senderWindow) => {
if (modal === 'media-timing-review') {
void mediaTimingReviewRuntime.dispose();
}
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
subtitleSidebarRequestedOpen = false;
}
@@ -5883,6 +5968,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
const client = appState.mpvClient;
if (!client?.connected) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5902,6 +5991,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5916,6 +6009,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
})
) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5932,6 +6029,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
});
if (!resolvedSource) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5942,6 +6043,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5955,6 +6060,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues,
currentTimeSec,
currentSubtitle,
@@ -5965,6 +6074,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
} catch {
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
@@ -5985,6 +6098,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getMpvInputBindings: () =>
readMpvInputBindings({
getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
platform:
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'win32'
: 'linux',
}),
getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
@@ -6119,6 +6243,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext,
);
appState.ankiIntegration?.setMediaTimingReviewCallback(
mediaTimingReviewRuntime.requestReview,
);
},
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
@@ -6386,6 +6513,15 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
runAndApplyStartupState();
void app.whenReady().then(() => {
void takePendingLauncherMigrationPath(async (pendingLauncherPath) => {
const acknowledgedPaths = await refreshManagedCommandLineLauncher({
...createCommandLineLauncherRuntimeOptions(),
additionalLauncherPaths: pendingLauncherPath ? [pendingLauncherPath] : [],
});
return pendingLauncherPath !== undefined && acknowledgedPaths.includes(pendingLauncherPath);
}).catch((error) => {
logger.warn('Failed to refresh the installed command-line launcher', error);
});
if (!shouldStartAutomaticUpdateChecks(appState.initialArgs)) {
return;
}
@@ -6452,6 +6588,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
if (overlayManager.getModalWindow() !== window) {
return;
}
void mediaTimingReviewRuntime.dispose();
overlayManager.setModalWindow(null);
}
},
@@ -6765,3 +6902,25 @@ function setOverlayVisible(visible: boolean): void {
}
registerIpcRuntimeHandlers();
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>
path.join(path.dirname(configService.getConfigPath()), 'models', 'whisper'),
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed())
window.webContents.send(IPC_CHANNELS.event.subtitleGenerationProgress, progress);
}
},
});
registerSubtitleGenerationIpc({
ipc: ipcMain,
isAllowedSender: (sender) =>
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
(window) => window && !window.isDestroyed() && window.webContents === sender,
),
runtime: subtitleGenerationRuntime,
openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
});
app.on('before-quit', () => subtitleGenerationRuntime.cancel());
+10
View File
@@ -62,6 +62,10 @@ export interface MainIpcRuntimeServiceDepsParams {
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
@@ -79,6 +83,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
@@ -260,6 +265,10 @@ export function createMainIpcRuntimeServiceDeps(
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
previewMediaTimingReview: params.previewMediaTimingReview,
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
resolveMediaTimingReview: params.resolveMediaTimingReview,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay,
@@ -275,6 +284,7 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings,
getMpvInputBindings: params.getMpvInputBindings,
getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction,
+62
View File
@@ -382,6 +382,8 @@ test('anime browser modal keeps its document warm across close on Linux', () =>
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
assert.equal(modalWindow.isVisible(), false);
runtime.notifyOverlayModalOpened('anime-browser');
assert.equal(modalWindow.isVisible(), true);
});
@@ -883,6 +885,7 @@ test('modal fallback reveal skips showing window when content is not ready', asy
setModalWindowBounds: () => {},
},
{
platform: 'darwin',
scheduleRevealFallback: (callback) => {
scheduledReveal = callback;
return { scheduled: true } as never;
@@ -1418,3 +1421,62 @@ test('modal placement reconcile cancels stale retry ladder after a newer visible
globalThis.clearTimeout = originalClearTimeout;
}
});
test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
const modalWindow = createMockWindow();
const order: string[] = [];
const hideMain = mainWindow.hide;
mainWindow.hide = () => {
order.push('main:hide');
hideMain();
};
const showModal = modalWindow.show;
modalWindow.show = () => {
order.push('modal:show');
showModal();
};
let revealScheduled = false;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => mainWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
platform: 'linux',
scheduleRevealFallback: () => {
revealScheduled = true;
return { scheduled: true } as never;
},
clearRevealFallback: () => {},
},
);
const open = () =>
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'review' },
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
);
assert.equal(open(), true);
assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]);
assert.equal(revealScheduled, false);
assert.equal(modalWindow.getShowCount(), 0);
assert.equal(mainWindow.getHideCount(), 0);
// The open retry must not map the window before the renderer answers either.
assert.equal(open(), true);
assert.equal(modalWindow.getShowCount(), 0);
runtime.notifyOverlayModalOpened('media-timing-review');
assert.deepEqual(order, ['main:hide', 'modal:show']);
assert.equal(mainWindow.isVisible(), false);
assert.equal(modalWindow.isVisible(), true);
assert.equal(modalWindow.ignoreMouseEvents, false);
});
+21 -5
View File
@@ -93,6 +93,12 @@ export function createOverlayModalRuntimeService(
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
const shouldReuseModalWindowAfterClose = (): boolean =>
platform === 'darwin' || (platform !== 'win32' && retainModalWindowState);
// On Linux (Hyprland) every placement dispatch on a mapped window (resize, move, set_prop)
// blanks the still-visible overlay for a few frames while mpv is fullscreen. Revealing the
// dedicated modal window before its renderer has the modal open runs the placement ladder,
// and the open retry, against a visible overlay, which the user sees as flicker. Keep the
// window unmapped until the renderer acknowledges the open, then hide the overlay first.
const deferModalRevealUntilOpened = platform === 'linux';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -463,7 +469,9 @@ export function createOverlayModalRuntimeService(
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
if (!wasVisible) {
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
if (deferModalRevealUntilOpened) {
// notifyOverlayModalOpened reveals the window once the renderer has the modal open.
} else if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
showModalWindow(modalWindow);
} else {
scheduleModalWindowReveal(modalWindow);
@@ -567,15 +575,23 @@ export function createOverlayModalRuntimeService(
}
const modalWindow = deps.getModalWindow();
const targetIsModalWindow =
modalWindow !== null && !modalWindow.isDestroyed() && targetWindow === modalWindow;
const handOffMainWindowToModal = (): void => {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
};
if (targetIsModalWindow && deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
if (targetIsModalWindow && !deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
};
@@ -48,12 +48,13 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
await cleanup();
assert.equal(calls.length, 35);
assert.equal(calls.length, 36);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -62,6 +63,7 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-remote-media-windows'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -104,6 +106,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -32,6 +32,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -76,6 +77,7 @@ export function createOnWillQuitCleanupHandler(deps: {
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
await stopSyncAutoScheduler;
};
@@ -75,6 +75,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -157,6 +158,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -210,6 +212,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -61,6 +61,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -148,6 +149,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
+54 -12
View File
@@ -32,6 +32,8 @@ export type CommonOptions = FsDeps & {
resourcesPath?: string;
appExePath?: string;
launcherResourcePath?: string;
bundledBunPath?: string;
appVersion?: string;
runCommand?: RunCommand;
};
@@ -143,8 +145,40 @@ function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
function quoteForWindowsShell(value: string): string {
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
/*!
* Windows command escaping adapted from cross-spawn 7.0.6.
*
* The MIT License (MIT)
*
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
// are consumed by cmd, leaving the quoted argument unchanged for the command.
function escapeWindowsShellArgument(value: string): string {
const quotesEscaped = value
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
.replace(/(?=(\\+?)?)\1$/, '$1$1');
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
}
function createDefaultRunCommand(): RunCommand {
@@ -153,16 +187,24 @@ function createDefaultRunCommand(): RunCommand {
const useShell = needsWindowsShell(command);
let child: ReturnType<typeof spawn>;
try {
child = useShell
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
env: options.env ?? process.env,
windowsHide: false,
shell: true,
})
: spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
const env = options.env ?? process.env;
if (useShell) {
const shellCommand = [
escapeWindowsShellArgument(command),
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
env,
windowsHide: false,
windowsVerbatimArguments: true,
});
} else {
child = spawn(command, args, {
env,
windowsHide: false,
});
}
} catch (error) {
resolve({
exitCode: 1,
+35 -23
View File
@@ -91,43 +91,54 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
if (process.platform !== 'win32') return;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer cmd & 100% ! '));
const scriptPath = path.join(tempDir, 'argv.cmd');
const outputPath = path.join(tempDir, 'argv.txt');
const argvScriptPath = path.join(tempDir, 'argv.js');
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.writeFileSync(
argvScriptPath,
'process.stdout.write(JSON.stringify(process.argv.slice(2)));',
'utf8',
);
fs.writeFileSync(
scriptPath,
[
'@echo off',
'setlocal DisableDelayedExpansion',
'> "%SUBMINER_ARGV_OUT%" (',
' echo 1=%~1',
' echo 2=%~2',
' echo 3=%~3',
' echo 4=%~4',
' echo 5=%~5',
' echo 6=%~6',
')',
'"%SUBMINER_TEST_RUNTIME%" "%SUBMINER_ARGV_SCRIPT%" %*',
'exit /b %errorlevel%',
'',
].join('\r\n'),
'utf8',
);
const result = await getRunCommand({})(
scriptPath,
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
{
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
const args = [
'plain',
'has space',
'a&b',
'x|y',
'p%TEMP%q',
'bang!z',
'caret^z',
'<left>',
'say "hi"',
'slash\\"quote',
'trailing\\',
'',
'日本語',
];
const result = await getRunCommand({})(scriptPath, args, {
env: {
...process.env,
SUBMINER_ARGV_SCRIPT: argvScriptPath,
SUBMINER_TEST_RUNTIME: process.execPath,
},
);
});
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(
fs.readFileSync(outputPath, 'utf8'),
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
);
assert.deepEqual(JSON.parse(result.stdout), args);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
@@ -189,7 +200,7 @@ test('resolveLauncherInstallTarget prefers writable user bin on Linux', async ()
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
});
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
test('resolveLauncherInstallTarget offers a user bin without writable PATH dirs', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
@@ -200,8 +211,9 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
},
});
assert.equal(target.status, 'not_installable');
assert.equal(target.installPath, null);
assert.equal(target.status, 'not_installed');
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
assert.match(target.message ?? '', /export PATH=/);
});
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
+205 -36
View File
@@ -1,6 +1,13 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
cleanupOldWindowsManagedRuntimes,
isManagedLauncher,
managedLauncherContent,
shellQuote,
stageManagedLauncher,
} from './managed-launcher';
import {
accessSyncOf,
envOf,
@@ -115,8 +122,13 @@ export function resolveBunInstallCommand(
}
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
const bunPath = findCommand('bun', options);
const installCommand = resolveBunInstallCommand(options);
const bundled = options.bundledBunPath;
const bunPath = bundled
? existsSyncOf(options)(bundled)
? bundled
: null
: findCommand('bun', options);
const installCommand = bundled ? null : resolveBunInstallCommand(options);
if (!bunPath) {
return {
status: 'missing',
@@ -124,7 +136,9 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: null,
message: bundled
? 'The included launcher runtime is missing. Reinstall SubMiner to repair it.'
: null,
};
}
@@ -139,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
version: result.stdout.trim() || null,
installMethod: null,
installCommand: null,
message: null,
message: bundled ? 'Included with SubMiner. No separate Bun installation is needed.' : null,
};
}
@@ -158,9 +172,11 @@ export function resolveLauncherResourcePath(options: CommonOptions): string {
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
const packaged = resourcesPath
? platformPath.join(resourcesPath, 'launcher', 'subminer.js')
: null;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer.js');
}
function isWritableDir(candidate: string, options: CommonOptions): boolean {
@@ -183,6 +199,21 @@ function collectPathDirs(options: CommonOptions): string[] {
return dirs;
}
function preferredLauncherDirs(platform: NodeJS.Platform, homeDir: string): string[] {
return platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
}
export async function resolveLauncherInstallTarget(
options: CommonOptions & WindowsPathOptions = {},
): Promise<LauncherSnapshot> {
@@ -201,19 +232,7 @@ export async function resolveLauncherInstallTarget(
const homeDir = options.homeDir ?? os.homedir();
const pathDirs = collectPathDirs(options);
const preferred =
platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
const preferred = preferredLauncherDirs(platform, homeDir);
const manualPreferred =
platform === 'darwin'
? [
@@ -251,13 +270,15 @@ export async function resolveLauncherInstallTarget(
isWritableDir(dir, options),
);
if (!selected) {
const pathDir = path.posix.join(homeDir, '.local', 'bin');
const installPath = path.posix.join(pathDir, 'subminer');
return {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
status: existsSyncOf(options)(installPath) ? 'not_on_path' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir,
shadowedBy: null,
message: 'No writable directory was found on your command-line PATH.',
message: `Add ${pathDir} to your terminal PATH: export PATH=${shellQuote(pathDir)}:"$PATH". Save this in your shell configuration for future terminals.`,
};
}
const installPath = path.posix.join(selected, 'subminer');
@@ -283,7 +304,29 @@ export async function detectLauncher(
const launcherResourcePath = resolveLauncherResourcePath(options);
const appExePath = options.appExePath ?? process.execPath;
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
if (options.bundledBunPath && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!isManagedLauncher(content)) {
return {
...target,
status: 'not_installed',
message: 'Reinstall the launcher to use the runtime included with SubMiner.',
};
}
if (
content !==
managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? appExePath,
})
) {
return {
...target,
status: 'not_installed',
message: 'Reinstall the launcher to refresh its SubMiner location.',
};
}
} else if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
return {
@@ -305,26 +348,19 @@ export async function detectLauncher(
}
if (!existsSyncOf(options)(expectedPath))
return { ...target, status: 'not_installed', commandPath: null };
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message: 'Launcher exists but its directory is not on PATH.',
};
}
const bunSnapshot = options.bunSnapshot ?? (await detectBun(options));
if (bunSnapshot.status !== 'ready') {
return {
...target,
status: 'installed_bun_missing',
commandPath,
message: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
message: options.bundledBunPath
? bunSnapshot.message
: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
};
}
const result = await getRunCommand(options)(commandPath, ['--help'], {
const result = await getRunCommand(options)(expectedPath, ['--help'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
@@ -336,6 +372,16 @@ export async function detectLauncher(
message: failureMessage(result, 'subminer --help failed'),
};
}
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message:
target.message ??
`Launcher installed. Add ${target.pathDir} to your terminal PATH: export PATH=${shellQuote(target.pathDir ?? '')}:"$PATH". Save this in your shell configuration for future terminals.`,
};
}
return { ...target, status: 'ready', commandPath, message: null };
}
@@ -354,6 +400,48 @@ export async function installLauncher(
};
}
if (options.bundledBunPath) {
const bun = await detectBun(options);
if (bun.status !== 'ready')
return {
...target,
status: 'failed',
message: bun.message ?? 'The included launcher runtime failed to start.',
};
try {
stageManagedLauncher({
...options,
bundledBunPath: options.bundledBunPath,
launcherResourcePath,
force: true,
});
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
target.installPath,
managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
}),
);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
if (platform === 'win32') {
cleanupOldWindowsManagedRuntimes(options);
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
if (nextPath && options.env) {
options.env.PATH = nextPath;
options.env.Path = nextPath;
}
}
return await detectLauncher({ ...options, bunSnapshot: bun });
} catch (error) {
return {
...target,
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
}
if (platform === 'win32') {
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
@@ -375,6 +463,8 @@ export async function installLauncher(
};
}
} else {
if (!existsSyncOf(options)(target.pathDir))
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
}
@@ -384,6 +474,7 @@ export async function installLauncher(
export async function installBun(
options: CommonOptions & WindowsPathOptions = {},
): Promise<BunSnapshot> {
if (options.bundledBunPath) return detectBun(options);
const platform = platformOf(options);
if (platform === 'win32') {
const bunDir = defaultBunRepairPath(options);
@@ -455,6 +546,84 @@ export async function installBun(
};
}
// Runs at app startup. Migrates recognized launchers in the standard bin dirs,
// the setup install target, and any paths a deferred update handed over.
// Returns paths that were refreshed or are no longer eligible for migration.
export async function refreshManagedCommandLineLauncher(
options: CommonOptions & WindowsPathOptions & { additionalLauncherPaths?: string[] },
): Promise<string[]> {
if (!options.bundledBunPath) return [];
const target = await resolveLauncherInstallTarget(options);
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
// cmd.exe reads a batch file incrementally while it runs, so the launcher that
// started this app is left alone until a later app start rewrites it.
const runningLauncherPath =
platform === 'win32' ? envOf(options).SUBMINER_LAUNCHER_PATH : undefined;
const isRunningLauncher = (candidate: string) =>
runningLauncherPath !== undefined &&
platformPath.normalize(candidate).toLowerCase() ===
platformPath.normalize(runningLauncherPath).toLowerCase();
const candidates = new Set([
...(target.installPath ? [target.installPath] : []),
...(options.additionalLauncherPaths ?? []),
...(platform === 'win32'
? []
: preferredLauncherDirs(platform, options.homeDir ?? os.homedir()).map((directory) =>
path.posix.join(directory, 'subminer'),
)),
]);
const readFile = options.readFileSync ?? fs.readFileSync;
const acknowledgedPaths: string[] = [];
let payload: ReturnType<typeof stageManagedLauncher> | undefined;
for (const candidate of candidates) {
if (isRunningLauncher(candidate)) continue;
if (!existsSyncOf(options)(candidate)) {
acknowledgedPaths.push(candidate);
continue;
}
let existing: string;
try {
existing = String(readFile(candidate, 'utf8'));
} catch {
continue;
}
const legacy =
(existing.startsWith('#!/usr/bin/env bun\n') &&
(existing.includes('SubMiner launcher') ||
existing.includes('Launch MPV with SubMiner'))) ||
(platform === 'win32' &&
existing ===
windowsShimContent(
options.appExePath ?? process.execPath,
resolveLauncherResourcePath(options).replace(/subminer\.js$/, 'subminer'),
));
if (!isManagedLauncher(existing) && !legacy) {
acknowledgedPaths.push(candidate);
continue;
}
if (!isWritableDir(pathModuleFor(platform).dirname(candidate), options)) continue;
try {
accessSyncOf(options)(candidate, fs.constants.W_OK);
} catch {
continue;
}
payload ??= stageManagedLauncher({
...options,
bundledBunPath: options.bundledBunPath,
launcherResourcePath: resolveLauncherResourcePath(options),
});
const content = managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
});
if (existing !== content) (options.writeFileSync ?? fs.writeFileSync)(candidate, content);
acknowledgedPaths.push(candidate);
}
if (platform === 'win32' && payload) cleanupOldWindowsManagedRuntimes(options);
return acknowledgedPaths;
}
export async function detectCommandLineLauncher(
options: CommonOptions & WindowsPathOptions = {},
): Promise<CommandLineLauncherSnapshot> {
@@ -52,6 +52,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
},
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.media.normalizeAudio = false;
config.ankiConnect.media.mirrorMpvVolume = false;
config.ankiConnect.media.reviewTiming = true;
const ankiPatches: unknown[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
},
config,
);
applyHotReload(
{
hotReloadFields: ['ankiConnect.media.reviewTiming'],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(ankiPatches, [
{ media: { normalizeAudio: false } },
{ media: { mirrorMpvVolume: false } },
{ media: { reviewTiming: true } },
]);
});
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
}
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
}
if (Object.keys(mediaPatch).length > 0) {
patch.media = mediaPatch;
}
@@ -271,7 +271,7 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
test('buildFirstRunSetupHtml reports a broken included runtime in the optional launcher controls', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
@@ -294,9 +294,9 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'network failed',
installMethod: null,
installCommand: null,
message: 'Included Bun runtime is missing.',
},
launcher: {
status: 'installed_bun_missing',
@@ -311,14 +311,11 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
});
assert.match(html, /Command line launcher/);
assert.match(html, /Optional\. Setup can finish without Bun or the launcher\./);
assert.match(html, /Bun runtime/);
assert.match(html, /Optional\. Install the launcher to use SubMiner from your terminal\./);
assert.match(html, /Failed/);
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
assert.match(html, /Install Bun/);
assert.match(html, /action=install-bun/);
assert.match(html, /SubMiner launcher/);
assert.match(html, /Installed, Bun missing/);
assert.match(html, /Reinstall SubMiner to repair it/);
assert.match(html, /<button disabled[^>]+action=install-command-line-launcher/);
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/);
assert.match(
@@ -360,7 +357,7 @@ test('buildFirstRunSetupHtml disables launcher install when no target is install
assert.match(
html,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install command-line launcher<\/button>/,
);
});
+13 -64
View File
@@ -1,9 +1,5 @@
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type {
BunSnapshot,
CommandLineLauncherSnapshot,
LauncherSnapshot,
} from './command-line-launcher';
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
type FocusableWindowLike = {
focus: () => void;
@@ -74,29 +70,12 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
function formatCommand(command: string[] | null): string {
return command?.join(' ') ?? 'No install command detected';
}
function getBunStatusLabel(status: BunSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installing':
return 'Installing';
case 'failed':
return 'Failed';
case 'missing':
return 'Missing';
}
}
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installed_bun_missing':
return 'Installed, Bun missing';
return 'Unavailable';
case 'not_installed':
return 'Not installed';
case 'not_on_path':
@@ -110,13 +89,6 @@ function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
}
}
function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installing') return 'muted';
return 'warn';
}
function getLauncherTone(
status: LauncherSnapshot['status'],
): 'ready' | 'warn' | 'muted' | 'danger' {
@@ -135,49 +107,26 @@ function renderCommandLineLauncherSection(
const bun = commandLineLauncher.bun;
const launcher = commandLineLauncher.launcher;
const bunMeta =
bun.status === 'ready'
? [
bun.commandPath ? `Path: ${bun.commandPath}` : null,
bun.version ? `Version: ${bun.version}` : null,
].filter(Boolean)
: [
bun.installMethod ? `Method: ${bun.installMethod}` : null,
`Command: ${formatCommand(bun.installCommand)}`,
bun.message,
].filter(Boolean);
const runtimeUnavailable = bun.status !== 'ready';
const launcherStatus = runtimeUnavailable ? 'failed' : launcher.status;
const runtimeError = bun.installCommand
? "The launcher runtime is unavailable. Install the project's Bun dependency, then refresh."
: 'The launcher runtime is unavailable. Reinstall SubMiner to repair it.';
const launcherMeta = [
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
launcher.message,
bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null,
runtimeUnavailable ? runtimeError : launcher.message,
].filter(Boolean);
const bunInstallButton =
bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: '';
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
const launcherButtonDisabled =
launcher.status === 'not_installable' || bun.status !== 'ready' ? 'disabled' : '';
return `
<section class="setup-section">
<div class="section-head">
<h2>Command line launcher</h2>
<div class="meta">Optional. Setup can finish without Bun or the launcher.</div>
</div>
<div class="card block">
<div class="card-head">
<div>
<strong>Bun runtime</strong>
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
</div>
<div class="inline-actions">
${bunInstallButton}
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
<div class="meta">Optional. Install the launcher to use SubMiner from your terminal.</div>
</div>
<div class="card block">
<div class="card-head">
@@ -185,10 +134,10 @@ function renderCommandLineLauncherSection(
<strong>SubMiner launcher</strong>
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
${renderStatusBadge(getLauncherStatusLabel(launcherStatus), getLauncherTone(launcherStatus))}
</div>
<div class="inline-actions">
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install command-line launcher</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more