feat(youtube): add mediaCache mode and safer stream media extraction (#130)

This commit is contained in:
2026-06-27 19:20:18 -07:00
committed by GitHub
parent d199376364
commit 5326ad32f5
52 changed files with 4065 additions and 165 deletions
+160 -12
View File
@@ -61,7 +61,18 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
import {
resolveAudioStreamIndexForMediaGeneration,
resolveMediaGenerationInput,
resolveMediaGenerationInputPath,
type MediaGenerationInputResolverOptions,
} from './anki-integration/media-source';
import type { PendingYoutubeMediaUpdate } from './anki-integration/pending-youtube-media';
import { PendingYoutubeMediaQueue } from './anki-integration/pending-youtube-media-queue';
import type {
PendingYoutubeMediaQueueFailedOptions,
PendingYoutubeMediaQueueReadyOptions,
} from './anki-integration/pending-youtube-media-queue';
const log = createLogger('anki').child('integration');
@@ -225,6 +236,10 @@ export class AnkiIntegration {
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>();
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
null;
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
constructor(
config: AnkiConnectConfig,
@@ -240,6 +255,8 @@ export class AnkiIntegration {
aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
) {
this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
@@ -252,6 +269,9 @@ export class AnkiIntegration {
this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? null;
this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner();
this.cardCreationService = this.createCardCreationService();
@@ -295,6 +315,81 @@ export class AnkiIntegration {
}
}
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message),
};
if (this.getCachedMediaPath) {
options.getCachedMediaPath = this.getCachedMediaPath;
}
if (this.shouldRequireRemoteMediaCache?.()) {
options.remoteCacheMode = 'required';
}
return options;
}
private createPendingYoutubeMediaQueue(): PendingYoutubeMediaQueue {
return new PendingYoutubeMediaQueue({
client: {
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
},
mediaGenerator: {
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
this.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
audioPadding,
audioStreamIndex,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) =>
this.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
audioPadding,
options,
),
},
getConfig: () => this.config,
getCurrentVideoPath: () => this.mpvClient.currentVideoPath,
getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
generateAudioFilename: () => this.generateAudioFilename(),
generateImageFilename: () => this.generateImageFilename(),
formatMiscInfoPatternForMediaPath: (
fallbackFilename,
startTimeSeconds,
mediaPath,
mediaTitle,
) =>
this.formatMiscInfoPatternForMediaPath(
fallbackFilename,
startTimeSeconds,
mediaPath,
mediaTitle,
),
showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label, errorSuffix) =>
this.showNotification(noteId, label, errorSuffix),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
});
}
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
return new KnownWordCacheManager({
client: {
@@ -377,6 +472,9 @@ export class AnkiIntegration {
getAiConfig: () => this.aiConfig,
getTimingTracker: () => this.timingTracker,
getMpvClient: () => this.mpvClient,
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
getDeck: () => this.config.deck,
client: {
addNote: (deck, modelName, fields, tags) =>
@@ -540,6 +638,7 @@ export class AnkiIntegration {
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showStatusNotification(message),
@@ -833,13 +932,45 @@ export class AnkiIntegration {
};
}
private queuePendingYoutubeMediaUpdate(job: PendingYoutubeMediaUpdate): void {
this.pendingYoutubeMediaQueue.enqueue(job);
}
private async queuePendingYoutubeMediaUpdateForNote(job: {
noteId: number;
noteInfo: NoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}): Promise<boolean> {
return this.pendingYoutubeMediaQueue.queueFromNote(job);
}
async handleYoutubeMediaCacheReady(
sourceUrl: string,
cachedPath: string,
options?: PendingYoutubeMediaQueueReadyOptions,
): Promise<void> {
await this.pendingYoutubeMediaQueue.handleReady(sourceUrl, cachedPath, options);
}
async handleYoutubeMediaCacheFailed(
sourceUrl: string,
options?: PendingYoutubeMediaQueueFailedOptions,
): Promise<void> {
await this.pendingYoutubeMediaQueue.handleFailed(sourceUrl, options);
}
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
const mpvClient = this.mpvClient;
if (!mpvClient || !mpvClient.currentVideoPath) {
return null;
}
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
const videoPath = await resolveMediaGenerationInput(
mpvClient,
'audio',
this.getMediaResolverOptions(),
);
if (!videoPath) {
return null;
}
@@ -850,7 +981,7 @@ export class AnkiIntegration {
startTime,
endTime,
this.config.media?.audioPadding,
this.mpvClient.currentAudioStreamIndex,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
);
}
@@ -862,7 +993,11 @@ export class AnkiIntegration {
return null;
}
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
const videoPath = await resolveMediaGenerationInput(
this.mpvClient,
'video',
this.getMediaResolverOptions(),
);
if (!videoPath) {
return null;
}
@@ -896,17 +1031,30 @@ export class AnkiIntegration {
}
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
return this.formatMiscInfoPatternForMediaPath(
fallbackFilename,
startTimeSeconds,
this.mpvClient.currentVideoPath || '',
this.mpvClient.currentMediaTitle ?? undefined,
);
}
private formatMiscInfoPatternForMediaPath(
fallbackFilename: string,
startTimeSeconds: number | undefined,
mediaPath: string,
mediaTitle?: string,
): string {
if (!this.config.metadata?.pattern) {
return '';
}
const currentVideoPath = this.mpvClient.currentVideoPath || '';
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
const videoFilename = extractFilenameFromMediaPath(mediaPath);
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
? mediaTitle || videoFilename
: videoFilename || mediaTitle) || fallbackFilename;
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? resolvedMediaTitle || videoFilename
: videoFilename || resolvedMediaTitle) || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
@@ -1457,7 +1605,7 @@ export class AnkiIntegration {
miscInfoValue?: string;
} = {};
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
if (this.config.media?.generateAudio !== false && this.mpvClient?.currentVideoPath) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.generateAudio();
@@ -1477,7 +1625,7 @@ export class AnkiIntegration {
}
}
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
if (this.config.media?.generateImage !== false && this.mpvClient?.currentVideoPath) {
try {
const animatedLeadInSeconds = noteInfo
? await this.getAnimatedImageLeadInSeconds(noteInfo)