mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix: delegate multi-line digit selection to visible overlay (#78)
This commit is contained in:
@@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
|
||||
test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => {
|
||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||
config: {
|
||||
fields: {
|
||||
@@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(leadInSeconds, 1.75);
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
|
||||
|
||||
@@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
|
||||
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
|
||||
}
|
||||
|
||||
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
|
||||
const configuredPadding = config.media?.audioPadding;
|
||||
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
|
||||
return configuredPadding;
|
||||
}
|
||||
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
|
||||
}
|
||||
|
||||
export async function probeAudioDurationSeconds(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
@@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
||||
totalLeadInSeconds += durationSeconds;
|
||||
}
|
||||
|
||||
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
|
||||
return totalLeadInSeconds;
|
||||
}
|
||||
|
||||
@@ -175,3 +175,99 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||
assert.equal(mergeCalls.length, 0);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const { service, updatedFields, storedMedia } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageFormat: 'jpg',
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: false,
|
||||
overwriteImage: false,
|
||||
},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getTimingTracker: () =>
|
||||
({
|
||||
findTiming: (text: string) => {
|
||||
if (text === '一行目') return { startTime: 10, endTime: 12 };
|
||||
if (text === '二行目') return { startTime: 12.5, endTime: 14 };
|
||||
return null;
|
||||
},
|
||||
}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'stream-open-filename');
|
||||
return edlSource;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
|
||||
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
@@ -237,14 +237,19 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
@@ -271,12 +276,14 @@ export class CardCreationService {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(
|
||||
mpvClient.currentVideoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
animatedLeadInSeconds,
|
||||
);
|
||||
const imageBuffer = videoPath
|
||||
? await this.generateImageBuffer(
|
||||
videoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
animatedLeadInSeconds,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
|
||||
@@ -61,6 +61,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.texthooker.launchAtStartup, false);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||
assert.equal(config.anilist.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||
|
||||
@@ -51,7 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
animatedMaxHeight: 0,
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
audioPadding: 0.5,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
handleMultiCopyDigit,
|
||||
mineSentenceCard,
|
||||
} from './mining';
|
||||
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
|
||||
|
||||
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
|
||||
const osd: string[] = [];
|
||||
@@ -207,3 +208,76 @@ test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
const created: Array<{ sentence: string; startTime: number; endTime: number }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('same', 1, 2);
|
||||
tracker.recordSubtitle('other', 3, 4);
|
||||
tracker.recordSubtitle('same', 5, 6);
|
||||
|
||||
handleMineSentenceDigit(3, {
|
||||
subtitleTimingTracker: tracker,
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime) => {
|
||||
created.push({ sentence, startTime, endTime });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => undefined,
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(created, [{ sentence: 'same other same', startTime: 1, endTime: 6 }]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
const recordSubtitleWithSecondary = tracker.recordSubtitle as (
|
||||
text: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
secondaryText?: string,
|
||||
) => void;
|
||||
|
||||
try {
|
||||
recordSubtitleWithSecondary.call(tracker, 'one', 1, 2, 'translation one');
|
||||
recordSubtitleWithSecondary.call(tracker, 'two', 3, 4, 'translation two');
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: tracker,
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'current translation only',
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{ sentence: 'one two', secondarySub: 'translation one translation two' },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||
|
||||
interface SubtitleTimingTrackerLike {
|
||||
getRecentBlocks: (count: number) => string[];
|
||||
getRecentEntries?: (count: number) => SubtitleTimingBlock[];
|
||||
getCurrentSubtitle: () => string | null;
|
||||
findTiming: (text: string) => { startTime: number; endTime: number } | null;
|
||||
}
|
||||
@@ -79,6 +82,19 @@ function requireAnkiIntegration(
|
||||
return ankiIntegration;
|
||||
}
|
||||
|
||||
function getSecondarySubTextForMinedBlocks(
|
||||
entries: SubtitleTimingBlock[] | undefined,
|
||||
getCurrentSecondarySubText: () => string | undefined,
|
||||
): string | undefined {
|
||||
const secondaryBlocks = entries
|
||||
?.map((entry) => entry.secondaryText?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
if (secondaryBlocks && secondaryBlocks.length > 0) {
|
||||
return secondaryBlocks.join(' ');
|
||||
}
|
||||
return getCurrentSecondarySubText();
|
||||
}
|
||||
|
||||
export async function updateLastCardFromClipboard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
readClipboardText: () => string;
|
||||
@@ -146,17 +162,20 @@ export function handleMineSentenceDigit(
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
|
||||
const blocks = deps.subtitleTimingTracker.getRecentBlocks(count);
|
||||
const entries = deps.subtitleTimingTracker.getRecentEntries?.(count);
|
||||
const blocks =
|
||||
entries?.map((entry) => entry.displayText) ?? deps.subtitleTimingTracker.getRecentBlocks(count);
|
||||
if (blocks.length === 0) {
|
||||
deps.showMpvOsd('No subtitle history available');
|
||||
return;
|
||||
}
|
||||
|
||||
const timings: { startTime: number; endTime: number }[] = [];
|
||||
for (const block of blocks) {
|
||||
const timing = deps.subtitleTimingTracker.findTiming(block);
|
||||
if (timing) timings.push(timing);
|
||||
}
|
||||
const timings: { startTime: number; endTime: number }[] =
|
||||
entries ??
|
||||
blocks.flatMap((block) => {
|
||||
const timing = deps.subtitleTimingTracker?.findTiming(block);
|
||||
return timing ? [timing] : [];
|
||||
});
|
||||
|
||||
if (timings.length === 0) {
|
||||
deps.showMpvOsd('Subtitle timing not found');
|
||||
@@ -166,9 +185,13 @@ export function handleMineSentenceDigit(
|
||||
const rangeStart = Math.min(...timings.map((t) => t.startTime));
|
||||
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
|
||||
const sentence = blocks.join(' ');
|
||||
const secondarySubText = getSecondarySubTextForMinedBlocks(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
const cardsToMine = 1;
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
|
||||
@@ -843,7 +843,7 @@ export function createStatsApp(
|
||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||
const mediaGen = new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0.5;
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
|
||||
+90
-9
@@ -464,6 +464,7 @@ import {
|
||||
composeStartupLifecycleHandlers,
|
||||
} from './main/runtime/composers';
|
||||
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
|
||||
import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection';
|
||||
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
||||
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
||||
import {
|
||||
@@ -547,7 +548,12 @@ import {
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
@@ -988,8 +994,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
refreshCurrentSubtitle: (text: string) => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
refreshSubtitleSidebarSource: async (sourcePath: string) => {
|
||||
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath);
|
||||
refreshSubtitleSidebarSource: async (sourcePath: string, mediaPath?: string) => {
|
||||
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {
|
||||
await startTokenizationWarmups();
|
||||
@@ -1076,9 +1082,18 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||
notifyPrimarySubtitleLoaded: () =>
|
||||
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
||||
warn: (message: string) => logger.warn(message),
|
||||
log: (message: string) => logger.info(message),
|
||||
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
||||
createSubtitleTempDir: () =>
|
||||
fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-youtube-subtitles-')),
|
||||
cleanupSubtitleTempDirs: (dirs) => {
|
||||
for (const dir of dirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
@@ -1545,6 +1560,20 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
|
||||
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
|
||||
getCurrentSubtitleState: async () => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
return null;
|
||||
}
|
||||
const [sid, trackList] = await Promise.all([
|
||||
client.requestProperty('sid').catch(() => null),
|
||||
client.requestProperty('track-list').catch(() => null),
|
||||
]);
|
||||
return {
|
||||
sid,
|
||||
trackList: Array.isArray(trackList) ? trackList : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isYoutubePlaybackActiveNow(): boolean {
|
||||
@@ -1745,6 +1774,9 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||
appState.activeParsedSubtitleCues = cues ?? [];
|
||||
appState.activeParsedSubtitleSource = sourceKey;
|
||||
if (!cues?.length) {
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
const mediaPath = getCurrentAutoplayMediaPath();
|
||||
if (mediaPath && cues?.length) {
|
||||
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
|
||||
@@ -1763,11 +1795,15 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||
});
|
||||
|
||||
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
|
||||
async function refreshSubtitleSidebarFromSource(
|
||||
sourcePath: string,
|
||||
mediaPath?: string,
|
||||
): Promise<void> {
|
||||
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||
if (!normalizedSourcePath) {
|
||||
return;
|
||||
}
|
||||
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
normalizedSourcePath,
|
||||
lastObservedTimePos,
|
||||
@@ -1778,6 +1814,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
|
||||
subtitlePrefetchInitController,
|
||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
});
|
||||
@@ -1792,8 +1829,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
const subtitlePrefetchRuntime = {
|
||||
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
||||
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
|
||||
refreshSubtitleSidebarFromSource(sourcePath),
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) =>
|
||||
refreshSubtitleSidebarFromSource(sourcePath, mediaPath),
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
||||
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
||||
@@ -3632,6 +3669,7 @@ const {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
appState.discordPresenceService = null;
|
||||
@@ -4271,6 +4309,10 @@ const {
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
|
||||
normalizedPath,
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
);
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
|
||||
@@ -4284,8 +4326,11 @@ const {
|
||||
appState.currentSubText = '';
|
||||
appState.currentSubAssText = '';
|
||||
appState.currentSubtitleData = null;
|
||||
appState.activeParsedSubtitleCues = [];
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
if (!preserveParsedSubtitleCues) {
|
||||
appState.activeParsedSubtitleCues = [];
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
@@ -4295,7 +4340,9 @@ const {
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
startupOsdSequencer.reset();
|
||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
if (!preserveParsedSubtitleCues) {
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
}
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -4844,6 +4891,20 @@ const {
|
||||
numericSessions: {
|
||||
onMultiCopyDigit: (count) => handleMultiCopyDigit(count),
|
||||
onMineSentenceDigit: (count) => handleMineSentenceDigit(count),
|
||||
tryBeginMultiCopyOverlaySelection: (timeoutMs) =>
|
||||
tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'copySubtitleMultiple',
|
||||
timeoutMs,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
}),
|
||||
tryBeginMineSentenceOverlaySelection: (timeoutMs) =>
|
||||
tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'mineSentenceMultiple',
|
||||
timeoutMs,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
}),
|
||||
},
|
||||
overlayShortcutsRuntimeMainDeps: {
|
||||
overlayShortcutsRuntime,
|
||||
@@ -5555,6 +5616,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
config,
|
||||
};
|
||||
}
|
||||
if (
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath,
|
||||
cachedMediaPath: appState.activeParsedSubtitleMediaPath,
|
||||
cachedCueCount: appState.activeParsedSubtitleCues.length,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
||||
currentExternalFilenameRaw,
|
||||
@@ -5586,6 +5661,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const cues = parseSubtitleCues(content, resolvedSource.path);
|
||||
appState.activeParsedSubtitleCues = cues;
|
||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||
return {
|
||||
cues,
|
||||
currentTimeSec,
|
||||
@@ -5793,6 +5869,11 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||
logInfo: (message: string) => logger.info(message),
|
||||
},
|
||||
ensureTrayForCommand: (args) => {
|
||||
if (args.background || args.managedPlayback) {
|
||||
ensureTray();
|
||||
}
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ test('manual watched session action starts immersion tracker before marking watc
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes clear rendered subtitle state', () => {
|
||||
test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
|
||||
@@ -31,8 +31,11 @@ test('media path changes clear rendered subtitle state', () => {
|
||||
assert.match(actionBlock, /appState\.currentSubText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
|
||||
assert.match(actionBlock, /isSameYoutubeMediaPath\(/);
|
||||
assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/);
|
||||
assert.match(actionBlock, /lastObservedTimePos = 0;/);
|
||||
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
|
||||
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
|
||||
@@ -52,3 +55,19 @@ test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
|
||||
const source = readMainSource();
|
||||
const snapshotBlock = source.match(
|
||||
/getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(snapshotBlock);
|
||||
assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/);
|
||||
assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/);
|
||||
assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/);
|
||||
assert.ok(
|
||||
snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') <
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,15 +40,17 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,3 +83,39 @@ test('cli command runtime handler skips generic overlay prerequisites for youtub
|
||||
|
||||
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
|
||||
});
|
||||
|
||||
test('cli command runtime handler ensures tray for managed playback commands', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createCliCommandRuntimeHandler({
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
ensureTrayForCommand: (args) => {
|
||||
if (args.managedPlayback) {
|
||||
calls.push('ensure-tray');
|
||||
}
|
||||
},
|
||||
createCliCommandContext: () => {
|
||||
calls.push('context');
|
||||
return { id: 'ctx' };
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
|
||||
calls.push(`cli:${source}:${context.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
handler(
|
||||
{
|
||||
managedPlayback: true,
|
||||
youtubePlay: 'https://youtube.com/watch?v=abc',
|
||||
} as never,
|
||||
'second-instance',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['ensure-tray', 'context', 'cli:second-instance:ctx']);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
|
||||
|
||||
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
|
||||
ensureTrayForCommand?: (args: CliArgs, source: CliCommandSource) => void;
|
||||
createCliCommandContext: () => TCliContext;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
@@ -29,6 +30,7 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
) {
|
||||
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
||||
}
|
||||
deps.ensureTrayForCommand?.(args, source);
|
||||
const cliContext = deps.createCliCommandContext();
|
||||
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -9,6 +9,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSecondarySubText: 'secondary',
|
||||
currentTimePos: 12.25,
|
||||
requestProperty: async () => 18.75,
|
||||
},
|
||||
@@ -20,7 +21,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
|
||||
recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) =>
|
||||
calls.push(`timing:${text}:${secondaryText ?? ''}`),
|
||||
},
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
@@ -113,6 +115,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('remote-stopped'));
|
||||
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
||||
assert.ok(calls.includes('anilist-post-watch'));
|
||||
assert.ok(calls.includes('timing:y:secondary'));
|
||||
assert.ok(calls.includes('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordPauseState?: (paused: boolean) => void;
|
||||
} | null;
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
||||
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||
} | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
@@ -132,7 +132,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
deps.appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
),
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
|
||||
@@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers',
|
||||
'mine-sentence:digit:3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('numeric shortcut session runtime handlers prefer overlay digit selection when available', () => {
|
||||
const calls: string[] = [];
|
||||
const createSession = (name: string) => ({
|
||||
start: () => calls.push(`${name}:start`),
|
||||
cancel: () => calls.push(`${name}:cancel`),
|
||||
});
|
||||
|
||||
const runtime = createNumericShortcutSessionRuntimeHandlers({
|
||||
multiCopySession: createSession('multi-copy'),
|
||||
mineSentenceSession: createSession('mine-sentence'),
|
||||
onMultiCopyDigit: () => calls.push('multi-copy:digit'),
|
||||
onMineSentenceDigit: () => calls.push('mine-sentence:digit'),
|
||||
tryBeginMultiCopyOverlaySelection: (timeoutMs) => {
|
||||
calls.push(`multi-copy:overlay:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
tryBeginMineSentenceOverlaySelection: () => false,
|
||||
});
|
||||
|
||||
runtime.startPendingMultiCopy(500);
|
||||
runtime.startPendingMineSentenceMultiple(700);
|
||||
|
||||
assert.deepEqual(calls, ['multi-copy:overlay:500', 'mine-sentence:start']);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
||||
mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
|
||||
onMultiCopyDigit: (count: number) => void;
|
||||
onMineSentenceDigit: (count: number) => void;
|
||||
tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean;
|
||||
tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean;
|
||||
}) {
|
||||
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
|
||||
session: deps.multiCopySession,
|
||||
@@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
||||
|
||||
return {
|
||||
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs),
|
||||
startPendingMultiCopy: (timeoutMs: number) => {
|
||||
if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return;
|
||||
startPendingMultiCopyHandler(timeoutMs);
|
||||
},
|
||||
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
startPendingMineSentenceMultipleHandler(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => {
|
||||
if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return;
|
||||
startPendingMineSentenceMultipleHandler(timeoutMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { tryBeginVisibleOverlayNumericSelection } from './overlay-numeric-selection';
|
||||
|
||||
function createWindowStub(
|
||||
options: {
|
||||
destroyed?: boolean;
|
||||
visible?: boolean;
|
||||
focused?: boolean;
|
||||
webContentsFocused?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
window: {
|
||||
isDestroyed: () => options.destroyed === true,
|
||||
isVisible: () => options.visible !== false,
|
||||
isFocused: () => options.focused === true,
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
calls.push(`mouse:${ignore}`);
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
webContents: {
|
||||
isFocused: () => options.webContentsFocused === true,
|
||||
focus: () => {
|
||||
calls.push('web-focus');
|
||||
},
|
||||
send: (channel: string, payload: unknown) => {
|
||||
calls.push(`send:${channel}:${JSON.stringify(payload)}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('tryBeginVisibleOverlayNumericSelection focuses visible overlay and sends selector event', () => {
|
||||
const { window, calls } = createWindowStub();
|
||||
|
||||
const handled = tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'copySubtitleMultiple',
|
||||
timeoutMs: 1234,
|
||||
getMainWindow: () => window,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [
|
||||
'mouse:false',
|
||||
'focus',
|
||||
'web-focus',
|
||||
`send:${IPC_CHANNELS.event.sessionNumericSelectionStart}:{"actionId":"copySubtitleMultiple","timeoutMs":1234}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('tryBeginVisibleOverlayNumericSelection skips hidden visible overlay', () => {
|
||||
const { window, calls } = createWindowStub({ visible: false });
|
||||
|
||||
const handled = tryBeginVisibleOverlayNumericSelection({
|
||||
actionId: 'mineSentenceMultiple',
|
||||
timeoutMs: 3000,
|
||||
getMainWindow: () => window,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { SessionNumericSelectionStartPayload } from '../../types/runtime';
|
||||
|
||||
type OverlayNumericSelectionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
isFocused?: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
focus: () => void;
|
||||
webContents: {
|
||||
isFocused?: () => boolean;
|
||||
focus: () => void;
|
||||
send: (channel: string, payload: SessionNumericSelectionStartPayload) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function tryBeginVisibleOverlayNumericSelection(options: {
|
||||
actionId: SessionNumericSelectionStartPayload['actionId'];
|
||||
timeoutMs: number;
|
||||
getMainWindow: () => OverlayNumericSelectionWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
}): boolean {
|
||||
if (!options.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = options.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
if (typeof mainWindow.isFocused !== 'function' || !mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
if (
|
||||
typeof mainWindow.webContents.isFocused !== 'function' ||
|
||||
!mainWindow.webContents.isFocused()
|
||||
) {
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
mainWindow.webContents.send(IPC_CHANNELS.event.sessionNumericSelectionStart, {
|
||||
actionId: options.actionId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime';
|
||||
import {
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from './subtitle-prefetch-runtime';
|
||||
|
||||
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -57,3 +60,43 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
|
||||
cleanup: resolved?.cleanup,
|
||||
});
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime preserves parsed cues when YouTube active track source is unresolved', async () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'path') return 'https://www.youtube.com/watch?v=video123';
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 4,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (name === 'sid') return 4;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {
|
||||
calls.push('cancel');
|
||||
},
|
||||
initSubtitlePrefetch: async () => {
|
||||
calls.push('init');
|
||||
},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => videoPath.includes('youtube.com'),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
@@ -160,6 +161,9 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||
return;
|
||||
}
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,55 @@ test('ensure tray creates new tray and binds click handler', () => {
|
||||
assert.ok(calls.includes('bind-click'));
|
||||
});
|
||||
|
||||
test('ensure tray logs Linux tray registration failures without crashing startup', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = null;
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => null,
|
||||
setTray: (tray) => {
|
||||
trayRef = tray;
|
||||
calls.push('set-tray');
|
||||
},
|
||||
buildTrayMenu: () => ({ id: 'menu' }),
|
||||
resolveTrayIconPath: () => '/tmp/icon.png',
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: () => ({
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}),
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () => {
|
||||
throw new Error('StatusNotifier watcher unavailable');
|
||||
},
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'linux',
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
|
||||
assert.equal(trayRef, null);
|
||||
assert.deepEqual(calls, [
|
||||
'warn:Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. StatusNotifier watcher unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
test('destroy tray handler destroys active tray and clears ref', () => {
|
||||
const calls: string[] = [];
|
||||
let tray: { destroy: () => void } | null = {
|
||||
|
||||
@@ -48,7 +48,20 @@ export function createEnsureTrayHandler(deps: {
|
||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
||||
}
|
||||
|
||||
const tray = deps.createTray(trayIcon);
|
||||
let tray: TrayLike;
|
||||
try {
|
||||
tray = deps.createTray(trayIcon);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
if (deps.platform === 'linux') {
|
||||
deps.logWarn(
|
||||
`Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. ${reason}`,
|
||||
);
|
||||
} else {
|
||||
deps.logWarn(`Unable to create tray icon. ${reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tray.setToolTip(deps.trayTooltip);
|
||||
tray.setContextMenu(deps.buildTrayMenu());
|
||||
tray.on('click', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||
@@ -306,6 +307,7 @@ test('youtube flow reports probe failure through the configured reporter in manu
|
||||
|
||||
test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => {
|
||||
const failures: string[] = [];
|
||||
const loadedSignals: string[] = [];
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -358,6 +360,9 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
reportSubtitleFailure: (message) => {
|
||||
failures.push(message);
|
||||
},
|
||||
notifyPrimarySubtitleLoaded: () => {
|
||||
loadedSignals.push('loaded');
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
@@ -368,6 +373,7 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
assert.deepEqual(loadedSignals, ['loaded']);
|
||||
});
|
||||
|
||||
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
|
||||
@@ -781,11 +787,13 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
|
||||
test('youtube flow injects downloaded primary while reusing existing manual secondary tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -813,7 +821,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.language === 'ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error('should not download secondary track when manual english already exists');
|
||||
},
|
||||
@@ -832,6 +840,13 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
@@ -853,7 +868,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
if (name === 'secondary-sid') {
|
||||
return selectedSecondarySid;
|
||||
}
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -887,6 +902,17 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
'external-filename': null,
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||
@@ -912,24 +938,451 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
|
||||
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-remove'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
|
||||
test('youtube flow injects downloaded primary subtitles instead of reusing streamed youtube tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const refreshedSidebarSources: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/manual-ja.ja.vtt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [
|
||||
{
|
||||
...primaryTrack,
|
||||
id: 'manual:ja',
|
||||
sourceLanguage: 'ja',
|
||||
kind: 'manual',
|
||||
title: 'Japanese',
|
||||
},
|
||||
],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
assert.equal(track.id, 'manual:ja');
|
||||
return { path: downloadedPrimaryPath };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: 'manual:ja',
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return downloadedPrimaryAdded
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||
},
|
||||
];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||
refreshedSidebarSources.push(sourcePath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow confirms primary subtitle load before sidebar and tokenization waits', async () => {
|
||||
const events: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/auto-ja-orig.vtt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async () => ({ path: downloadedPrimaryPath }),
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return downloadedPrimaryAdded
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {
|
||||
events.push('sidebar');
|
||||
assert.ok(
|
||||
events.includes('notify'),
|
||||
'primary load should be confirmed before sidebar parsing can delay',
|
||||
);
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {
|
||||
events.push('tokenization');
|
||||
assert.ok(
|
||||
events.includes('notify'),
|
||||
'primary load should be confirmed before tokenization waits can delay',
|
||||
);
|
||||
},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
notifyPrimarySubtitleLoaded: () => {
|
||||
events.push('notify');
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.deepEqual(events, ['notify', 'sidebar', 'tokenization']);
|
||||
});
|
||||
|
||||
test('youtube flow downloads subtitles into temporary dirs and exposes cleanup', async () => {
|
||||
const outputDirs: string[] = [];
|
||||
const cleanupCalls: string[][] = [];
|
||||
let tempDirIndex = 0;
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let addedSubtitlePath: string | null = null;
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
|
||||
outputDirs.push(outputDir);
|
||||
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
|
||||
addedSubtitlePath = command[1];
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return addedSubtitlePath
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10 + tempDirIndex,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(addedSubtitlePath),
|
||||
external: true,
|
||||
'external-filename': addedSubtitlePath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp/unused-youtube-cache',
|
||||
createSubtitleTempDir: async () => {
|
||||
tempDirIndex += 1;
|
||||
return `/tmp/subminer-youtube-subtitles-${tempDirIndex}`;
|
||||
},
|
||||
cleanupSubtitleTempDirs: (dirs) => {
|
||||
cleanupCalls.push([...dirs]);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
addedSubtitlePath = null;
|
||||
selectedPrimarySid = null;
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
runtime.cleanupSubtitleTempDirs();
|
||||
runtime.cleanupSubtitleTempDirs();
|
||||
|
||||
assert.deepEqual(outputDirs, [
|
||||
'/tmp/subminer-youtube-subtitles-1',
|
||||
'/tmp/subminer-youtube-subtitles-2',
|
||||
]);
|
||||
assert.deepEqual(cleanupCalls, [
|
||||
['/tmp/subminer-youtube-subtitles-1'],
|
||||
['/tmp/subminer-youtube-subtitles-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube flow falls back to configured output dir when subtitle temp dir creation fails', async () => {
|
||||
const outputDirs: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let addedSubtitlePath: string | null = null;
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => {
|
||||
throw new Error('single primary selection should not batch download');
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
|
||||
outputDirs.push(outputDir);
|
||||
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
|
||||
addedSubtitlePath = command[1];
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
return '字幕です';
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
}
|
||||
return addedSubtitlePath
|
||||
? [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'ja-orig',
|
||||
title: path.basename(addedSubtitlePath),
|
||||
external: true,
|
||||
'external-filename': addedSubtitlePath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
refreshSubtitleSidebarSource: async () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp/youtube-cache',
|
||||
createSubtitleTempDir: async () => {
|
||||
throw new Error('tmp unavailable');
|
||||
},
|
||||
cleanupSubtitleTempDirs: () => {},
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
|
||||
|
||||
assert.deepEqual(outputDirs, ['/tmp/youtube-cache']);
|
||||
assert.deepEqual(warnings, [
|
||||
'Failed to create YouTube subtitle temp dir; using configured output dir: tmp unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube flow waits for manual secondary tracks while injecting downloaded primary', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let trackListReads = 0;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -957,7 +1410,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.language === 'ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error('should not download secondary track when manual english appears in mpv');
|
||||
},
|
||||
@@ -976,6 +1429,13 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||
selectedPrimarySid = command[2];
|
||||
}
|
||||
@@ -1001,7 +1461,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
if (trackListReads === 1) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -1035,6 +1495,17 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
'external-filename': null,
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
@@ -1057,18 +1528,22 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
|
||||
test('youtube flow injects downloaded primary even when reusable manual youtube tracks exist', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let selectedPrimarySid: number | null = null;
|
||||
let selectedSecondarySid: number | null = null;
|
||||
let downloadedPrimaryAdded = false;
|
||||
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
@@ -1098,7 +1573,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
},
|
||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||
if (track.id === 'manual:ja') {
|
||||
return { path: '/tmp/manual-ja.ja.srt' };
|
||||
return { path: downloadedPrimaryPath };
|
||||
}
|
||||
throw new Error(
|
||||
'should not download secondary track when existing manual english track is reusable',
|
||||
@@ -1109,6 +1584,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
if (
|
||||
command[0] === 'sub-add' &&
|
||||
command[1] === downloadedPrimaryPath &&
|
||||
command[2] === 'select'
|
||||
) {
|
||||
downloadedPrimaryAdded = true;
|
||||
}
|
||||
if (command[0] === 'set_property' && command[1] === 'sid') {
|
||||
selectedPrimarySid = Number(command[2]);
|
||||
}
|
||||
@@ -1118,7 +1600,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
const tracks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
@@ -1144,6 +1626,17 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt',
|
||||
},
|
||||
];
|
||||
if (downloadedPrimaryAdded) {
|
||||
tracks.push({
|
||||
type: 'sub',
|
||||
id: 9,
|
||||
lang: 'ja',
|
||||
title: path.basename(downloadedPrimaryPath),
|
||||
external: true,
|
||||
'external-filename': downloadedPrimaryPath,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
if (name === 'sid') {
|
||||
return selectedPrimarySid;
|
||||
@@ -1181,11 +1674,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
||||
mode: 'download',
|
||||
});
|
||||
|
||||
assert.equal(selectedPrimarySid, 2);
|
||||
assert.equal(selectedPrimarySid, 9);
|
||||
assert.equal(selectedSecondarySid, 1);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'sub-add'),
|
||||
false,
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ type YoutubeFlowDeps = {
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string, mediaPath?: string) => Promise<void>;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
waitForTokenizationReady: () => Promise<void>;
|
||||
waitForAnkiReady: () => Promise<void>;
|
||||
@@ -42,9 +42,12 @@ type YoutubeFlowDeps = {
|
||||
focusOverlayWindow: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
reportSubtitleFailure: (message: string) => void;
|
||||
notifyPrimarySubtitleLoaded?: () => void;
|
||||
warn: (message: string) => void;
|
||||
log: (message: string) => void;
|
||||
getYoutubeOutputDir: () => string;
|
||||
createSubtitleTempDir?: () => Promise<string>;
|
||||
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
|
||||
};
|
||||
|
||||
type YoutubeFlowSession = {
|
||||
@@ -349,7 +352,9 @@ async function injectDownloadedSubtitles(
|
||||
}
|
||||
|
||||
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
|
||||
let primaryTrackId: number | null = primarySelection.existingTrackId;
|
||||
let primaryTrackId: number | null = primarySelection.injectedPath
|
||||
? null
|
||||
: primarySelection.existingTrackId;
|
||||
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
||||
@@ -423,6 +428,53 @@ async function injectDownloadedSubtitles(
|
||||
|
||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let activeSession: YoutubeFlowSession | null = null;
|
||||
const activeSubtitleTempDirs = new Set<string>();
|
||||
|
||||
const cleanupSubtitleTempDirs = (): void => {
|
||||
const dirs = [...activeSubtitleTempDirs];
|
||||
if (dirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!deps.cleanupSubtitleTempDirs) {
|
||||
activeSubtitleTempDirs.clear();
|
||||
return;
|
||||
}
|
||||
deps.cleanupSubtitleTempDirs(dirs);
|
||||
for (const dir of dirs) {
|
||||
activeSubtitleTempDirs.delete(dir);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupSubtitleTempDirsForNextLoad = (): void => {
|
||||
try {
|
||||
cleanupSubtitleTempDirs();
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to cleanup YouTube subtitle temp files: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareSubtitleOutputDir = async (fallbackOutputDir: string): Promise<string> => {
|
||||
if (!deps.createSubtitleTempDir || !deps.cleanupSubtitleTempDirs) {
|
||||
return fallbackOutputDir;
|
||||
}
|
||||
cleanupSubtitleTempDirsForNextLoad();
|
||||
try {
|
||||
const tempDir = await deps.createSubtitleTempDir();
|
||||
activeSubtitleTempDirs.add(tempDir);
|
||||
return tempDir;
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to create YouTube subtitle temp dir; using configured output dir: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return fallbackOutputDir;
|
||||
}
|
||||
};
|
||||
|
||||
const acquireSelectedTracks = async (input: {
|
||||
targetUrl: string;
|
||||
@@ -567,6 +619,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
osdProgress.setMessage('Downloading subtitles...');
|
||||
}
|
||||
try {
|
||||
const outputDir = await prepareSubtitleOutputDir(input.outputDir);
|
||||
let initialTrackListRaw: unknown = null;
|
||||
let existingPrimaryTrackId: number | null = null;
|
||||
let existingSecondaryTrackId: number | null = null;
|
||||
@@ -602,19 +655,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let primaryInjectedPath: string | null = null;
|
||||
let secondaryInjectedPath: string | null = null;
|
||||
|
||||
if (existingPrimaryTrackId !== null) {
|
||||
primarySidebarPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
primaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
@@ -622,7 +667,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
} else {
|
||||
const acquired = await acquireSelectedTracks({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
primaryTrack: input.primaryTrack,
|
||||
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||
@@ -641,7 +686,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
secondaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
outputDir,
|
||||
track: input.secondaryTrack,
|
||||
})
|
||||
).path;
|
||||
@@ -685,8 +730,9 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
if (!refreshedActiveSubtitle) {
|
||||
return false;
|
||||
}
|
||||
deps.notifyPrimarySubtitleLoaded?.();
|
||||
try {
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath, input.url);
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||
@@ -877,5 +923,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
resolveActivePicker,
|
||||
cancelActivePicker,
|
||||
hasActiveSession: () => Boolean(activeSession),
|
||||
cleanupSubtitleTempDirs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './youtube-playback';
|
||||
|
||||
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
||||
@@ -22,3 +27,49 @@ test('isYoutubePlaybackActive checks both current media and mpv video paths', ()
|
||||
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
||||
});
|
||||
|
||||
test('isSameYoutubeMediaPath matches equivalent youtube urls by video id', () => {
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123&t=30', 'https://youtu.be/abc123'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath(
|
||||
'https://www.youtube.com/embed/abc123',
|
||||
'https://www.youtube-nocookie.com/embed/abc123',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123', 'https://youtu.be/xyz789'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isSameYoutubeMediaPath('/tmp/video.mkv', 'https://youtu.be/abc123'), false);
|
||||
});
|
||||
|
||||
test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube video', () => {
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123&t=30',
|
||||
cachedMediaPath: 'https://youtu.be/abc123',
|
||||
cachedCueCount: 12,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
cachedMediaPath: 'https://youtu.be/abc123',
|
||||
cachedCueCount: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseCachedYoutubeParsedCues({
|
||||
videoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
cachedMediaPath: 'https://youtu.be/other',
|
||||
cachedCueCount: 12,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,39 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (matchesYoutubeHost(host, 'youtu.be')) {
|
||||
return parsed.pathname.replace(/^\/+/, '').split('/')[0]?.trim() || null;
|
||||
}
|
||||
if (
|
||||
!matchesYoutubeHost(host, 'youtube.com') &&
|
||||
!matchesYoutubeHost(host, 'youtube-nocookie.com')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.pathname === '/watch') {
|
||||
return parsed.searchParams.get('v')?.trim() || null;
|
||||
}
|
||||
const pathSegments = parsed.pathname.replace(/^\/+/, '').split('/');
|
||||
if (pathSegments[0] === 'shorts' || pathSegments[0] === 'embed') {
|
||||
return pathSegments[1]?.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
@@ -31,6 +64,26 @@ export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolea
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameYoutubeMediaPath(
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
): boolean {
|
||||
const leftId = extractYoutubeVideoId(left);
|
||||
const rightId = extractYoutubeVideoId(right);
|
||||
return Boolean(leftId && rightId && leftId === rightId);
|
||||
}
|
||||
|
||||
export function shouldUseCachedYoutubeParsedCues(input: {
|
||||
videoPath: string | null | undefined;
|
||||
cachedMediaPath: string | null | undefined;
|
||||
cachedCueCount: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.cachedCueCount > 0 &&
|
||||
isSameYoutubeMediaPath(input.videoPath, input.cachedMediaPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function isYoutubePlaybackActive(
|
||||
currentMediaPath: string | null | undefined,
|
||||
currentVideoPath: string | null | undefined,
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
|
||||
function createTimerHarness() {
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
const timers = new Map<number, () => void | Promise<void>>();
|
||||
return {
|
||||
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
|
||||
schedule: (fn: () => void | Promise<void>): YoutubePrimarySubtitleNotificationTimer => {
|
||||
const id = nextId++;
|
||||
timers.set(id, fn);
|
||||
return { id };
|
||||
@@ -26,7 +26,14 @@ function createTimerHarness() {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
fn();
|
||||
void fn();
|
||||
}
|
||||
},
|
||||
runAllAsync: async () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
await fn();
|
||||
}
|
||||
},
|
||||
size: () => timers.size,
|
||||
@@ -195,3 +202,80 @@ test('notifier suppresses timer while app-owned youtube flow is still settling',
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifier suppresses stale delayed failure after primary subtitle load is confirmed', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
|
||||
]);
|
||||
runtime.markCurrentMediaPrimarySubtitleLoaded();
|
||||
|
||||
assert.equal(timers.size(), 0);
|
||||
timers.runAll();
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier suppresses delayed failure when live mpv state has downloaded primary selected', async () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
let liveStateReads = 0;
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
getCurrentSubtitleState: async () => {
|
||||
liveStateReads += 1;
|
||||
return {
|
||||
sid: 22,
|
||||
trackList: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
lang: 'en',
|
||||
title: 'English',
|
||||
external: true,
|
||||
selected: true,
|
||||
'main-selection': 1,
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 22,
|
||||
lang: 'ja',
|
||||
title: 'manual-ja.ja.srt',
|
||||
external: true,
|
||||
selected: true,
|
||||
'main-selection': 0,
|
||||
'external-filename': '/tmp/subminer-youtube-subtitles-aahLWu/manual-ja.ja.srt',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=uO2jfacqjYQ');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, selected: false },
|
||||
]);
|
||||
|
||||
assert.equal(timers.size(), 1);
|
||||
await timers.runAllAsync();
|
||||
|
||||
assert.equal(liveStateReads, 1);
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ type SubtitleTrackEntry = {
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
type CurrentSubtitleState = {
|
||||
sid: unknown;
|
||||
trackList: unknown[] | null;
|
||||
};
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
@@ -101,8 +106,12 @@ function hasSelectedPrimarySubtitle(
|
||||
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
notifyFailure: (message: string) => void;
|
||||
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
|
||||
schedule: (
|
||||
fn: () => void | Promise<void>,
|
||||
delayMs: number,
|
||||
) => YoutubePrimarySubtitleNotificationTimer;
|
||||
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
|
||||
getCurrentSubtitleState?: () => CurrentSubtitleState | null | Promise<CurrentSubtitleState | null>;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 5000;
|
||||
@@ -112,13 +121,35 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
|
||||
let lastReportedMediaPath: string | null = null;
|
||||
let appOwnedFlowInFlight = false;
|
||||
let primarySubtitleLoadedForCurrentMedia = false;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
deps.clearSchedule(pendingTimer);
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const maybeReportFailure = (): void => {
|
||||
const refreshCurrentSubtitleState = async (
|
||||
preferredLanguages: Set<string>,
|
||||
): Promise<boolean> => {
|
||||
const getCurrentSubtitleState = deps.getCurrentSubtitleState;
|
||||
if (!getCurrentSubtitleState) {
|
||||
return false;
|
||||
}
|
||||
let state: CurrentSubtitleState | null;
|
||||
try {
|
||||
state = await getCurrentSubtitleState();
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
currentSid = parseTrackId(state.sid);
|
||||
currentTrackList = Array.isArray(state.trackList) ? state.trackList : null;
|
||||
return hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages);
|
||||
};
|
||||
|
||||
const maybeReportFailure = async (): Promise<void> => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
@@ -126,13 +157,30 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
if (lastReportedMediaPath === mediaPath) {
|
||||
return;
|
||||
}
|
||||
if (appOwnedFlowInFlight) {
|
||||
return;
|
||||
}
|
||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||
if (preferredLanguages.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (primarySubtitleLoadedForCurrentMedia) {
|
||||
return;
|
||||
}
|
||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||
return;
|
||||
}
|
||||
if (deps.getCurrentSubtitleState && (await refreshCurrentSubtitleState(preferredLanguages))) {
|
||||
clearPendingTimer();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentMediaPath?.trim() !== mediaPath ||
|
||||
appOwnedFlowInFlight ||
|
||||
primarySubtitleLoadedForCurrentMedia
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastReportedMediaPath = mediaPath;
|
||||
deps.notifyFailure(
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
@@ -148,9 +196,12 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
if (primarySubtitleLoadedForCurrentMedia) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(async () => {
|
||||
pendingTimer = null;
|
||||
maybeReportFailure();
|
||||
await maybeReportFailure();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
@@ -160,6 +211,7 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||
if (currentMediaPath !== normalizedPath) {
|
||||
lastReportedMediaPath = null;
|
||||
primarySubtitleLoadedForCurrentMedia = false;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
currentSid = null;
|
||||
@@ -180,6 +232,14 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
clearPendingTimer();
|
||||
}
|
||||
},
|
||||
markCurrentMediaPrimarySubtitleLoaded: (): void => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
primarySubtitleLoadedForCurrentMedia = true;
|
||||
clearPendingTimer();
|
||||
},
|
||||
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
|
||||
appOwnedFlowInFlight = inFlight;
|
||||
if (inFlight) {
|
||||
|
||||
@@ -163,6 +163,7 @@ export interface AppState {
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
activeParsedSubtitleCues: SubtitleCue[];
|
||||
activeParsedSubtitleSource: string | null;
|
||||
activeParsedSubtitleMediaPath: string | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
subtitlePosition: SubtitlePosition | null;
|
||||
currentMediaPath: string | null;
|
||||
@@ -248,6 +249,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
currentSubtitleData: null,
|
||||
activeParsedSubtitleCues: [],
|
||||
activeParsedSubtitleSource: null,
|
||||
activeParsedSubtitleMediaPath: null,
|
||||
windowTracker: null,
|
||||
subtitlePosition: null,
|
||||
currentMediaPath: null,
|
||||
|
||||
+140
-1
@@ -1,7 +1,60 @@
|
||||
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 { buildAnimatedImageVideoFilter } from './media-generator';
|
||||
import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator';
|
||||
|
||||
async function withStubbedFfmpeg(
|
||||
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
|
||||
const binDir = path.join(root, 'bin');
|
||||
const tempDir = path.join(root, 'media');
|
||||
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const ffmpegPath = path.join(binDir, 'ffmpeg');
|
||||
fs.writeFileSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
|
||||
' echo " V..... libaom-av1"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
|
||||
'out=""',
|
||||
'for arg in "$@"; do out="$arg"; done',
|
||||
'printf avif > "$out"',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(ffmpegPath, 0o755);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
|
||||
const generator = new MediaGenerator(tempDir);
|
||||
|
||||
try {
|
||||
await run(generator, argsPath);
|
||||
} finally {
|
||||
generator.cleanup();
|
||||
process.env.PATH = originalPath;
|
||||
if (originalArgsPath === undefined) {
|
||||
delete process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
} else {
|
||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readFfmpegArgs(argsPath: string): string[] {
|
||||
return fs.readFileSync(argsPath, 'utf8').trim().split('\n');
|
||||
}
|
||||
|
||||
test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in is provided', () => {
|
||||
assert.equal(
|
||||
@@ -13,3 +66,89 @@ test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in i
|
||||
'tpad=start_duration=1.25:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
|
||||
test('generateAnimatedImage freezes first frame for leading audio padding', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2.5');
|
||||
assert.equal(
|
||||
args[args.indexOf('-vf') + 1],
|
||||
'tpad=start_duration=0.5:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage defaults to unpadded sentence timing', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 10, 12, undefined, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2');
|
||||
assert.equal(args[args.indexOf('-vf') + 1], 'fps=10,scale=w=640:h=-2');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage adds audio lead padding to existing word-audio lead-in', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
leadingStillDuration: 1.25,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2.5');
|
||||
assert.equal(
|
||||
args[args.indexOf('-vf') + 1],
|
||||
'tpad=start_duration=1.75:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage clips leading audio padding at the start of media', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage('/video.mp4', 0.2, 1.2, 0.5, {
|
||||
fps: 10,
|
||||
maxWidth: 640,
|
||||
});
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '0.2');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '1.5');
|
||||
assert.equal(
|
||||
args[args.indexOf('-vf') + 1],
|
||||
'tpad=start_duration=0.2:start_mode=clone,fps=10,scale=w=640:h=-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio defaults to unpadded sentence timing', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '10');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '2');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-ss') + 1], '0');
|
||||
assert.equal(args[args.indexOf('-t') + 1], '1.7');
|
||||
});
|
||||
});
|
||||
|
||||
+11
-7
@@ -158,11 +158,12 @@ export class MediaGenerator {
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0.5,
|
||||
padding: number = 0,
|
||||
audioStreamIndex: number | null = null,
|
||||
): Promise<Buffer> {
|
||||
const start = Math.max(0, startTime - padding);
|
||||
const duration = endTime - startTime + 2 * padding;
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = endTime - start + safePadding;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
||||
@@ -310,7 +311,7 @@ export class MediaGenerator {
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0.5,
|
||||
padding: number = 0,
|
||||
options: {
|
||||
fps?: number;
|
||||
maxWidth?: number;
|
||||
@@ -319,9 +320,12 @@ export class MediaGenerator {
|
||||
leadingStillDuration?: number;
|
||||
} = {},
|
||||
): Promise<Buffer> {
|
||||
const start = Math.max(0, startTime - padding);
|
||||
const duration = endTime - startTime + 2 * padding;
|
||||
const { fps = 10, maxWidth = 640, maxHeight, crf = 35, leadingStillDuration = 0 } = options;
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime);
|
||||
const duration = endTime - startTime + safePadding;
|
||||
const effectiveLeadingPadding = Math.min(safePadding, start);
|
||||
const totalLeadingStillDuration = Math.max(0, leadingStillDuration) + effectiveLeadingPadding;
|
||||
|
||||
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||
|
||||
@@ -359,7 +363,7 @@ export class MediaGenerator {
|
||||
fps,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
leadingStillDuration,
|
||||
leadingStillDuration: totalLeadingStillDuration,
|
||||
}),
|
||||
...encoderArgs,
|
||||
'-y',
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
SessionNumericSelectionStartPayload,
|
||||
YoutubePickerOpenPayload,
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
@@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl
|
||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.youtubePickerCancel,
|
||||
);
|
||||
const onSessionNumericSelectionStartEvent =
|
||||
createQueuedIpcListenerWithPayload<SessionNumericSelectionStartPayload>(
|
||||
IPC_CHANNELS.event.sessionNumericSelectionStart,
|
||||
(payload) => payload as SessionNumericSelectionStartPayload,
|
||||
);
|
||||
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||
);
|
||||
@@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = {
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
|
||||
@@ -670,6 +670,21 @@ test('numeric selection ignores non-digit keys instead of falling through to oth
|
||||
}
|
||||
});
|
||||
|
||||
test('numeric selection start focuses overlay for follow-up digit keys', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.beginSessionNumericSelection('copySubtitleMultiple');
|
||||
|
||||
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
|
||||
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
function startPendingNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
timeoutMs: number = ctx.state.sessionActionTimeoutMs,
|
||||
): void {
|
||||
cancelPendingNumericSelection(false);
|
||||
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
||||
@@ -159,15 +160,17 @@ export function createKeyboardHandlers(
|
||||
timeout: setTimeout(() => {
|
||||
pendingNumericSelection = null;
|
||||
showSessionSelectionMessage(timeoutMessage);
|
||||
}, ctx.state.sessionActionTimeoutMs),
|
||||
}, timeoutMs),
|
||||
};
|
||||
showSessionSelectionMessage(promptMessage);
|
||||
}
|
||||
|
||||
function beginSessionNumericSelection(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||
timeoutMs?: number,
|
||||
): void {
|
||||
startPendingNumericSelection(actionId);
|
||||
startPendingNumericSelection(actionId, timeoutMs);
|
||||
restoreOverlayKeyboardFocus();
|
||||
}
|
||||
|
||||
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
||||
|
||||
@@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void {
|
||||
}
|
||||
|
||||
function registerKeyboardCommandHandlers(): void {
|
||||
window.electronAPI.onSessionNumericSelectionStart((payload) => {
|
||||
runGuarded('session:numeric-selection-start', () => {
|
||||
keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs);
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -123,6 +123,7 @@ export const IPC_CHANNELS = {
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
|
||||
@@ -27,9 +27,17 @@ interface HistoryEntry {
|
||||
timingKey: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondaryText?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubtitleTimingBlock {
|
||||
displayText: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
export class SubtitleTimingTracker {
|
||||
private timings = new Map<string, TimingEntry>();
|
||||
private history: HistoryEntry[] = [];
|
||||
@@ -41,11 +49,12 @@ export class SubtitleTimingTracker {
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
recordSubtitle(text: string, startTime: number, endTime: number): void {
|
||||
recordSubtitle(text: string, startTime: number, endTime: number, secondaryText?: string): void {
|
||||
const normalizedText = this.normalizeText(text);
|
||||
if (!normalizedText) return;
|
||||
|
||||
const displayText = this.prepareDisplayText(text);
|
||||
const displaySecondaryText = secondaryText ? this.prepareDisplayText(secondaryText) : undefined;
|
||||
const timingKey = normalizedText;
|
||||
|
||||
this.timings.set(timingKey, {
|
||||
@@ -60,6 +69,7 @@ export class SubtitleTimingTracker {
|
||||
// Update timing to most recent occurrence
|
||||
lastEntry.startTime = startTime;
|
||||
lastEntry.endTime = endTime;
|
||||
lastEntry.secondaryText = displaySecondaryText;
|
||||
lastEntry.timestamp = Date.now();
|
||||
return;
|
||||
}
|
||||
@@ -69,6 +79,7 @@ export class SubtitleTimingTracker {
|
||||
timingKey,
|
||||
startTime,
|
||||
endTime,
|
||||
secondaryText: displaySecondaryText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
@@ -106,6 +117,23 @@ export class SubtitleTimingTracker {
|
||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks with their original event timings.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
*/
|
||||
getRecentEntries(count: number): SubtitleTimingBlock[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => ({
|
||||
displayText: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
secondaryText: entry.secondaryText,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for the most recent subtitle.
|
||||
*/
|
||||
|
||||
@@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult {
|
||||
staleMediaIds: number[];
|
||||
}
|
||||
|
||||
export interface SessionNumericSelectionStartPayload {
|
||||
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -451,6 +456,9 @@ export interface ElectronAPI {
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||
onSessionNumericSelectionStart: (
|
||||
callback: (payload: SessionNumericSelectionStartPayload) => void,
|
||||
) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||
|
||||
Reference in New Issue
Block a user