mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
|
import type { MediaInput } from '../media-input';
|
|
import type { MediaGenerator } from '../media-generator';
|
|
import type { AnkiConnectConfig } from '../types/anki';
|
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
|
import { youtubeMediaUrlsMatch, type PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
|
import type { MediaGenerationInputResolverOptions } from './media-source';
|
|
|
|
type PendingYoutubeMediaUpdateResult = 'updated' | 'partial' | 'failed';
|
|
|
|
export interface PendingYoutubeMediaQueueReadyOptions {
|
|
notifyNoQueued?: boolean;
|
|
}
|
|
|
|
export interface PendingYoutubeMediaQueueFailedOptions {
|
|
notifyStatus?: boolean;
|
|
}
|
|
|
|
export interface PendingYoutubeMediaNoteInfo {
|
|
noteId: number;
|
|
fields: Record<string, { value: string }>;
|
|
}
|
|
|
|
export interface PendingYoutubeMediaQueueDeps {
|
|
client: {
|
|
notesInfo(noteIds: number[]): Promise<unknown>;
|
|
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
|
};
|
|
mediaGenerator: Pick<
|
|
MediaGenerator,
|
|
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
|
|
>;
|
|
getConfig: () => AnkiConnectConfig;
|
|
getCurrentVideoPath: () => Promise<string | undefined> | string | undefined;
|
|
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
|
|
shouldRequireRemoteMediaCache: () => boolean;
|
|
getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
|
|
startTime: number;
|
|
endTime: number;
|
|
};
|
|
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
|
|
resolveConfiguredFieldName: (
|
|
noteInfo: PendingYoutubeMediaNoteInfo,
|
|
...preferredNames: (string | undefined)[]
|
|
) => string | null;
|
|
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
|
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
|
|
generateAudioFilename: () => string;
|
|
generateImageFilename: () => string;
|
|
formatMiscInfoPatternForMediaPath: (
|
|
fallbackFilename: string,
|
|
startTimeSeconds: number | undefined,
|
|
mediaPath: string,
|
|
mediaTitle?: string,
|
|
) => string;
|
|
showStatusNotification: (message: string) => void;
|
|
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
|
|
logInfo: (message: string, ...args: unknown[]) => void;
|
|
logWarn: (message: string, ...args: unknown[]) => void;
|
|
logError: (message: string, ...args: unknown[]) => void;
|
|
}
|
|
|
|
function trimToNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
|
|
return config.media?.generateAudio !== false;
|
|
}
|
|
|
|
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
|
return config.media?.generateImage !== false;
|
|
}
|
|
|
|
export class PendingYoutubeMediaQueue {
|
|
private updates: PendingYoutubeMediaUpdate[] = [];
|
|
|
|
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
|
|
|
|
enqueue(job: PendingYoutubeMediaUpdate): void {
|
|
if (!job.generateAudio && !job.generateImage) {
|
|
return;
|
|
}
|
|
const isFirstQueuedForSource = !this.updates.some((existing) =>
|
|
youtubeMediaUrlsMatch(existing.sourceUrl, job.sourceUrl),
|
|
);
|
|
this.updates.push(job);
|
|
this.deps.logInfo('Queued YouTube media update for note:', job.noteId);
|
|
if (isFirstQueuedForSource) {
|
|
this.deps.showStatusNotification(
|
|
'YouTube media cache is still downloading. Card media will be added when the cache is ready.',
|
|
);
|
|
}
|
|
}
|
|
|
|
async queueFromNote(job: {
|
|
noteId: number;
|
|
noteInfo: PendingYoutubeMediaNoteInfo;
|
|
context?: SubtitleMiningContext;
|
|
label: string | number;
|
|
}): Promise<boolean> {
|
|
const sourceUrl = trimToNonEmptyString(await this.deps.getCurrentVideoPath());
|
|
const getCachedMediaPath = this.deps.getCachedMediaPath;
|
|
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
|
|
return false;
|
|
}
|
|
|
|
let cachedPath: string | null = null;
|
|
try {
|
|
cachedPath = await getCachedMediaPath(sourceUrl, 'video');
|
|
} catch (error) {
|
|
this.deps.logWarn(
|
|
'Failed to read YouTube cache state; falling back to immediate media generation:',
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
return false;
|
|
}
|
|
if (cachedPath) {
|
|
return false;
|
|
}
|
|
|
|
const config = this.deps.getConfig();
|
|
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
|
|
this.enqueue({
|
|
sourceUrl,
|
|
noteId: job.noteId,
|
|
startTime: mediaRange.startTime,
|
|
endTime: mediaRange.endTime,
|
|
label: job.label,
|
|
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
|
|
imageFieldName:
|
|
this.deps.resolveConfiguredFieldName(
|
|
job.noteInfo,
|
|
config.fields?.image,
|
|
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
|
|
) ?? undefined,
|
|
miscInfoFieldName:
|
|
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
|
|
generateAudio: shouldGenerateAudio(config),
|
|
generateImage: shouldGenerateImage(config),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async handleReady(
|
|
sourceUrl: string,
|
|
cachedPath: string,
|
|
options: PendingYoutubeMediaQueueReadyOptions = {},
|
|
): Promise<void> {
|
|
const jobs = this.takeMatchingUpdates(sourceUrl);
|
|
if (jobs.length === 0) {
|
|
if (options.notifyNoQueued !== false) {
|
|
this.deps.showStatusNotification('YouTube media cache ready.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.deps.showStatusNotification(
|
|
`YouTube media cache ready. Adding media to ${jobs.length} queued card${
|
|
jobs.length === 1 ? '' : 's'
|
|
}.`,
|
|
);
|
|
|
|
let updatedCount = 0;
|
|
let partialCount = 0;
|
|
let failedCount = 0;
|
|
for (const job of jobs) {
|
|
try {
|
|
const result = await this.applyUpdate(job, cachedPath);
|
|
if (result === 'updated') {
|
|
updatedCount += 1;
|
|
} else if (result === 'partial') {
|
|
partialCount += 1;
|
|
} else {
|
|
failedCount += 1;
|
|
}
|
|
} catch (error) {
|
|
failedCount += 1;
|
|
this.deps.logError(
|
|
'Failed to apply queued YouTube media update:',
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (partialCount > 0 || failedCount > 0) {
|
|
this.deps.showStatusNotification(
|
|
`Queued YouTube media finished with ${updatedCount} updated, ${partialCount} partial, and ${failedCount} failed.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async handleFailed(
|
|
sourceUrl: string,
|
|
options: PendingYoutubeMediaQueueFailedOptions = {},
|
|
): Promise<void> {
|
|
const jobs = this.takeMatchingUpdates(sourceUrl);
|
|
if (jobs.length === 0) {
|
|
if (options.notifyStatus !== false) {
|
|
this.deps.showStatusNotification('YouTube media cache failed.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (options.notifyStatus !== false) {
|
|
this.deps.showStatusNotification(
|
|
`YouTube media cache failed. Media was not added to ${jobs.length} queued card${
|
|
jobs.length === 1 ? '' : 's'
|
|
}.`,
|
|
);
|
|
}
|
|
this.deps.logWarn('Discarding queued YouTube media updates after cache failure:', jobs.length);
|
|
|
|
for (const job of jobs) {
|
|
try {
|
|
await this.deps.showNotification(job.noteId, job.label, 'media cache failed');
|
|
} catch (error) {
|
|
this.deps.logWarn(
|
|
'Failed to show queued YouTube media failure notification:',
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
|
|
const matched: PendingYoutubeMediaUpdate[] = [];
|
|
const remaining: PendingYoutubeMediaUpdate[] = [];
|
|
for (const job of this.updates) {
|
|
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
|
|
matched.push(job);
|
|
} else {
|
|
remaining.push(job);
|
|
}
|
|
}
|
|
this.updates = remaining;
|
|
return matched;
|
|
}
|
|
|
|
private async applyUpdate(
|
|
job: PendingYoutubeMediaUpdate,
|
|
cachedPath: string,
|
|
): Promise<PendingYoutubeMediaUpdateResult> {
|
|
const notesInfoResult = await this.deps.client.notesInfo([job.noteId]);
|
|
const notesInfo = notesInfoResult as unknown as PendingYoutubeMediaNoteInfo[];
|
|
const noteInfo = notesInfo[0];
|
|
if (!noteInfo) {
|
|
this.deps.logWarn('Queued YouTube media target note not found:', job.noteId);
|
|
return 'failed';
|
|
}
|
|
|
|
const config = this.deps.getConfig();
|
|
const mediaFields: Record<string, string> = {};
|
|
const errors: string[] = [];
|
|
let miscInfoFilename: string | null = null;
|
|
const cachedMediaInput: MediaInput = {
|
|
path: cachedPath,
|
|
source: 'youtube-cache',
|
|
};
|
|
|
|
if (job.generateAudio) {
|
|
try {
|
|
const audioFilename = this.deps.generateAudioFilename();
|
|
const audioBuffer = await this.deps.mediaGenerator.generateAudio(
|
|
cachedMediaInput,
|
|
job.startTime,
|
|
job.endTime,
|
|
config.media?.audioPadding,
|
|
undefined,
|
|
config.media?.normalizeAudio !== false,
|
|
);
|
|
if (audioBuffer) {
|
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
|
const audioField =
|
|
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
|
|
if (audioField) {
|
|
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
|
mediaFields[audioField] = this.deps.mergeFieldValue(
|
|
existingAudio,
|
|
`[sound:${audioFilename}]`,
|
|
config.behavior?.overwriteAudio !== false,
|
|
);
|
|
}
|
|
miscInfoFilename = audioFilename;
|
|
}
|
|
} catch (error) {
|
|
errors.push('audio');
|
|
this.deps.logError('Failed to generate queued YouTube audio:', (error as Error).message);
|
|
}
|
|
}
|
|
|
|
if (job.generateImage) {
|
|
try {
|
|
const imageFilename = this.deps.generateImageFilename();
|
|
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
|
const imageBuffer = await this.generateImageFromInput(
|
|
cachedMediaInput,
|
|
job.startTime,
|
|
job.endTime,
|
|
animatedLeadInSeconds,
|
|
);
|
|
if (imageBuffer) {
|
|
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
|
const imageField =
|
|
job.imageFieldName ||
|
|
this.deps.resolveConfiguredFieldName(
|
|
noteInfo,
|
|
config.fields?.image,
|
|
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
|
|
);
|
|
if (imageField) {
|
|
const existingImage = noteInfo.fields[imageField]?.value || '';
|
|
mediaFields[imageField] = this.deps.mergeFieldValue(
|
|
existingImage,
|
|
`<img src="${imageFilename}">`,
|
|
config.behavior?.overwriteImage !== false,
|
|
);
|
|
} else {
|
|
this.deps.logWarn(
|
|
'Image field not found on queued YouTube media note, skipping image update',
|
|
);
|
|
}
|
|
miscInfoFilename = imageFilename;
|
|
}
|
|
} catch (error) {
|
|
errors.push('image');
|
|
this.deps.logError('Failed to generate queued YouTube image:', (error as Error).message);
|
|
}
|
|
}
|
|
|
|
if (config.fields?.miscInfo && miscInfoFilename) {
|
|
const miscInfoField =
|
|
job.miscInfoFieldName ||
|
|
this.deps.resolveConfiguredFieldName(noteInfo, config.fields.miscInfo);
|
|
const miscInfo = this.deps.formatMiscInfoPatternForMediaPath(
|
|
miscInfoFilename,
|
|
job.startTime,
|
|
job.sourceUrl,
|
|
);
|
|
if (miscInfoField && miscInfo) {
|
|
mediaFields[miscInfoField] = miscInfo;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(mediaFields).length === 0) {
|
|
return 'failed';
|
|
}
|
|
|
|
await this.deps.client.updateNoteFields(job.noteId, mediaFields);
|
|
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
|
|
await this.deps.showNotification(job.noteId, job.label, errorSuffix);
|
|
this.deps.logInfo('Applied queued YouTube media update for note:', job.noteId);
|
|
return errors.length === 0 ? 'updated' : 'partial';
|
|
}
|
|
|
|
private async generateImageFromInput(
|
|
videoPath: MediaInput,
|
|
startTime: number,
|
|
endTime: number,
|
|
animatedLeadInSeconds = 0,
|
|
): Promise<Buffer | null> {
|
|
const config = this.deps.getConfig();
|
|
if (config.media?.imageType === 'avif') {
|
|
return this.deps.mediaGenerator.generateAnimatedImage(
|
|
videoPath,
|
|
startTime,
|
|
endTime,
|
|
config.media?.audioPadding,
|
|
{
|
|
fps: config.media?.animatedFps,
|
|
maxWidth: config.media?.animatedMaxWidth,
|
|
maxHeight: config.media?.animatedMaxHeight,
|
|
crf: config.media?.animatedCrf,
|
|
leadingStillDuration: animatedLeadInSeconds,
|
|
},
|
|
);
|
|
}
|
|
|
|
const timestamp = startTime + (endTime - startTime) / 2;
|
|
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
|
|
format: config.media?.imageFormat as 'jpg' | 'png' | 'webp',
|
|
quality: config.media?.imageQuality,
|
|
maxWidth: config.media?.imageMaxWidth,
|
|
maxHeight: config.media?.imageMaxHeight,
|
|
});
|
|
}
|
|
}
|