mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
feat(youtube): add mediaCache mode and safer stream media extraction (#130)
This commit is contained in:
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
@@ -266,6 +267,8 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
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',
|
||||
@@ -338,11 +341,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
audioPaths.push(recordMediaPath(path));
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
imagePaths.push(recordMediaPath(path));
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
test('CardCreationService counts locally created sentence cards', async () => {
|
||||
@@ -287,6 +288,8 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
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',
|
||||
@@ -345,11 +348,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
audioPaths.push(recordMediaPath(path));
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
imagePaths.push(recordMediaPath(path));
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
@@ -398,6 +401,259 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
});
|
||||
|
||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||
audioCalls.push({ path: typeof path === 'string' ? path : path.path, audioStreamIndex });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 10, 12);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioCalls, [
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('CardCreationService queues YouTube media when required cache is not ready', async () => {
|
||||
const mediaCalls: string[] = [];
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const queuedUpdates: Array<{
|
||||
sourceUrl: string;
|
||||
noteId: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
label: string | number;
|
||||
audioFieldName?: string;
|
||||
imageFieldName?: string;
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
}> = [];
|
||||
let streamRequests = 0;
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 2,
|
||||
requestProperty: async () => {
|
||||
streamRequests += 1;
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
},
|
||||
}) as never,
|
||||
getCachedMediaPath: async () => null,
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
queuePendingYoutubeMediaUpdate: (job) => {
|
||||
queuedUpdates.push(job);
|
||||
},
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
MiscInfo: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => {
|
||||
mediaCalls.push('audio');
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => {
|
||||
mediaCalls.push('image');
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 10, 12);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.equal(streamRequests, 0);
|
||||
assert.deepEqual(mediaCalls, []);
|
||||
assert.deepEqual(queuedUpdates, [
|
||||
{
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 42,
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
label: 'テスト',
|
||||
audioFieldName: 'SentenceAudio',
|
||||
imageFieldName: 'Picture',
|
||||
miscInfoFieldName: 'MiscInfo',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
|
||||
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
|
||||
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const duplicateLookupExpressions: string[] = [];
|
||||
|
||||
@@ -5,15 +5,29 @@ import {
|
||||
} from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { AiConfig } from '../types/integrations';
|
||||
import { MpvClient } from '../types/runtime';
|
||||
import { resolveSentenceBackText } from './ai';
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import {
|
||||
resolveMediaGenerationInput,
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateAudio !== false;
|
||||
}
|
||||
|
||||
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
export interface CardCreationNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -38,14 +52,14 @@ interface CardCreationClient {
|
||||
|
||||
interface CardCreationMediaGenerator {
|
||||
generateAudio(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: 'jpg' | 'png' | 'webp';
|
||||
@@ -55,7 +69,7 @@ interface CardCreationMediaGenerator {
|
||||
},
|
||||
): Promise<Buffer | null>;
|
||||
generateAnimatedImage(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
@@ -74,6 +88,9 @@ interface CardCreationDeps {
|
||||
getAiConfig: () => AiConfig;
|
||||
getTimingTracker: () => SubtitleTimingTracker;
|
||||
getMpvClient: () => MpvClient;
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
client: CardCreationClient;
|
||||
mediaGenerator: CardCreationMediaGenerator;
|
||||
@@ -121,6 +138,19 @@ interface CardCreationDeps {
|
||||
export class CardCreationService {
|
||||
constructor(private readonly deps: CardCreationDeps) {}
|
||||
|
||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||
const options: MediaGenerationInputResolverOptions = {
|
||||
logDebug: (message) => log.debug(message),
|
||||
};
|
||||
if (this.deps.getCachedMediaPath) {
|
||||
options.getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||
}
|
||||
if (this.deps.shouldRequireRemoteMediaCache?.()) {
|
||||
options.remoteCacheMode = 'required';
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private getConfiguredAnkiTags(): string[] {
|
||||
const tags = this.deps.getConfig().tags;
|
||||
if (!Array.isArray(tags)) {
|
||||
@@ -246,14 +276,18 @@ 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')
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
@@ -281,7 +315,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -441,7 +475,7 @@ export class CardCreationService {
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (shouldGenerateImage(this.deps.getConfig())) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -522,9 +556,23 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
if (!videoPath) {
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const missingRequestedMediaInput =
|
||||
(generateImage && !videoPath) || (generateAudio && !audioSourcePath);
|
||||
const shouldQueuePendingYoutubeMedia =
|
||||
missingRequestedMediaInput &&
|
||||
this.deps.shouldRequireRemoteMediaCache?.() === true &&
|
||||
typeof this.deps.queuePendingYoutubeMediaUpdate === 'function';
|
||||
if (missingRequestedMediaInput && !shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.showOsdNotification('No video loaded');
|
||||
return false;
|
||||
}
|
||||
@@ -534,13 +582,13 @@ export class CardCreationService {
|
||||
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
|
||||
const translationField = config.fields?.translation || 'SelectionText';
|
||||
let resolvedMiscInfoField: string | null = null;
|
||||
let resolvedSentenceAudioField: string = audioFieldName;
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
const ankiAiConfig = this.deps.getConfig().ai;
|
||||
const ankiAiConfig = config.ai;
|
||||
const ankiAiEnabled =
|
||||
typeof ankiAiConfig === 'object' && ankiAiConfig !== null
|
||||
? ankiAiConfig.enabled === true
|
||||
@@ -563,7 +611,7 @@ export class CardCreationService {
|
||||
|
||||
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
|
||||
fields.IsSentenceCard = 'x';
|
||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||
fields[getConfiguredWordFieldName(config)] = sentence;
|
||||
}
|
||||
|
||||
const pendingNoteInfo = this.createPendingNoteInfo(fields);
|
||||
@@ -572,7 +620,7 @@ export class CardCreationService {
|
||||
);
|
||||
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
|
||||
pendingNoteFields,
|
||||
this.deps.getConfig(),
|
||||
config,
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
@@ -590,7 +638,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck || 'Default';
|
||||
const deck = config.deck || 'Default';
|
||||
let noteId: number;
|
||||
try {
|
||||
noteId = await this.deps.client.addNote(
|
||||
@@ -636,7 +684,7 @@ export class CardCreationService {
|
||||
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
|
||||
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.miscInfo,
|
||||
config.fields?.miscInfo,
|
||||
);
|
||||
|
||||
const cardTypeFields: Record<string, string> = {};
|
||||
@@ -654,41 +702,67 @@ export class CardCreationService {
|
||||
errors.push('card type fields');
|
||||
}
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
if (shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||
sourceUrl: mpvClient.currentVideoPath,
|
||||
noteId,
|
||||
startTime,
|
||||
endTime,
|
||||
label,
|
||||
audioFieldName: resolvedSentenceAudioField,
|
||||
imageFieldName: config.fields?.image,
|
||||
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||
generateAudio,
|
||||
generateImage,
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (missingRequestedMediaInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
|
||||
if (generateImage) {
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
||||
|
||||
const imageField = this.deps.getConfig().fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
const imageField = config.fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().fields?.miscInfo) {
|
||||
if (config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
|
||||
if (miscInfo && resolvedMiscInfoField) {
|
||||
mediaFields[resolvedMiscInfoField] = miscInfo;
|
||||
@@ -704,7 +778,6 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
|
||||
await this.deps.showNotification(noteId, label, errorSuffix);
|
||||
return true;
|
||||
@@ -740,7 +813,7 @@ export class CardCreationService {
|
||||
}
|
||||
|
||||
private async mediaGenerateAudio(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<Buffer | null> {
|
||||
@@ -754,12 +827,15 @@ export class CardCreationService {
|
||||
startTime,
|
||||
endTime,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
resolveAudioStreamIndexForMediaGeneration(
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async generateImageBuffer(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import * as mediaSource from './media-source';
|
||||
|
||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||
|
||||
type StructuredMediaInput = {
|
||||
path: string;
|
||||
source: string;
|
||||
singleResolvedStream: boolean;
|
||||
inputOptions?: {
|
||||
reconnect?: boolean;
|
||||
userAgent?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type StructuredMediaResolver = (
|
||||
mpvClient: Parameters<typeof resolveMediaGenerationInputPath>[0],
|
||||
kind?: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||
options?: {
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||
) => Promise<string | null>;
|
||||
remoteCacheMode?: 'optional' | 'required';
|
||||
logDebug?: (message: string) => void;
|
||||
},
|
||||
) => Promise<StructuredMediaInput | null>;
|
||||
|
||||
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
||||
const result = await resolveMediaGenerationInputPath({
|
||||
@@ -62,3 +88,211 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
|
||||
|
||||
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
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',
|
||||
].join(';');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'stream-open-filename') return edlSource;
|
||||
if (name === 'user-agent') return 'Mozilla/5.0';
|
||||
if (name === 'http-header-fields') {
|
||||
return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.reconnect, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||
assert.deepEqual(result?.inputOptions?.headers, {
|
||||
Referer: 'https://www.youtube.com/',
|
||||
'X-Test': 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput reads file-local mpv request options', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'stream-open-filename') {
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
}
|
||||
if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent';
|
||||
if (name === 'options/http-header-fields') return ['X-Shared: ok'];
|
||||
if (name === 'file-local-options/http-header-fields') {
|
||||
return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent');
|
||||
assert.deepEqual(result?.inputOptions?.headers, {
|
||||
'X-Shared': 'ok',
|
||||
Referer: 'https://m.youtube.com/',
|
||||
'X-Local': 'yes',
|
||||
Origin: 'https://www.youtube.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv');
|
||||
assert.equal(result?.source, 'youtube-cache');
|
||||
assert.equal(result?.singleResolvedStream, false);
|
||||
assert.equal(result?.inputOptions, undefined);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput debug-logs sanitized YouTube cache hits', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
logDebug: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.source, 'youtube-cache');
|
||||
assert.match(logs.join('\n'), /kind=video source=youtube-cache/);
|
||||
assert.match(
|
||||
logs.join('\n'),
|
||||
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
|
||||
);
|
||||
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback/);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput does not fall back to direct remote streams when cache is required', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => null,
|
||||
remoteCacheMode: 'required',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput falls back when optional cache lookup fails', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => {
|
||||
throw new Error('cache unavailable');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.equal(result?.source, 'stream-open-filename');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput debug-logs sanitized required-cache misses', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => null,
|
||||
remoteCacheMode: 'required',
|
||||
logDebug: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.match(logs.join('\n'), /kind=video source=cache-miss/);
|
||||
assert.match(logs.join('\n'), /mode=required/);
|
||||
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback|googlevideo/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||
import type { MpvClient } from '../types/runtime';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
export type MediaGenerationInputSource =
|
||||
| 'current-path'
|
||||
| 'stream-open-filename'
|
||||
| 'edl-stream'
|
||||
| 'youtube-cache';
|
||||
|
||||
export interface ResolvedMediaGenerationInput {
|
||||
path: string;
|
||||
kind: MediaGenerationKind;
|
||||
source: MediaGenerationInputSource;
|
||||
singleResolvedStream: boolean;
|
||||
inputOptions?: MediaInputOptions;
|
||||
}
|
||||
|
||||
export interface MediaGenerationInputResolverOptions {
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: MediaGenerationKind,
|
||||
) => Promise<string | null>;
|
||||
remoteCacheMode?: 'optional' | 'required';
|
||||
logDebug?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function resolveAudioStreamIndexForMediaGeneration(
|
||||
input: MediaInput,
|
||||
audioStreamIndex: number | null | undefined,
|
||||
): number | undefined {
|
||||
if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') {
|
||||
return undefined;
|
||||
}
|
||||
return audioStreamIndex ?? undefined;
|
||||
}
|
||||
|
||||
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
||||
'http-header-fields',
|
||||
'options/http-header-fields',
|
||||
'file-local-options/http-header-fields',
|
||||
] as const;
|
||||
const USER_AGENT_PROPERTY_NAMES = [
|
||||
'file-local-options/user-agent',
|
||||
'options/user-agent',
|
||||
'user-agent',
|
||||
] as const;
|
||||
const REFERRER_PROPERTY_NAMES = [
|
||||
'file-local-options/referrer',
|
||||
'options/referrer',
|
||||
'referrer',
|
||||
] as const;
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -11,6 +61,17 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeHeaderName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||
return [...matches]
|
||||
@@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource(
|
||||
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
|
||||
}
|
||||
|
||||
function getHostname(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
function isGoogleVideoMediaPath(value: string): boolean {
|
||||
const host = getHostname(value);
|
||||
return Boolean(host && matchesHost(host, 'googlevideo.com'));
|
||||
}
|
||||
|
||||
function describeMediaPathForDebugLog(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
|
||||
}
|
||||
return `${url.protocol.replace(/:$/, '')}:`;
|
||||
} catch {
|
||||
// Not a URL; treat as a local file path below.
|
||||
}
|
||||
|
||||
if (value.startsWith('edl://')) {
|
||||
return 'edl:';
|
||||
}
|
||||
|
||||
return `local:${value}`;
|
||||
}
|
||||
|
||||
function logMediaResolutionDebug(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
message: string,
|
||||
): void {
|
||||
if (!options.logDebug) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
options.logDebug(`[media-source] ${message}`);
|
||||
} catch {
|
||||
// Debug logging should not affect media generation.
|
||||
}
|
||||
}
|
||||
|
||||
function logResolvedMediaGenerationInput(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
currentVideoPath: string,
|
||||
result: ResolvedMediaGenerationInput,
|
||||
): void {
|
||||
logMediaResolutionDebug(
|
||||
options,
|
||||
[
|
||||
`kind=${result.kind}`,
|
||||
`source=${result.source}`,
|
||||
`input=${describeMediaPathForDebugLog(result.path)}`,
|
||||
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
||||
`singleResolvedStream=${result.singleResolvedStream}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function logMediaGenerationInputMiss(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
kind: MediaGenerationKind,
|
||||
currentVideoPath: string,
|
||||
reason: string,
|
||||
): void {
|
||||
logMediaResolutionDebug(
|
||||
options,
|
||||
[
|
||||
`kind=${kind}`,
|
||||
'source=cache-miss',
|
||||
`reason=${reason}`,
|
||||
`mode=${options.remoteCacheMode ?? 'optional'}`,
|
||||
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMpvHeaderField(value: string): [string, string] | null {
|
||||
const separatorIndex = value.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
||||
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
||||
if (!name || !headerValue) {
|
||||
return null;
|
||||
}
|
||||
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||
}
|
||||
|
||||
function toHeaderFields(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function requestOptionalMpvProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!mpvClient.requestProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await mpvClient.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFirstNonEmptyStringProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
names: readonly string[],
|
||||
): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRemoteInputOptions(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
resolvedPath: string,
|
||||
): Promise<MediaInputOptions | undefined> {
|
||||
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||
const mpvHeaderFields = toHeaderFields(
|
||||
await requestOptionalMpvProperty(mpvClient, propertyName),
|
||||
);
|
||||
for (const field of mpvHeaderFields) {
|
||||
const parsed = parseMpvHeaderField(field);
|
||||
if (parsed) {
|
||||
headers[parsed[0]] = parsed[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
|
||||
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
|
||||
if (referrer) {
|
||||
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||
}
|
||||
if (isGoogleVideoMediaPath(resolvedPath)) {
|
||||
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
||||
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
||||
}
|
||||
|
||||
return {
|
||||
reconnect: true,
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function toResolvedMediaGenerationInput(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
path: string,
|
||||
kind: MediaGenerationKind,
|
||||
source: MediaGenerationInputSource,
|
||||
singleResolvedStream: boolean,
|
||||
): Promise<ResolvedMediaGenerationInput> {
|
||||
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
|
||||
return {
|
||||
path,
|
||||
kind,
|
||||
source,
|
||||
singleResolvedStream,
|
||||
...(inputOptions ? { inputOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInput(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
options: MediaGenerationInputResolverOptions = {},
|
||||
): Promise<ResolvedMediaGenerationInput | null> {
|
||||
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||
if (!currentVideoPath) {
|
||||
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRemoteMediaPath(currentVideoPath)) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: currentVideoPath,
|
||||
kind,
|
||||
source: 'current-path',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let cachedPath: string | null = null;
|
||||
if (options.getCachedMediaPath) {
|
||||
try {
|
||||
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
|
||||
} catch {
|
||||
cachedPath = null;
|
||||
}
|
||||
}
|
||||
if (cachedPath) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: cachedPath,
|
||||
kind,
|
||||
source: 'youtube-cache',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (options.remoteCacheMode === 'required') {
|
||||
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mpvClient?.requestProperty) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: currentVideoPath,
|
||||
kind,
|
||||
source: 'current-path',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamOpenFilename = trimToNonEmptyString(
|
||||
await mpvClient.requestProperty('stream-open-filename'),
|
||||
);
|
||||
if (streamOpenFilename?.startsWith('edl://')) {
|
||||
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
||||
if (preferredUrl) {
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
preferredUrl,
|
||||
kind,
|
||||
'edl-stream',
|
||||
true,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
false,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
if (streamOpenFilename) {
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
isRemoteMediaPath(streamOpenFilename),
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||
}
|
||||
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
currentVideoPath,
|
||||
kind,
|
||||
'current-path',
|
||||
false,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInputPath(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
|
||||
@@ -409,3 +409,59 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
||||
assert.deepEqual(imageContext, sidebarContext);
|
||||
assert.equal(miscInfoStartTime, 10);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const queuedUpdates: Array<{
|
||||
noteId: number;
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo;
|
||||
context?: SubtitleMiningContext;
|
||||
label: string | number;
|
||||
}> = [];
|
||||
const mediaCalls: string[] = [];
|
||||
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
},
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.generateAudio = async () => {
|
||||
mediaCalls.push('audio');
|
||||
return Buffer.from('audio');
|
||||
};
|
||||
harness.deps.generateImage = async () => {
|
||||
mediaCalls.push('image');
|
||||
return Buffer.from('image');
|
||||
};
|
||||
harness.deps.queuePendingYoutubeMediaUpdate = async (job) => {
|
||||
queuedUpdates.push(job);
|
||||
return true;
|
||||
};
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.deepEqual(mediaCalls, []);
|
||||
assert.equal(queuedUpdates.length, 1);
|
||||
assert.equal(queuedUpdates[0]?.noteId, 42);
|
||||
assert.equal(queuedUpdates[0]?.label, 'taberu');
|
||||
assert.equal(queuedUpdates[0]?.context, undefined);
|
||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||
});
|
||||
|
||||
@@ -85,6 +85,12 @@ export interface NoteUpdateWorkflowDeps {
|
||||
) => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||
queuePendingYoutubeMediaUpdate?: (job: {
|
||||
noteId: number;
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo;
|
||||
context?: SubtitleMiningContext;
|
||||
label: string | number;
|
||||
}) => Promise<boolean>;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
showOsdNotification: (message: string) => void;
|
||||
@@ -195,6 +201,7 @@ export class NoteUpdateWorkflow {
|
||||
sentenceField,
|
||||
config.fields?.sentence,
|
||||
);
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
@@ -227,7 +234,19 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.media?.generateAudio) {
|
||||
const generateAudio = config.media?.generateAudio !== false;
|
||||
const generateImage = config.media?.generateImage !== false;
|
||||
const mediaCacheQueued =
|
||||
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
|
||||
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||
noteId,
|
||||
noteInfo,
|
||||
context: subtitleMiningContext ?? undefined,
|
||||
label: noteLabel,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!mediaCacheQueued && generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.deps.generateAudioFilename();
|
||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
||||
@@ -252,7 +271,7 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.media?.generateImage) {
|
||||
if (!mediaCacheQueued && generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.deps.generateImageFilename();
|
||||
@@ -287,7 +306,7 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fields?.miscInfo) {
|
||||
if (!mediaCacheQueued && config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||
miscInfoFilename || '',
|
||||
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||
@@ -305,8 +324,8 @@ export class NoteUpdateWorkflow {
|
||||
if (updatePerformed) {
|
||||
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
||||
await this.deps.addConfiguredTagsToNote(noteId);
|
||||
this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId);
|
||||
await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId);
|
||||
this.deps.logInfo('Updated card fields for:', noteLabel);
|
||||
await this.deps.showNotification(noteId, noteLabel);
|
||||
}
|
||||
|
||||
if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import {
|
||||
PendingYoutubeMediaQueue,
|
||||
type PendingYoutubeMediaQueueDeps,
|
||||
} from './pending-youtube-media-queue';
|
||||
|
||||
function createDeps(
|
||||
overrides: Partial<PendingYoutubeMediaQueueDeps> = {},
|
||||
): PendingYoutubeMediaQueueDeps {
|
||||
const warnings: unknown[][] = [];
|
||||
const deps: PendingYoutubeMediaQueueDeps & { warnings: unknown[][] } = {
|
||||
client: {
|
||||
notesInfo: async () => [],
|
||||
updateNoteFields: async () => {},
|
||||
storeMediaFile: async () => {},
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
generateAnimatedImage: async () => Buffer.from('image'),
|
||||
},
|
||||
getConfig: () =>
|
||||
({
|
||||
media: { generateAudio: true, generateImage: true },
|
||||
fields: {},
|
||||
}) as AnkiConnectConfig,
|
||||
getCurrentVideoPath: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
getCachedMediaPath: async () => null,
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
|
||||
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
|
||||
resolveConfiguredFieldName: () => 'Picture',
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
generateAudioFilename: () => 'audio.mp3',
|
||||
generateImageFilename: () => 'image.webp',
|
||||
formatMiscInfoPatternForMediaPath: () => '',
|
||||
showStatusNotification: () => {},
|
||||
showNotification: async () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: (...args) => {
|
||||
warnings.push(args);
|
||||
},
|
||||
logError: () => {},
|
||||
warnings,
|
||||
...overrides,
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
|
||||
const deps = createDeps({
|
||||
getCachedMediaPath: async () => {
|
||||
throw new Error('cache unavailable');
|
||||
},
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
const queued = await queue.queueFromNote({
|
||||
noteId: 42,
|
||||
noteInfo: { noteId: 42, fields: {} },
|
||||
label: 'demo',
|
||||
});
|
||||
|
||||
assert.equal(queued, false);
|
||||
assert.deepEqual((deps as typeof deps & { warnings: unknown[][] }).warnings, [
|
||||
[
|
||||
'Failed to read YouTube cache state; falling back to immediate media generation:',
|
||||
'cache unavailable',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PendingYoutubeMediaQueue drains matching queued jobs when the cache download fails', async () => {
|
||||
const statusMessages: string[] = [];
|
||||
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
|
||||
const updatedNotes: number[] = [];
|
||||
const deps = createDeps({
|
||||
client: {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId) => {
|
||||
updatedNotes.push(noteId);
|
||||
},
|
||||
storeMediaFile: async () => {},
|
||||
},
|
||||
showStatusNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
showNotification: async (noteId, label, suffix) => {
|
||||
notifications.push({ noteId, label, suffix });
|
||||
},
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
queue.enqueue({
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 42,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
label: 'queued',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
});
|
||||
|
||||
await queue.handleFailed('https://youtu.be/abc123');
|
||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.deepEqual(updatedNotes, []);
|
||||
assert.deepEqual(notifications, [{ noteId: 42, label: 'queued', suffix: 'media cache failed' }]);
|
||||
assert.equal(
|
||||
statusMessages.includes('YouTube media cache failed. Media was not added to 1 queued card.'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queuing from notes', async () => {
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
const deps = createDeps({
|
||||
client: {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
},
|
||||
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
const queued = await queue.queueFromNote({
|
||||
noteId: 42,
|
||||
noteInfo: { noteId: 42, fields: {} },
|
||||
label: 'demo',
|
||||
});
|
||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
|
||||
});
|
||||
|
||||
test('PendingYoutubeMediaQueue only announces a download once per source while jobs collect', () => {
|
||||
const statusMessages: string[] = [];
|
||||
const deps = createDeps({
|
||||
showStatusNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
queue.enqueue({
|
||||
sourceUrl: 'https://youtu.be/abc123',
|
||||
noteId: 1,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
label: 'first',
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
});
|
||||
queue.enqueue({
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 2,
|
||||
startTime: 3,
|
||||
endTime: 4,
|
||||
label: 'second',
|
||||
generateAudio: false,
|
||||
generateImage: true,
|
||||
});
|
||||
|
||||
assert.equal(statusMessages.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
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: () => 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(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,
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface PendingYoutubeMediaUpdate {
|
||||
sourceUrl: string;
|
||||
noteId: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
label: string | number;
|
||||
audioFieldName?: string;
|
||||
imageFieldName?: string;
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function getYoutubeVideoId(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
|
||||
return trimToNonEmptyString(parsed.pathname.replace(/^\/+/, '').split('/')[0]);
|
||||
}
|
||||
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
|
||||
const watchId = trimToNonEmptyString(parsed.searchParams.get('v'));
|
||||
if (watchId) {
|
||||
return watchId;
|
||||
}
|
||||
const parts = parsed.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'shorts' || parts[0] === 'embed' || parts[0] === 'live') {
|
||||
return trimToNonEmptyString(parts[1]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function youtubeMediaUrlsMatch(a: string, b: string): boolean {
|
||||
if (a.trim() === b.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aVideoId = getYoutubeVideoId(a);
|
||||
const bVideoId = getYoutubeVideoId(b);
|
||||
return Boolean(aVideoId && bVideoId && aVideoId === bVideoId);
|
||||
}
|
||||
Reference in New Issue
Block a user