mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
279 files changed
+23054
-1603
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -237,6 +237,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleGeneration',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({}), []);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user