mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-24 03:13:30 -07:00
feat(youtube): queue media for background cache and fill fields when rea
- Add `youtube.mediaCache.maxHeight` config option (default 720p) - Background mode creates text-only cards while cache downloads, queues media updates, fills audio/image fields once cached file is ready - Announce cache download start and readiness via overlay/OSD notifications - Skip mpv stream indexes when generating audio from a YouTube cached file
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: youtube
|
area: youtube
|
||||||
|
|
||||||
- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps.
|
- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files.
|
||||||
- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable.
|
- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default.
|
||||||
|
|||||||
@@ -620,7 +620,8 @@
|
|||||||
"jpn"
|
"jpn"
|
||||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
"mediaCache": {
|
"mediaCache": {
|
||||||
"mode": "direct" // How YouTube card audio/images are extracted. Values: direct | background
|
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||||
|
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||||
} // Media cache setting.
|
} // Media cache setting.
|
||||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
|
|||||||
@@ -1522,18 +1522,20 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
|||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": ["ja", "jpn"],
|
"primarySubLanguages": ["ja", "jpn"],
|
||||||
"mediaCache": {
|
"mediaCache": {
|
||||||
"mode": "direct"
|
"mode": "direct",
|
||||||
|
"maxHeight": 720
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
|
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||||
|
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||||
|
|
||||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download; card media generation uses the cached file once it is ready and otherwise falls back to direct stream extraction.
|
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready.
|
||||||
|
|
||||||
Current launcher behavior:
|
Current launcher behavior:
|
||||||
|
|
||||||
|
|||||||
@@ -620,7 +620,8 @@
|
|||||||
"jpn"
|
"jpn"
|
||||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
"mediaCache": {
|
"mediaCache": {
|
||||||
"mode": "direct" // How YouTube card audio/images are extracted. Values: direct | background
|
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||||
|
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||||
} // Media cache setting.
|
} // Media cache setting.
|
||||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AnkiIntegration } from './anki-integration';
|
import { AnkiIntegration } from './anki-integration';
|
||||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||||
|
import type { MediaInput } from './media-input';
|
||||||
import { AnkiConnectConfig } from './types';
|
import { AnkiConnectConfig } from './types';
|
||||||
|
|
||||||
type TestOverlayNotificationPayload = {
|
type TestOverlayNotificationPayload = {
|
||||||
@@ -24,6 +25,13 @@ interface IntegrationTestContext {
|
|||||||
stateDir: string;
|
stateDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeMediaInputForTest(input: MediaInput): string {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return `${input.path}:${input.source ?? 'raw'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function createIntegrationTestContext(
|
function createIntegrationTestContext(
|
||||||
options: {
|
options: {
|
||||||
highlightEnabled?: boolean;
|
highlightEnabled?: boolean;
|
||||||
@@ -527,6 +535,354 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
|||||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
|
const storedMedia: string[] = [];
|
||||||
|
const mediaInputs: string[] = [];
|
||||||
|
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
imageFormat: 'jpg',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
notificationType: 'osd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
(text) => {
|
||||||
|
osdMessages.push(text);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const internals = integration as unknown as {
|
||||||
|
client: {
|
||||||
|
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||||
|
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||||
|
storeMediaFile: (filename: string, data: Buffer) => Promise<void>;
|
||||||
|
};
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: (
|
||||||
|
path: MediaInput,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
audioPadding?: number,
|
||||||
|
audioStreamIndex?: number,
|
||||||
|
) => Promise<Buffer>;
|
||||||
|
generateScreenshot: (path: MediaInput) => Promise<Buffer>;
|
||||||
|
};
|
||||||
|
queuePendingYoutubeMediaUpdate: (job: {
|
||||||
|
sourceUrl: string;
|
||||||
|
noteId: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
label: string | number;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
audioFieldName?: string;
|
||||||
|
imageFieldName?: string;
|
||||||
|
generateAudio: boolean;
|
||||||
|
generateImage: boolean;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
internals.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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
internals.mediaGenerator = {
|
||||||
|
generateAudio: async (mediaPath, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
||||||
|
mediaInputs.push(
|
||||||
|
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}`,
|
||||||
|
);
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
generateScreenshot: async (mediaPath) => {
|
||||||
|
mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`);
|
||||||
|
return Buffer.from('image');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
internals.queuePendingYoutubeMediaUpdate({
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
noteId: 101,
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
label: 'first',
|
||||||
|
audioStreamIndex: 22,
|
||||||
|
audioFieldName: 'SentenceAudio',
|
||||||
|
imageFieldName: 'Picture',
|
||||||
|
generateAudio: true,
|
||||||
|
generateImage: true,
|
||||||
|
});
|
||||||
|
internals.queuePendingYoutubeMediaUpdate({
|
||||||
|
sourceUrl: 'https://youtu.be/abc123',
|
||||||
|
noteId: 202,
|
||||||
|
startTime: 20,
|
||||||
|
endTime: 22,
|
||||||
|
label: 'second',
|
||||||
|
audioStreamIndex: 23,
|
||||||
|
audioFieldName: 'SentenceAudio',
|
||||||
|
imageFieldName: 'Picture',
|
||||||
|
generateAudio: true,
|
||||||
|
generateImage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||||
|
|
||||||
|
assert.deepEqual(mediaInputs, [
|
||||||
|
'audio:/tmp/media.mkv:youtube-cache:auto',
|
||||||
|
'image:/tmp/media.mkv:youtube-cache',
|
||||||
|
'audio:/tmp/media.mkv:youtube-cache:auto',
|
||||||
|
'image:/tmp/media.mkv:youtube-cache',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
updatedNotes.map((update) => update.noteId),
|
||||||
|
[101, 202],
|
||||||
|
);
|
||||||
|
const firstUpdate = updatedNotes[0];
|
||||||
|
const secondUpdate = updatedNotes[1];
|
||||||
|
assert.ok(firstUpdate);
|
||||||
|
assert.ok(secondUpdate);
|
||||||
|
assert.match(firstUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||||
|
assert.match(firstUpdate.fields.Picture ?? '', /^<img src="image_/);
|
||||||
|
assert.match(secondUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||||
|
assert.match(secondUpdate.fields.Picture ?? '', /^<img src="image_/);
|
||||||
|
assert.equal(storedMedia.length, 4);
|
||||||
|
assert.equal(
|
||||||
|
osdMessages.some((message) =>
|
||||||
|
message.includes('YouTube media cache ready. Adding media to 2 queued cards.'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration reports partial queued YouTube media updates separately from failures', async () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
|
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
|
||||||
|
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
imageFormat: 'jpg',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
notificationType: 'osd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
(text) => {
|
||||||
|
osdMessages.push(text);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const internals = integration as unknown as {
|
||||||
|
client: {
|
||||||
|
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||||
|
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||||
|
storeMediaFile: () => Promise<void>;
|
||||||
|
};
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: () => Promise<Buffer>;
|
||||||
|
generateScreenshot: () => Promise<Buffer>;
|
||||||
|
};
|
||||||
|
queuePendingYoutubeMediaUpdate: (job: {
|
||||||
|
sourceUrl: string;
|
||||||
|
noteId: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
label: string | number;
|
||||||
|
audioFieldName?: string;
|
||||||
|
imageFieldName?: string;
|
||||||
|
generateAudio: boolean;
|
||||||
|
generateImage: boolean;
|
||||||
|
}) => void;
|
||||||
|
showNotification: (noteId: number, label: string | number, suffix?: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
internals.client = {
|
||||||
|
notesInfo: async (noteIds) =>
|
||||||
|
noteIds.map((noteId) => ({
|
||||||
|
noteId,
|
||||||
|
fields: {
|
||||||
|
SentenceAudio: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
updateNoteFields: async (noteId, fields) => {
|
||||||
|
updatedNotes.push({ noteId, fields });
|
||||||
|
},
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
};
|
||||||
|
internals.mediaGenerator = {
|
||||||
|
generateAudio: async () => {
|
||||||
|
throw new Error('audio stream not found');
|
||||||
|
},
|
||||||
|
generateScreenshot: async () => Buffer.from('image'),
|
||||||
|
};
|
||||||
|
internals.showNotification = async (noteId, label, suffix) => {
|
||||||
|
notifications.push({ noteId, label, suffix });
|
||||||
|
};
|
||||||
|
|
||||||
|
internals.queuePendingYoutubeMediaUpdate({
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=partial',
|
||||||
|
noteId: 303,
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
label: 'partial',
|
||||||
|
audioFieldName: 'SentenceAudio',
|
||||||
|
imageFieldName: 'Picture',
|
||||||
|
generateAudio: true,
|
||||||
|
generateImage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/partial', '/tmp/media.mkv');
|
||||||
|
|
||||||
|
assert.equal(updatedNotes.length, 1);
|
||||||
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||||
|
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
||||||
|
assert.deepEqual(notifications, [{ noteId: 303, label: 'partial', suffix: 'audio failed' }]);
|
||||||
|
assert.equal(
|
||||||
|
osdMessages.some((message) =>
|
||||||
|
message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
|
||||||
|
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
||||||
|
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
media: {
|
||||||
|
audioPadding: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
currentSubStart: 10,
|
||||||
|
currentSubEnd: 12,
|
||||||
|
currentTimePos: 11,
|
||||||
|
} as never,
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const internals = integration as unknown as {
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: (
|
||||||
|
path: { path: string },
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
audioPadding?: number,
|
||||||
|
audioStreamIndex?: number,
|
||||||
|
) => Promise<Buffer>;
|
||||||
|
};
|
||||||
|
generateAudio: () => Promise<Buffer | null>;
|
||||||
|
};
|
||||||
|
internals.mediaGenerator = {
|
||||||
|
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
||||||
|
audioCalls.push({ path: path.path, audioStreamIndex });
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await internals.generateAudio();
|
||||||
|
|
||||||
|
assert.deepEqual(audioCalls, [
|
||||||
|
{
|
||||||
|
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||||
|
audioStreamIndex: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration announces ready YouTube cache when no queued notes exist', async () => {
|
||||||
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||||
|
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
behavior: {
|
||||||
|
notificationType: 'overlay',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
(payload) => {
|
||||||
|
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||||
|
|
||||||
|
assert.equal(overlayNotifications.length, 1);
|
||||||
|
assert.equal(overlayNotifications[0]?.title, 'SubMiner');
|
||||||
|
assert.equal(overlayNotifications[0]?.body, 'YouTube media cache ready.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration can let caller own no-queued YouTube cache ready notification', async () => {
|
||||||
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||||
|
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
behavior: {
|
||||||
|
notificationType: 'overlay',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
(payload) => {
|
||||||
|
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv', {
|
||||||
|
notifyNoQueued: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(overlayNotifications, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||||
|
|||||||
+121
-8
@@ -62,10 +62,14 @@ import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflo
|
|||||||
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
||||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||||
import {
|
import {
|
||||||
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
resolveMediaGenerationInput,
|
resolveMediaGenerationInput,
|
||||||
resolveMediaGenerationInputPath,
|
resolveMediaGenerationInputPath,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './anki-integration/media-source';
|
} 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 { PendingYoutubeMediaQueueReadyOptions } from './anki-integration/pending-youtube-media-queue';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration');
|
const log = createLogger('anki').child('integration');
|
||||||
|
|
||||||
@@ -231,6 +235,8 @@ export class AnkiIntegration {
|
|||||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||||
null;
|
null;
|
||||||
|
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
|
||||||
|
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: AnkiConnectConfig,
|
config: AnkiConnectConfig,
|
||||||
@@ -247,6 +253,7 @@ export class AnkiIntegration {
|
|||||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean,
|
||||||
) {
|
) {
|
||||||
this.config = normalizeAnkiIntegrationConfig(config);
|
this.config = normalizeAnkiIntegrationConfig(config);
|
||||||
this.aiConfig = { ...aiConfig };
|
this.aiConfig = { ...aiConfig };
|
||||||
@@ -260,6 +267,8 @@ export class AnkiIntegration {
|
|||||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||||
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||||
|
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
|
||||||
|
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
|
||||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||||
this.pollingRunner = this.createPollingRunner();
|
this.pollingRunner = this.createPollingRunner();
|
||||||
this.cardCreationService = this.createCardCreationService();
|
this.cardCreationService = this.createCardCreationService();
|
||||||
@@ -304,13 +313,80 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||||
const options: MediaGenerationInputResolverOptions = {};
|
const options: MediaGenerationInputResolverOptions = {
|
||||||
|
logDebug: (message) => log.debug(message),
|
||||||
|
};
|
||||||
if (this.getCachedMediaPath) {
|
if (this.getCachedMediaPath) {
|
||||||
options.getCachedMediaPath = this.getCachedMediaPath;
|
options.getCachedMediaPath = this.getCachedMediaPath;
|
||||||
}
|
}
|
||||||
|
if (this.shouldRequireRemoteMediaCache?.()) {
|
||||||
|
options.remoteCacheMode = 'required';
|
||||||
|
}
|
||||||
return options;
|
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 {
|
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
|
||||||
return new KnownWordCacheManager({
|
return new KnownWordCacheManager({
|
||||||
client: {
|
client: {
|
||||||
@@ -394,6 +470,8 @@ export class AnkiIntegration {
|
|||||||
getTimingTracker: () => this.timingTracker,
|
getTimingTracker: () => this.timingTracker,
|
||||||
getMpvClient: () => this.mpvClient,
|
getMpvClient: () => this.mpvClient,
|
||||||
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
||||||
|
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||||
|
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
|
||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
client: {
|
client: {
|
||||||
addNote: (deck, modelName, fields, tags) =>
|
addNote: (deck, modelName, fields, tags) =>
|
||||||
@@ -557,6 +635,7 @@ export class AnkiIntegration {
|
|||||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||||
|
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
|
||||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||||
@@ -850,6 +929,27 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
|
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
|
||||||
const mpvClient = this.mpvClient;
|
const mpvClient = this.mpvClient;
|
||||||
if (!mpvClient || !mpvClient.currentVideoPath) {
|
if (!mpvClient || !mpvClient.currentVideoPath) {
|
||||||
@@ -871,7 +971,7 @@ export class AnkiIntegration {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
this.config.media?.audioPadding,
|
this.config.media?.audioPadding,
|
||||||
this.mpvClient.currentAudioStreamIndex,
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,17 +1021,30 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
|
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) {
|
if (!this.config.metadata?.pattern) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
const videoFilename = extractFilenameFromMediaPath(mediaPath);
|
||||||
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
|
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
|
||||||
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
|
|
||||||
const filenameWithExt =
|
const filenameWithExt =
|
||||||
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
|
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
|
||||||
? mediaTitle || videoFilename
|
? resolvedMediaTitle || videoFilename
|
||||||
: videoFilename || mediaTitle) || fallbackFilename;
|
: videoFilename || resolvedMediaTitle) || fallbackFilename;
|
||||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
const currentTimePos =
|
const currentTimePos =
|
||||||
|
|||||||
@@ -401,6 +401,259 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
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 () => {
|
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
|
||||||
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||||
const duplicateLookupExpressions: string[] = [];
|
const duplicateLookupExpressions: string[] = [];
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { MpvClient } from '../types/runtime';
|
|||||||
import { resolveSentenceBackText } from './ai';
|
import { resolveSentenceBackText } from './ai';
|
||||||
import {
|
import {
|
||||||
resolveMediaGenerationInput,
|
resolveMediaGenerationInput,
|
||||||
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
|
|
||||||
@@ -79,6 +81,8 @@ interface CardCreationDeps {
|
|||||||
getTimingTracker: () => SubtitleTimingTracker;
|
getTimingTracker: () => SubtitleTimingTracker;
|
||||||
getMpvClient: () => MpvClient;
|
getMpvClient: () => MpvClient;
|
||||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
|
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
|
||||||
getDeck?: () => string | undefined;
|
getDeck?: () => string | undefined;
|
||||||
client: CardCreationClient;
|
client: CardCreationClient;
|
||||||
mediaGenerator: CardCreationMediaGenerator;
|
mediaGenerator: CardCreationMediaGenerator;
|
||||||
@@ -127,10 +131,15 @@ export class CardCreationService {
|
|||||||
constructor(private readonly deps: CardCreationDeps) {}
|
constructor(private readonly deps: CardCreationDeps) {}
|
||||||
|
|
||||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||||
const options: MediaGenerationInputResolverOptions = {};
|
const options: MediaGenerationInputResolverOptions = {
|
||||||
|
logDebug: (message) => log.debug(message),
|
||||||
|
};
|
||||||
if (this.deps.getCachedMediaPath) {
|
if (this.deps.getCachedMediaPath) {
|
||||||
options.getCachedMediaPath = this.deps.getCachedMediaPath;
|
options.getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||||
}
|
}
|
||||||
|
if (this.deps.shouldRequireRemoteMediaCache?.()) {
|
||||||
|
options.remoteCacheMode = 'required';
|
||||||
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +556,11 @@ export class CardCreationService {
|
|||||||
'audio',
|
'audio',
|
||||||
mediaResolverOptions,
|
mediaResolverOptions,
|
||||||
);
|
);
|
||||||
if (!videoPath) {
|
const shouldQueuePendingYoutubeMedia =
|
||||||
|
!videoPath &&
|
||||||
|
this.deps.shouldRequireRemoteMediaCache?.() === true &&
|
||||||
|
typeof this.deps.queuePendingYoutubeMediaUpdate === 'function';
|
||||||
|
if (!videoPath && !shouldQueuePendingYoutubeMedia) {
|
||||||
this.deps.showOsdNotification('No video loaded');
|
this.deps.showOsdNotification('No video loaded');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -677,6 +690,28 @@ export class CardCreationService {
|
|||||||
errors.push('card type fields');
|
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: this.deps.getConfig().fields?.image,
|
||||||
|
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||||
|
generateAudio: this.deps.getConfig().media?.generateAudio !== false,
|
||||||
|
generateImage: this.deps.getConfig().media?.generateImage !== false,
|
||||||
|
});
|
||||||
|
await this.deps.showNotification(noteId, label, 'media queued');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const mediaFields: Record<string, string> = {};
|
const mediaFields: Record<string, string> = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -727,7 +762,6 @@ export class CardCreationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
|
||||||
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
|
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
|
||||||
await this.deps.showNotification(noteId, label, errorSuffix);
|
await this.deps.showNotification(noteId, label, errorSuffix);
|
||||||
return true;
|
return true;
|
||||||
@@ -777,7 +811,10 @@ export class CardCreationService {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
this.deps.getConfig().media?.audioPadding,
|
this.deps.getConfig().media?.audioPadding,
|
||||||
|
resolveAudioStreamIndexForMediaGeneration(
|
||||||
|
videoPath,
|
||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type StructuredMediaResolver = (
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
remoteCacheMode?: 'optional' | 'required';
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
},
|
},
|
||||||
) => Promise<StructuredMediaInput | null>;
|
) => Promise<StructuredMediaInput | null>;
|
||||||
|
|
||||||
@@ -186,3 +188,111 @@ test('resolveMediaGenerationInput prefers a ready cached media file for YouTube
|
|||||||
assert.equal(result?.singleResolvedStream, false);
|
assert.equal(result?.singleResolvedStream, false);
|
||||||
assert.equal(result?.inputOptions, undefined);
|
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,5 +1,5 @@
|
|||||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||||
import type { MediaInputOptions } from '../media-input';
|
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||||
import type { MpvClient } from '../types/runtime';
|
import type { MpvClient } from '../types/runtime';
|
||||||
|
|
||||||
export type MediaGenerationKind = 'audio' | 'video';
|
export type MediaGenerationKind = 'audio' | 'video';
|
||||||
@@ -22,6 +22,18 @@ export interface MediaGenerationInputResolverOptions {
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: MediaGenerationKind,
|
kind: MediaGenerationKind,
|
||||||
) => Promise<string | null>;
|
) => 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 BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||||
@@ -119,6 +131,73 @@ function isGoogleVideoMediaPath(value: string): boolean {
|
|||||||
return Boolean(host && matchesHost(host, 'googlevideo.com'));
|
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 {
|
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
||||||
const lowerName = name.toLowerCase();
|
const lowerName = name.toLowerCase();
|
||||||
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||||
@@ -243,37 +322,54 @@ export async function resolveMediaGenerationInput(
|
|||||||
): Promise<ResolvedMediaGenerationInput | null> {
|
): Promise<ResolvedMediaGenerationInput | null> {
|
||||||
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||||
if (!currentVideoPath) {
|
if (!currentVideoPath) {
|
||||||
|
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRemoteMediaPath(currentVideoPath)) {
|
if (!isRemoteMediaPath(currentVideoPath)) {
|
||||||
return {
|
const result: ResolvedMediaGenerationInput = {
|
||||||
path: currentVideoPath,
|
path: currentVideoPath,
|
||||||
kind,
|
kind,
|
||||||
source: 'current-path',
|
source: 'current-path',
|
||||||
singleResolvedStream: false,
|
singleResolvedStream: false,
|
||||||
};
|
};
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedPath = options.getCachedMediaPath
|
let cachedPath: string | null = null;
|
||||||
? trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind))
|
if (options.getCachedMediaPath) {
|
||||||
: null;
|
try {
|
||||||
|
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
|
||||||
|
} catch {
|
||||||
|
cachedPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (cachedPath) {
|
if (cachedPath) {
|
||||||
return {
|
const result: ResolvedMediaGenerationInput = {
|
||||||
path: cachedPath,
|
path: cachedPath,
|
||||||
kind,
|
kind,
|
||||||
source: 'youtube-cache',
|
source: 'youtube-cache',
|
||||||
singleResolvedStream: false,
|
singleResolvedStream: false,
|
||||||
};
|
};
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.remoteCacheMode === 'required') {
|
||||||
|
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mpvClient?.requestProperty) {
|
if (!mpvClient?.requestProperty) {
|
||||||
return {
|
const result: ResolvedMediaGenerationInput = {
|
||||||
path: currentVideoPath,
|
path: currentVideoPath,
|
||||||
kind,
|
kind,
|
||||||
source: 'current-path',
|
source: 'current-path',
|
||||||
singleResolvedStream: false,
|
singleResolvedStream: false,
|
||||||
};
|
};
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -283,30 +379,50 @@ export async function resolveMediaGenerationInput(
|
|||||||
if (streamOpenFilename?.startsWith('edl://')) {
|
if (streamOpenFilename?.startsWith('edl://')) {
|
||||||
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
||||||
if (preferredUrl) {
|
if (preferredUrl) {
|
||||||
return toResolvedMediaGenerationInput(mpvClient, preferredUrl, kind, 'edl-stream', true);
|
const result = await toResolvedMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
preferredUrl,
|
||||||
|
kind,
|
||||||
|
'edl-stream',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return toResolvedMediaGenerationInput(
|
const result = await toResolvedMediaGenerationInput(
|
||||||
mpvClient,
|
mpvClient,
|
||||||
streamOpenFilename,
|
streamOpenFilename,
|
||||||
kind,
|
kind,
|
||||||
'stream-open-filename',
|
'stream-open-filename',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
if (streamOpenFilename) {
|
if (streamOpenFilename) {
|
||||||
return toResolvedMediaGenerationInput(
|
const result = await toResolvedMediaGenerationInput(
|
||||||
mpvClient,
|
mpvClient,
|
||||||
streamOpenFilename,
|
streamOpenFilename,
|
||||||
kind,
|
kind,
|
||||||
'stream-open-filename',
|
'stream-open-filename',
|
||||||
isRemoteMediaPath(streamOpenFilename),
|
isRemoteMediaPath(streamOpenFilename),
|
||||||
);
|
);
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||||
}
|
}
|
||||||
|
|
||||||
return toResolvedMediaGenerationInput(mpvClient, currentVideoPath, kind, 'current-path', false);
|
const result = await toResolvedMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
currentVideoPath,
|
||||||
|
kind,
|
||||||
|
'current-path',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMediaGenerationInputPath(
|
export async function resolveMediaGenerationInputPath(
|
||||||
|
|||||||
@@ -409,3 +409,59 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
|||||||
assert.deepEqual(imageContext, sidebarContext);
|
assert.deepEqual(imageContext, sidebarContext);
|
||||||
assert.equal(miscInfoStartTime, 10);
|
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>;
|
) => Promise<Buffer | null>;
|
||||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||||
|
queuePendingYoutubeMediaUpdate?: (job: {
|
||||||
|
noteId: number;
|
||||||
|
noteInfo: NoteUpdateWorkflowNoteInfo;
|
||||||
|
context?: SubtitleMiningContext;
|
||||||
|
label: string | number;
|
||||||
|
}) => Promise<boolean>;
|
||||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||||
showOsdNotification: (message: string) => void;
|
showOsdNotification: (message: string) => void;
|
||||||
@@ -195,6 +201,7 @@ export class NoteUpdateWorkflow {
|
|||||||
sentenceField,
|
sentenceField,
|
||||||
config.fields?.sentence,
|
config.fields?.sentence,
|
||||||
);
|
);
|
||||||
|
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||||
|
|
||||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
@@ -227,7 +234,18 @@ export class NoteUpdateWorkflow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.media?.generateAudio) {
|
const mediaCacheQueued =
|
||||||
|
(config.media?.generateAudio || config.media?.generateImage) &&
|
||||||
|
this.deps.queuePendingYoutubeMediaUpdate
|
||||||
|
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||||
|
noteId,
|
||||||
|
noteInfo,
|
||||||
|
context: subtitleMiningContext ?? undefined,
|
||||||
|
label: noteLabel,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!mediaCacheQueued && config.media?.generateAudio) {
|
||||||
try {
|
try {
|
||||||
const audioFilename = this.deps.generateAudioFilename();
|
const audioFilename = this.deps.generateAudioFilename();
|
||||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
||||||
@@ -252,7 +270,7 @@ export class NoteUpdateWorkflow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.media?.generateImage) {
|
if (!mediaCacheQueued && config.media?.generateImage) {
|
||||||
try {
|
try {
|
||||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||||
const imageFilename = this.deps.generateImageFilename();
|
const imageFilename = this.deps.generateImageFilename();
|
||||||
@@ -287,7 +305,7 @@ export class NoteUpdateWorkflow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.fields?.miscInfo) {
|
if (!mediaCacheQueued && config.fields?.miscInfo) {
|
||||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||||
miscInfoFilename || '',
|
miscInfoFilename || '',
|
||||||
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||||
@@ -305,8 +323,8 @@ export class NoteUpdateWorkflow {
|
|||||||
if (updatePerformed) {
|
if (updatePerformed) {
|
||||||
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
||||||
await this.deps.addConfiguredTagsToNote(noteId);
|
await this.deps.addConfiguredTagsToNote(noteId);
|
||||||
this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId);
|
this.deps.logInfo('Updated card fields for:', noteLabel);
|
||||||
await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId);
|
await this.deps.showNotification(noteId, noteLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) {
|
if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) {
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PendingYoutubeMediaQueue {
|
||||||
|
private updates: PendingYoutubeMediaUpdate[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
|
||||||
|
|
||||||
|
enqueue(job: PendingYoutubeMediaUpdate): void {
|
||||||
|
if (!job.generateAudio && !job.generateImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updates.push(job);
|
||||||
|
this.deps.logInfo('Queued YouTube media update for note:', job.noteId);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedPath = await getCachedMediaPath(sourceUrl, 'video');
|
||||||
|
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: config.media?.generateAudio === true,
|
||||||
|
generateImage: config.media?.generateImage === true,
|
||||||
|
});
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal('deviceId' in config.jellyfin, false);
|
assert.equal('deviceId' in config.jellyfin, false);
|
||||||
assert.equal('clientVersion' in config.jellyfin, false);
|
assert.equal('clientVersion' in config.jellyfin, false);
|
||||||
assert.equal(config.youtube.mediaCache.mode, 'direct');
|
assert.equal(config.youtube.mediaCache.mode, 'direct');
|
||||||
|
assert.equal(config.youtube.mediaCache.maxHeight, 720);
|
||||||
assert.equal(config.ai.enabled, false);
|
assert.equal(config.ai.enabled, false);
|
||||||
assert.equal(config.ai.apiKeyCommand, '');
|
assert.equal(config.ai.apiKeyCommand, '');
|
||||||
assert.equal(config.texthooker.openBrowser, false);
|
assert.equal(config.texthooker.openBrowser, false);
|
||||||
@@ -1758,7 +1759,8 @@ test('parses YouTube media cache config and warns on invalid values', () => {
|
|||||||
`{
|
`{
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"mediaCache": {
|
"mediaCache": {
|
||||||
"mode": "background"
|
"mode": "background",
|
||||||
|
"maxHeight": 480
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -1767,6 +1769,7 @@ test('parses YouTube media cache config and warns on invalid values', () => {
|
|||||||
|
|
||||||
const validService = new ConfigService(validDir);
|
const validService = new ConfigService(validDir);
|
||||||
assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background');
|
assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background');
|
||||||
|
assert.equal(validService.getConfig().youtube.mediaCache.maxHeight, 480);
|
||||||
|
|
||||||
const invalidDir = makeTempDir();
|
const invalidDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -1774,7 +1777,8 @@ test('parses YouTube media cache config and warns on invalid values', () => {
|
|||||||
`{
|
`{
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"mediaCache": {
|
"mediaCache": {
|
||||||
"mode": "always"
|
"mode": "always",
|
||||||
|
"maxHeight": -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -1786,9 +1790,16 @@ test('parses YouTube media cache config and warns on invalid values', () => {
|
|||||||
invalidService.getConfig().youtube.mediaCache.mode,
|
invalidService.getConfig().youtube.mediaCache.mode,
|
||||||
DEFAULT_CONFIG.youtube.mediaCache.mode,
|
DEFAULT_CONFIG.youtube.mediaCache.mode,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().youtube.mediaCache.maxHeight,
|
||||||
|
DEFAULT_CONFIG.youtube.mediaCache.maxHeight,
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.mode'),
|
invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.mode'),
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.maxHeight'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses controller settings with logical bindings and tuning knobs', () => {
|
test('parses controller settings with logical bindings and tuning knobs', () => {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
primarySubLanguages: ['ja', 'jpn'],
|
primarySubLanguages: ['ja', 'jpn'],
|
||||||
mediaCache: {
|
mediaCache: {
|
||||||
mode: 'direct',
|
mode: 'direct',
|
||||||
|
maxHeight: 720,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
subsync: {
|
subsync: {
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.youtube.mediaCache.mode,
|
defaultValue: defaultConfig.youtube.mediaCache.mode,
|
||||||
description: 'How YouTube card audio/images are extracted.',
|
description: 'How YouTube card audio/images are extracted.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'youtube.mediaCache.maxHeight',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.youtube.mediaCache.maxHeight,
|
||||||
|
description:
|
||||||
|
'Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'controller.enabled',
|
path: 'controller.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -310,6 +310,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
"Expected 'direct' or 'background'.",
|
"Expected 'direct' or 'background'.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxHeight = asNumber(src.youtube.mediaCache.maxHeight);
|
||||||
|
if (maxHeight !== undefined && Number.isInteger(maxHeight) && maxHeight >= 0) {
|
||||||
|
resolved.youtube.mediaCache.maxHeight = maxHeight;
|
||||||
|
} else if (src.youtube.mediaCache.maxHeight !== undefined) {
|
||||||
|
warn(
|
||||||
|
'youtube.mediaCache.maxHeight',
|
||||||
|
src.youtube.mediaCache.maxHeight,
|
||||||
|
resolved.youtube.mediaCache.maxHeight,
|
||||||
|
'Expected a whole number at least 0.',
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (src.youtube.mediaCache !== undefined) {
|
} else if (src.youtube.mediaCache !== undefined) {
|
||||||
warn(
|
warn(
|
||||||
'youtube.mediaCache',
|
'youtube.mediaCache',
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ test('settings registry exposes specialized controls for config-assisted inputs'
|
|||||||
|
|
||||||
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
||||||
const mediaCacheMode = field('youtube.mediaCache.mode');
|
const mediaCacheMode = field('youtube.mediaCache.mode');
|
||||||
|
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
||||||
|
|
||||||
assert.equal(mediaCacheMode.control, 'select');
|
assert.equal(mediaCacheMode.control, 'select');
|
||||||
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
|
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
|
||||||
@@ -174,6 +175,8 @@ test('settings registry exposes YouTube media cache mode as a labeled select', (
|
|||||||
direct: 'Direct stream extraction',
|
direct: 'Direct stream extraction',
|
||||||
background: 'Background media cache',
|
background: 'Background media cache',
|
||||||
});
|
});
|
||||||
|
assert.equal(mediaCacheMaxHeight.control, 'number');
|
||||||
|
assert.equal(mediaCacheMaxHeight.defaultValue, 720);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: 'audio' | 'video',
|
kind: 'audio' | 'video',
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
createFieldGroupingCallback: () => (
|
createFieldGroupingCallback: () => (
|
||||||
@@ -112,6 +113,7 @@ export function registerAnkiJimakuIpcRuntime(
|
|||||||
undefined,
|
undefined,
|
||||||
options.showOverlayNotification,
|
options.showOverlayNotification,
|
||||||
options.getCachedMediaPath,
|
options.getCachedMediaPath,
|
||||||
|
options.shouldRequireRemoteMediaCache,
|
||||||
);
|
);
|
||||||
integration.start();
|
integration.start();
|
||||||
options.setAnkiIntegration(integration);
|
options.setAnkiIntegration(integration);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type CreateAnkiIntegrationArgs = {
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: 'audio' | 'video',
|
kind: 'audio' | 'video',
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OverlayWindowTrackerOptions = {
|
export type OverlayWindowTrackerOptions = {
|
||||||
@@ -70,6 +71,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
|||||||
undefined,
|
undefined,
|
||||||
args.showOverlayNotification,
|
args.showOverlayNotification,
|
||||||
args.getCachedMediaPath,
|
args.getCachedMediaPath,
|
||||||
|
args.shouldRequireRemoteMediaCache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +143,7 @@ export function initializeOverlayRuntime(
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: 'audio' | 'video',
|
kind: 'audio' | 'video',
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
shouldStartAnkiIntegration?: () => boolean;
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
backendOverride: string | null;
|
backendOverride: string | null;
|
||||||
@@ -179,6 +182,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: 'audio' | 'video',
|
kind: 'audio' | 'video',
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
shouldStartAnkiIntegration?: () => boolean;
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
@@ -214,6 +218,9 @@ export function initializeOverlayAnkiIntegration(options: {
|
|||||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||||
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
|
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
|
||||||
|
...(options.shouldRequireRemoteMediaCache
|
||||||
|
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||||
integration.start();
|
integration.start();
|
||||||
|
|||||||
@@ -55,11 +55,19 @@ test('YouTube media cache exposes the downloaded file after the background job c
|
|||||||
const cacheRoot = makeTempCacheRoot();
|
const cacheRoot = makeTempCacheRoot();
|
||||||
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||||
const spawnCalls: SpawnCall[] = [];
|
const spawnCalls: SpawnCall[] = [];
|
||||||
|
const readyEvents: Array<{ url: string; path: string }> = [];
|
||||||
|
const startedEvents: Array<{ url: string }> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cache = createYoutubeMediaCacheService({
|
const cache = createYoutubeMediaCacheService({
|
||||||
cacheRoot,
|
cacheRoot,
|
||||||
getYtDlpCommand: () => 'yt-dlp',
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
onDownloadStarted: (event) => {
|
||||||
|
startedEvents.push(event);
|
||||||
|
},
|
||||||
|
onReady: (event) => {
|
||||||
|
readyEvents.push(event);
|
||||||
|
},
|
||||||
spawn: (command, args, options) => {
|
spawn: (command, args, options) => {
|
||||||
spawnCalls.push({ command, args, options });
|
spawnCalls.push({ command, args, options });
|
||||||
const proc = new FakeYtDlpProcess();
|
const proc = new FakeYtDlpProcess();
|
||||||
@@ -70,10 +78,15 @@ test('YouTube media cache exposes the downloaded file after the background job c
|
|||||||
|
|
||||||
cache.start('https://youtu.be/demo', { mode: 'background' });
|
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||||
|
|
||||||
|
assert.deepEqual(startedEvents, [{ url: 'https://youtu.be/demo' }]);
|
||||||
assert.equal(spawnCalls.length, 1);
|
assert.equal(spawnCalls.length, 1);
|
||||||
assert.equal(spawnCalls[0]?.command, 'yt-dlp');
|
assert.equal(spawnCalls[0]?.command, 'yt-dlp');
|
||||||
assert.ok(spawnCalls[0]?.args.includes('--no-playlist'));
|
assert.ok(spawnCalls[0]?.args.includes('--no-playlist'));
|
||||||
assert.ok(spawnCalls[0]?.args.includes('--merge-output-format'));
|
assert.ok(spawnCalls[0]?.args.includes('--merge-output-format'));
|
||||||
|
assert.equal(
|
||||||
|
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
|
||||||
|
'bestvideo*[height<=720]+bestaudio/best[height<=720]',
|
||||||
|
);
|
||||||
assert.deepEqual(spawnCalls[0]?.options?.stdio, ['ignore', 'ignore', 'ignore']);
|
assert.deepEqual(spawnCalls[0]?.options?.stdio, ['ignore', 'ignore', 'ignore']);
|
||||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||||
|
|
||||||
@@ -88,6 +101,59 @@ test('YouTube media cache exposes the downloaded file after the background job c
|
|||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
|
||||||
|
assert.deepEqual(readyEvents, [{ url: 'https://youtu.be/demo', path: outputPath }]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache can disable the download height cap', () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnCalls: SpawnCall[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args, options) => {
|
||||||
|
spawnCalls.push({ command, args, options });
|
||||||
|
return new FakeYtDlpProcess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 0 });
|
||||||
|
|
||||||
|
assert.equal(spawnCalls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
|
||||||
|
'bestvideo*+bestaudio/best',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache applies the configured download height cap', () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnCalls: SpawnCall[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args, options) => {
|
||||||
|
spawnCalls.push({ command, args, options });
|
||||||
|
return new FakeYtDlpProcess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
|
||||||
|
|
||||||
|
assert.equal(spawnCalls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
|
||||||
|
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,21 @@ interface MediaCacheSession {
|
|||||||
|
|
||||||
export interface YoutubeMediaCacheStartOptions {
|
export interface YoutubeMediaCacheStartOptions {
|
||||||
mode: YoutubeMediaCacheMode;
|
mode: YoutubeMediaCacheMode;
|
||||||
|
maxHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YoutubeMediaCacheServiceDeps {
|
export interface YoutubeMediaCacheServiceDeps {
|
||||||
cacheRoot?: string;
|
cacheRoot?: string;
|
||||||
getYtDlpCommand?: () => string;
|
getYtDlpCommand?: () => string;
|
||||||
spawn?: SpawnProcess;
|
spawn?: SpawnProcess;
|
||||||
|
onDownloadStarted?: (event: { url: string }) => void;
|
||||||
|
onReady?: (event: { url: string; path: string }) => void;
|
||||||
logInfo?: (message: string) => void;
|
logInfo?: (message: string) => void;
|
||||||
logWarn?: (message: string) => void;
|
logWarn?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']);
|
const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']);
|
||||||
|
const DEFAULT_MAX_HEIGHT = 720;
|
||||||
|
|
||||||
function cacheKeyForUrl(url: string): string {
|
function cacheKeyForUrl(url: string): string {
|
||||||
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
|
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
|
||||||
@@ -67,12 +71,25 @@ function findReadyMediaPath(dir: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createYtDlpArgs(url: string, outputTemplate: string): string[] {
|
function getFormatSelector(maxHeight: number): string {
|
||||||
|
return maxHeight > 0
|
||||||
|
? `bestvideo*[height<=${maxHeight}]+bestaudio/best[height<=${maxHeight}]`
|
||||||
|
: 'bestvideo*+bestaudio/best';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMaxHeight(maxHeight: number | undefined): number {
|
||||||
|
if (maxHeight === undefined) {
|
||||||
|
return DEFAULT_MAX_HEIGHT;
|
||||||
|
}
|
||||||
|
return Number.isInteger(maxHeight) && maxHeight >= 0 ? maxHeight : DEFAULT_MAX_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
|
||||||
return [
|
return [
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'-f',
|
'-f',
|
||||||
'bestvideo*+bestaudio/best',
|
getFormatSelector(normalizeMaxHeight(maxHeight)),
|
||||||
'--merge-output-format',
|
'--merge-output-format',
|
||||||
'mkv',
|
'mkv',
|
||||||
'-o',
|
'-o',
|
||||||
@@ -177,7 +194,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
|||||||
const dir = getSessionDir(url);
|
const dir = getSessionDir(url);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const outputTemplate = path.join(dir, 'media.%(ext)s');
|
const outputTemplate = path.join(dir, 'media.%(ext)s');
|
||||||
const args = createYtDlpArgs(url, outputTemplate);
|
const args = createYtDlpArgs(url, outputTemplate, options.maxHeight);
|
||||||
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||||
const session: MediaCacheSession = {
|
const session: MediaCacheSession = {
|
||||||
url,
|
url,
|
||||||
@@ -188,6 +205,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
|||||||
};
|
};
|
||||||
sessions.set(key, session);
|
sessions.set(key, session);
|
||||||
deps.logInfo?.(`Started YouTube media cache download for ${url}`);
|
deps.logInfo?.(`Started YouTube media cache download for ${url}`);
|
||||||
|
deps.onDownloadStarted?.({ url });
|
||||||
|
|
||||||
child.once('error', (error) => {
|
child.once('error', (error) => {
|
||||||
const currentSession = sessions.get(key);
|
const currentSession = sessions.get(key);
|
||||||
@@ -215,6 +233,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
|||||||
session.state = 'ready';
|
session.state = 'ready';
|
||||||
session.readyPath = readyPath;
|
session.readyPath = readyPath;
|
||||||
deps.logInfo?.(`YouTube media cache ready at ${readyPath}`);
|
deps.logInfo?.(`YouTube media cache ready at ${readyPath}`);
|
||||||
|
deps.onReady?.({ url, path: readyPath });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -1174,6 +1174,32 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
|||||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
});
|
});
|
||||||
const youtubeMediaCache = createYoutubeMediaCacheService({
|
const youtubeMediaCache = createYoutubeMediaCacheService({
|
||||||
|
onDownloadStarted: (event) => {
|
||||||
|
showConfiguredStatusNotification('YouTube media cache is downloading.', {
|
||||||
|
id: 'youtube-media-cache-status',
|
||||||
|
title: 'YouTube media cache',
|
||||||
|
variant: 'progress',
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
logger.info(`YouTube media cache download notification shown for ${event.url}`);
|
||||||
|
},
|
||||||
|
onReady: (event) => {
|
||||||
|
showConfiguredStatusNotification('YouTube media cache ready.', {
|
||||||
|
id: 'youtube-media-cache-status',
|
||||||
|
title: 'YouTube media cache',
|
||||||
|
variant: 'success',
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
void appState.ankiIntegration
|
||||||
|
?.handleYoutubeMediaCacheReady(event.url, event.path, { notifyNoQueued: false })
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to apply queued YouTube media updates: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
});
|
});
|
||||||
@@ -1324,6 +1350,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
startYoutubeMediaCache: (url) => {
|
startYoutubeMediaCache: (url) => {
|
||||||
youtubeMediaCache.start(url, {
|
youtubeMediaCache.start(url, {
|
||||||
mode: getResolvedConfig().youtube.mediaCache.mode,
|
mode: getResolvedConfig().youtube.mediaCache.mode,
|
||||||
|
maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
|
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
|
||||||
@@ -1645,6 +1672,12 @@ function isYoutubePlaybackActiveNow(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean {
|
||||||
|
return (
|
||||||
|
getResolvedConfig().youtube.mediaCache.mode === 'background' && isYoutubePlaybackActiveNow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getCachedYoutubeMediaPathForCurrentPlayback(
|
async function getCachedYoutubeMediaPathForCurrentPlayback(
|
||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
_kind: 'audio' | 'video',
|
_kind: 'audio' | 'video',
|
||||||
@@ -1655,6 +1688,8 @@ async function getCachedYoutubeMediaPathForCurrentPlayback(
|
|||||||
if (!isYoutubePlaybackActiveNow()) {
|
if (!isYoutubePlaybackActiveNow()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// mpv can expose the resolved stream URL here while the cache key uses the original page URL.
|
||||||
|
// Keep the active-cache fallback so current playback can still resolve the ready cached file.
|
||||||
return (
|
return (
|
||||||
(await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ??
|
(await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ??
|
||||||
(await youtubeMediaCache.getActiveCachedMediaPath())
|
(await youtubeMediaCache.getActiveCachedMediaPath())
|
||||||
@@ -2662,6 +2697,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({
|
|||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
flushQueuedOverlayNotifications,
|
flushQueuedOverlayNotifications,
|
||||||
|
flushQueuedMpvOsdNotifications,
|
||||||
openAnkiCardFromNotification,
|
openAnkiCardFromNotification,
|
||||||
toggleNotificationHistoryPanel,
|
toggleNotificationHistoryPanel,
|
||||||
showConfiguredPlaybackFeedback,
|
showConfiguredPlaybackFeedback,
|
||||||
@@ -4313,6 +4349,7 @@ const {
|
|||||||
},
|
},
|
||||||
onMpvConnected: () => {
|
onMpvConnected: () => {
|
||||||
maybeStartOverlayLoadingOsd();
|
maybeStartOverlayLoadingOsd();
|
||||||
|
flushQueuedMpvOsdNotifications();
|
||||||
if (appState.sessionBindingsInitialized) {
|
if (appState.sessionBindingsInitialized) {
|
||||||
sendMpvCommandRuntime(appState.mpvClient, [
|
sendMpvCommandRuntime(appState.mpvClient, [
|
||||||
'script-message',
|
'script-message',
|
||||||
@@ -5760,6 +5797,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||||
|
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
showOverlayNotification,
|
showOverlayNotification,
|
||||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
@@ -6238,6 +6276,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||||
|
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
|
||||||
shouldStartAnkiIntegration: () =>
|
shouldStartAnkiIntegration: () =>
|
||||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
|||||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
||||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||||
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
|
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
|
||||||
|
shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache'];
|
||||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||||
@@ -319,6 +320,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
|||||||
setAnkiIntegration: params.setAnkiIntegration,
|
setAnkiIntegration: params.setAnkiIntegration,
|
||||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||||
...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}),
|
...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}),
|
||||||
|
...(params.shouldRequireRemoteMediaCache
|
||||||
|
? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache }
|
||||||
|
: {}),
|
||||||
showDesktopNotification: params.showDesktopNotification,
|
showDesktopNotification: params.showDesktopNotification,
|
||||||
showOverlayNotification: params.showOverlayNotification,
|
showOverlayNotification: params.showOverlayNotification,
|
||||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||||
|
|||||||
@@ -514,6 +514,48 @@ test('configured overlay notifications require visible ready overlay window', ()
|
|||||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache lifecycle routes through configured status notifications', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const cacheBlock = source.match(
|
||||||
|
/const youtubeMediaCache = createYoutubeMediaCacheService\(\{(?<body>[\s\S]*?)\n\}\);\nconst waitForYoutubeMpvConnected/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const startCacheBlock = source.match(
|
||||||
|
/startYoutubeMediaCache:\s*\(url\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n runYoutubePlaybackFlow/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(cacheBlock);
|
||||||
|
assert.ok(startCacheBlock);
|
||||||
|
assert.match(
|
||||||
|
cacheBlock,
|
||||||
|
/onDownloadStarted:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache is downloading\.'/,
|
||||||
|
);
|
||||||
|
assert.match(cacheBlock, /id:\s*'youtube-media-cache-status'/);
|
||||||
|
assert.match(cacheBlock, /variant:\s*'progress'/);
|
||||||
|
assert.match(cacheBlock, /persistent:\s*true/);
|
||||||
|
assert.match(
|
||||||
|
cacheBlock,
|
||||||
|
/onReady:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache ready\.'/,
|
||||||
|
);
|
||||||
|
assert.match(cacheBlock, /variant:\s*'success'/);
|
||||||
|
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
|
||||||
|
assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/);
|
||||||
|
assert.match(
|
||||||
|
startCacheBlock,
|
||||||
|
/maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv connection flushes queued configured OSD notifications', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const connectedBlock = source.match(
|
||||||
|
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(connectedBlock);
|
||||||
|
assert.match(source, /flushQueuedMpvOsdNotifications/);
|
||||||
|
assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/);
|
||||||
|
});
|
||||||
|
|
||||||
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
|
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const setBlock = source.match(
|
const setBlock = source.match(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => {
|
test('notifyConfiguredStatus queues overlay for pre-overlay both status and preserves desktop', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
notifyConfiguredStatus('Overlay loading...', {
|
notifyConfiguredStatus('Overlay loading...', {
|
||||||
@@ -43,10 +43,10 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay both status',
|
|||||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => {
|
test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
notifyConfiguredStatus('Overlay loading...', {
|
notifyConfiguredStatus('Overlay loading...', {
|
||||||
@@ -61,7 +61,7 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only
|
|||||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
assert.deepEqual(calls, ['overlay::Overlay loading...']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
|
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
|
||||||
@@ -97,6 +97,37 @@ test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
|
|||||||
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus queues osd status when mpv osd is unavailable', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'YouTube media cache is downloading.',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
queueOsd: (message, options) => {
|
||||||
|
calls.push(`queue:${options.id ?? ''}:${message}`);
|
||||||
|
},
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'youtube-media-cache-status',
|
||||||
|
title: 'YouTube media cache',
|
||||||
|
variant: 'progress',
|
||||||
|
persistent: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'osd:YouTube media cache is downloading.',
|
||||||
|
'queue:youtube-media-cache-status:YouTube media cache is downloading.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => {
|
test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface ConfiguredStatusNotificationDeps {
|
|||||||
getNotificationType: () => NotificationType | undefined;
|
getNotificationType: () => NotificationType | undefined;
|
||||||
isOverlayReady?: () => boolean;
|
isOverlayReady?: () => boolean;
|
||||||
showOsd: (message: string) => boolean | void;
|
showOsd: (message: string) => boolean | void;
|
||||||
|
queueOsd?: (message: string, options: ConfiguredStatusNotificationOptions) => void;
|
||||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||||
}
|
}
|
||||||
@@ -50,8 +51,7 @@ export function notifyConfiguredStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
const overlayReady = deps.isOverlayReady?.() ?? true;
|
if (deps.showOverlayNotification) {
|
||||||
if (deps.showOverlayNotification && overlayReady) {
|
|
||||||
deps.showOverlayNotification({
|
deps.showOverlayNotification({
|
||||||
id: options.id,
|
id: options.id,
|
||||||
title: options.title ?? 'SubMiner',
|
title: options.title ?? 'SubMiner',
|
||||||
@@ -65,7 +65,10 @@ export function notifyConfiguredStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showOsd) {
|
if (showOsd) {
|
||||||
deps.showOsd(message);
|
const shown = deps.showOsd(message);
|
||||||
|
if (shown === false && delivery !== 'feedback') {
|
||||||
|
deps.queueOsd?.(message, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (desktopEnabled && shouldShowDesktop(type)) {
|
if (desktopEnabled && shouldShowDesktop(type)) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface OverlayNotificationsRuntimeDeps {
|
|||||||
getMainOverlayWindow: () => BrowserWindow | null;
|
getMainOverlayWindow: () => BrowserWindow | null;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => boolean | void;
|
||||||
getMpvClient: () => MpvIpcClient | null;
|
getMpvClient: () => MpvIpcClient | null;
|
||||||
getAnkiIntegration: () => AnkiIntegration | null;
|
getAnkiIntegration: () => AnkiIntegration | null;
|
||||||
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||||
@@ -42,6 +42,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
isVisibleOverlayContentReady: () => boolean;
|
isVisibleOverlayContentReady: () => boolean;
|
||||||
getConfiguredStatusNotificationType: () => NotificationType;
|
getConfiguredStatusNotificationType: () => NotificationType;
|
||||||
flushQueuedOverlayNotifications: () => void;
|
flushQueuedOverlayNotifications: () => void;
|
||||||
|
flushQueuedMpvOsdNotifications: () => void;
|
||||||
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||||
dismissOverlayNotification: (id: string) => void;
|
dismissOverlayNotification: (id: string) => void;
|
||||||
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
|
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
|
||||||
@@ -95,11 +96,38 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
});
|
});
|
||||||
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
||||||
null;
|
null;
|
||||||
|
const queuedConfiguredOsdNotifications = new Map<
|
||||||
|
string,
|
||||||
|
{ message: string; options: ConfiguredStatusNotificationOptions }
|
||||||
|
>();
|
||||||
|
|
||||||
function flushQueuedOverlayNotifications(): void {
|
function flushQueuedOverlayNotifications(): void {
|
||||||
overlayNotificationDelivery.flush();
|
overlayNotificationDelivery.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueConfiguredOsdNotification(
|
||||||
|
message: string,
|
||||||
|
options: ConfiguredStatusNotificationOptions,
|
||||||
|
): void {
|
||||||
|
const key = options.id ?? message;
|
||||||
|
queuedConfiguredOsdNotifications.set(key, { message, options });
|
||||||
|
while (queuedConfiguredOsdNotifications.size > 16) {
|
||||||
|
const oldestKey = queuedConfiguredOsdNotifications.keys().next().value;
|
||||||
|
if (typeof oldestKey !== 'string') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
queuedConfiguredOsdNotifications.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushQueuedMpvOsdNotifications(): void {
|
||||||
|
for (const [key, entry] of [...queuedConfiguredOsdNotifications.entries()]) {
|
||||||
|
if (deps.showMpvOsd(entry.message) !== false) {
|
||||||
|
queuedConfiguredOsdNotifications.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||||
overlayNotificationDelivery.send(payload);
|
overlayNotificationDelivery.send(payload);
|
||||||
}
|
}
|
||||||
@@ -145,6 +173,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
|
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||||
isOverlayReady: () => isVisibleOverlayContentReady(),
|
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||||
showOsd: (text) => deps.showMpvOsd(text),
|
showOsd: (text) => deps.showMpvOsd(text),
|
||||||
|
queueOsd: (text, queueOptions) => queueConfiguredOsdNotification(text, queueOptions),
|
||||||
showOverlayNotification,
|
showOverlayNotification,
|
||||||
showDesktopNotification: (title, notificationOptions) =>
|
showDesktopNotification: (title, notificationOptions) =>
|
||||||
showDesktopNotification(title, notificationOptions),
|
showDesktopNotification(title, notificationOptions),
|
||||||
@@ -238,6 +267,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
isVisibleOverlayContentReady,
|
isVisibleOverlayContentReady,
|
||||||
getConfiguredStatusNotificationType,
|
getConfiguredStatusNotificationType,
|
||||||
flushQueuedOverlayNotifications,
|
flushQueuedOverlayNotifications,
|
||||||
|
flushQueuedMpvOsdNotifications,
|
||||||
showOverlayNotification,
|
showOverlayNotification,
|
||||||
dismissOverlayNotification,
|
dismissOverlayNotification,
|
||||||
openAnkiCardFromNotification,
|
openAnkiCardFromNotification,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
|
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
|
||||||
|
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
bindOverlayOwner?: () => void;
|
bindOverlayOwner?: () => void;
|
||||||
releaseOverlayOwner?: () => void;
|
releaseOverlayOwner?: () => void;
|
||||||
@@ -79,6 +80,9 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||||
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||||
|
...(deps.shouldRequireRemoteMediaCache
|
||||||
|
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
|
||||||
|
: {}),
|
||||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||||
bindOverlayOwner: deps.bindOverlayOwner,
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type OverlayRuntimeOptions = {
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: 'audio' | 'video',
|
kind: 'audio' | 'video',
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
bindOverlayOwner?: () => void;
|
bindOverlayOwner?: () => void;
|
||||||
releaseOverlayOwner?: () => void;
|
releaseOverlayOwner?: () => void;
|
||||||
@@ -79,6 +80,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
currentVideoPath: string,
|
currentVideoPath: string,
|
||||||
kind: 'audio' | 'video',
|
kind: 'audio' | 'video',
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
shouldRequireRemoteMediaCache?: () => boolean;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
bindOverlayOwner?: () => void;
|
bindOverlayOwner?: () => void;
|
||||||
releaseOverlayOwner?: () => void;
|
releaseOverlayOwner?: () => void;
|
||||||
@@ -106,6 +108,9 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||||
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||||
|
...(deps.shouldRequireRemoteMediaCache
|
||||||
|
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
|
||||||
|
: {}),
|
||||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||||
bindOverlayOwner: deps.bindOverlayOwner,
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
|
|||||||
@@ -204,15 +204,77 @@ test('youtube playback runtime starts media cache without blocking the subtitle
|
|||||||
source: 'second-instance',
|
source: 'second-instance',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(
|
const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo');
|
||||||
calls.indexOf('prepare:https://youtu.be/demo') < calls.indexOf('cache:https://youtu.be/demo'),
|
const cacheIndex = calls.indexOf('cache:https://youtu.be/demo');
|
||||||
);
|
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo:download');
|
||||||
assert.ok(
|
assert.notEqual(prepareIndex, -1);
|
||||||
calls.indexOf('cache:https://youtu.be/demo') <
|
assert.notEqual(cacheIndex, -1);
|
||||||
calls.indexOf('run-flow:https://youtu.be/demo:download'),
|
assert.notEqual(runFlowIndex, -1);
|
||||||
);
|
assert.ok(prepareIndex < cacheIndex);
|
||||||
|
assert.ok(cacheIndex < runFlowIndex);
|
||||||
assert.equal(calls.includes('cache-done'), false);
|
assert.equal(calls.includes('cache-done'), false);
|
||||||
const resolveCacheNow = resolveCache;
|
const resolveCacheNow = resolveCache;
|
||||||
assert.ok(resolveCacheNow);
|
assert.ok(resolveCacheNow);
|
||||||
resolveCacheNow();
|
resolveCacheNow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube playback runtime logs synchronous media cache startup failures', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYoutubePlaybackRuntime({
|
||||||
|
platform: 'linux',
|
||||||
|
directPlaybackFormat: 'best',
|
||||||
|
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||||
|
autoLaunchTimeoutMs: 2_000,
|
||||||
|
connectTimeoutMs: 1_000,
|
||||||
|
getSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
getMpvConnected: () => true,
|
||||||
|
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||||
|
calls.push('invalidate-autoplay');
|
||||||
|
},
|
||||||
|
setAppOwnedFlowInFlight: (next) => {
|
||||||
|
calls.push(`app-owned:${next}`);
|
||||||
|
},
|
||||||
|
ensureYoutubePlaybackRuntimeReady: async () => {
|
||||||
|
calls.push('ensure-runtime-ready');
|
||||||
|
},
|
||||||
|
resolveYoutubePlaybackUrl: async () => {
|
||||||
|
throw new Error('linux path should not resolve direct playback url');
|
||||||
|
},
|
||||||
|
launchWindowsMpv: async () => ({ ok: false }),
|
||||||
|
waitForYoutubeMpvConnected: async () => true,
|
||||||
|
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||||
|
calls.push(`prepare:${url}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
startYoutubeMediaCache: () => {
|
||||||
|
calls.push('cache');
|
||||||
|
throw new Error('cache exploded');
|
||||||
|
},
|
||||||
|
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||||
|
calls.push(`run-flow:${url}:${mode}`);
|
||||||
|
},
|
||||||
|
logInfo: (message) => {
|
||||||
|
calls.push(`info:${message}`);
|
||||||
|
},
|
||||||
|
logWarn: (message) => {
|
||||||
|
calls.push(`warn:${message}`);
|
||||||
|
},
|
||||||
|
schedule: () => 1 as never,
|
||||||
|
clearScheduled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({
|
||||||
|
url: 'https://youtu.be/demo',
|
||||||
|
mode: 'download',
|
||||||
|
source: 'second-instance',
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.ok(calls.includes('run-flow:https://youtu.be/demo:download'));
|
||||||
|
assert.ok(
|
||||||
|
calls.some((entry) =>
|
||||||
|
entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||||
}
|
}
|
||||||
if (deps.startYoutubeMediaCache) {
|
if (deps.startYoutubeMediaCache) {
|
||||||
void Promise.resolve(deps.startYoutubeMediaCache(request.url)).catch((error) => {
|
void new Promise<void>((resolve) => {
|
||||||
|
resolve(deps.startYoutubeMediaCache?.(request.url));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
deps.logWarn(
|
deps.logWarn(
|
||||||
`Failed to start YouTube media cache: ${
|
`Failed to start YouTube media cache: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator
|
|||||||
|
|
||||||
async function withStubbedFfmpeg(
|
async function withStubbedFfmpeg(
|
||||||
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||||
|
options: {
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
|
now?: () => number;
|
||||||
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
@@ -44,7 +48,7 @@ async function withStubbedFfmpeg(
|
|||||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
|
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
|
||||||
const generator = new MediaGenerator(tempDir);
|
const generator = new MediaGenerator(tempDir, options);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await run(generator, argsPath);
|
await run(generator, argsPath);
|
||||||
@@ -243,3 +247,65 @@ test('generateAudio keeps explicit audio stream maps for normal media paths', as
|
|||||||
assert.equal(args[args.indexOf('-map') + 1], '0:2');
|
assert.equal(args[args.indexOf('-map') + 1], '0:2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generateAudio debug-logs cached input and completion timing', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const times = [1000, 1052];
|
||||||
|
|
||||||
|
await withStubbedFfmpeg(
|
||||||
|
async (generator) => {
|
||||||
|
await generator.generateAudio(
|
||||||
|
{
|
||||||
|
path: '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||||
|
source: 'youtube-cache',
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logDebug: (message) => logs.push(message),
|
||||||
|
now: () => times.shift() ?? 1052,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(logs.join('\n'), /\[media-generator\] audio start/);
|
||||||
|
assert.match(logs.join('\n'), /source=youtube-cache/);
|
||||||
|
assert.match(
|
||||||
|
logs.join('\n'),
|
||||||
|
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
|
||||||
|
);
|
||||||
|
assert.match(logs.join('\n'), /\[media-generator\] audio complete/);
|
||||||
|
assert.match(logs.join('\n'), /elapsedMs=52/);
|
||||||
|
assert.match(logs.join('\n'), /bytes=4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateAudio debug logs sanitize remote inputs', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const times = [1000, 1003];
|
||||||
|
|
||||||
|
await withStubbedFfmpeg(
|
||||||
|
async (generator) => {
|
||||||
|
await generator.generateAudio(
|
||||||
|
{
|
||||||
|
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?signature=secret&expire=123',
|
||||||
|
inputOptions: {
|
||||||
|
reconnect: true,
|
||||||
|
headers: {
|
||||||
|
Referer: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logDebug: (message) => logs.push(message),
|
||||||
|
now: () => times.shift() ?? 1003,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(logs.join('\n'), /input=remote:rr1---sn\.example\.googlevideo\.com/);
|
||||||
|
assert.doesNotMatch(logs.join('\n'), /signature=secret|expire=123|Referer|abc123/);
|
||||||
|
});
|
||||||
|
|||||||
+113
-1
@@ -72,12 +72,65 @@ export function buildAnimatedImageVideoFilter(options: {
|
|||||||
return vfParts.join(',');
|
return vfParts.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaGeneratorOptions {
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDebugToken(value: string, fallback: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const sanitized = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80);
|
||||||
|
return sanitized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMediaInputPathForDebugLog(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 describeMediaInputForDebugLog(input: MediaInput): string {
|
||||||
|
const pathValue = typeof input === 'string' ? input : input.path;
|
||||||
|
const sourceValue = typeof input === 'string' ? 'raw' : input.source;
|
||||||
|
const source = sanitizeDebugToken(sourceValue ?? 'raw', 'raw');
|
||||||
|
return `source=${source} input=${describeMediaInputPathForDebugLog(pathValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeFfmpegFailureForDebugLog(error: ExecFileException): string {
|
||||||
|
const code = typeof error.code === 'string' || typeof error.code === 'number' ? error.code : null;
|
||||||
|
const signal = typeof error.signal === 'string' ? error.signal : null;
|
||||||
|
if (code !== null) {
|
||||||
|
return `code=${code}`;
|
||||||
|
}
|
||||||
|
if (signal) {
|
||||||
|
return `signal=${signal}`;
|
||||||
|
}
|
||||||
|
return `name=${sanitizeDebugToken(error.name || 'Error', 'Error')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export class MediaGenerator {
|
export class MediaGenerator {
|
||||||
private tempDir: string;
|
private tempDir: string;
|
||||||
private notifyIconDir: string;
|
private notifyIconDir: string;
|
||||||
private av1EncoderPromise: Promise<string | null> | null = null;
|
private av1EncoderPromise: Promise<string | null> | null = null;
|
||||||
|
private readonly options: MediaGeneratorOptions;
|
||||||
|
|
||||||
constructor(tempDir?: string) {
|
constructor(tempDir?: string, options: MediaGeneratorOptions = {}) {
|
||||||
|
this.options = options;
|
||||||
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
||||||
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
||||||
this.ensureDirectory(this.tempDir);
|
this.ensureDirectory(this.tempDir);
|
||||||
@@ -86,6 +139,28 @@ export class MediaGenerator {
|
|||||||
this.cleanupOldNotificationIcons();
|
this.cleanupOldNotificationIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nowMs(): number {
|
||||||
|
try {
|
||||||
|
const value = this.options.now?.() ?? Date.now();
|
||||||
|
return Number.isFinite(value) ? value : Date.now();
|
||||||
|
} catch {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private elapsedMs(startedAt: number): number {
|
||||||
|
return Math.max(0, Math.round(this.nowMs() - startedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private logMediaDebug(message: string): void {
|
||||||
|
const logDebug = this.options.logDebug ?? ((line: string) => log.debug(line));
|
||||||
|
try {
|
||||||
|
logDebug(`[media-generator] ${message}`);
|
||||||
|
} catch {
|
||||||
|
// Debug logging should not affect media generation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ensureDirectory(dir: string): void {
|
private ensureDirectory(dir: string): void {
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -194,9 +269,11 @@ export class MediaGenerator {
|
|||||||
const start = Math.max(0, startTime - safePadding);
|
const start = Math.max(0, startTime - safePadding);
|
||||||
const duration = endTime - start + safePadding;
|
const duration = endTime - start + safePadding;
|
||||||
const mediaInput = normalizeMediaInput(videoPath);
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
|
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||||
|
const startedAt = this.nowMs();
|
||||||
const args: string[] = [
|
const args: string[] = [
|
||||||
'-ss',
|
'-ss',
|
||||||
start.toString(),
|
start.toString(),
|
||||||
@@ -218,8 +295,14 @@ export class MediaGenerator {
|
|||||||
|
|
||||||
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||||
|
|
||||||
|
this.logMediaDebug(
|
||||||
|
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
||||||
|
);
|
||||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
this.logMediaDebug(
|
||||||
|
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||||
|
);
|
||||||
reject(this.ffmpegError('audio generation', error));
|
reject(this.ffmpegError('audio generation', error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,6 +310,9 @@ export class MediaGenerator {
|
|||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(outputPath);
|
const data = fs.readFileSync(outputPath);
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
|
this.logMediaDebug(
|
||||||
|
`audio complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||||
|
);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -253,6 +339,7 @@ export class MediaGenerator {
|
|||||||
webp: 'webp',
|
webp: 'webp',
|
||||||
};
|
};
|
||||||
const mediaInput = normalizeMediaInput(videoPath);
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
|
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||||
|
|
||||||
const args: string[] = [
|
const args: string[] = [
|
||||||
'-ss',
|
'-ss',
|
||||||
@@ -292,10 +379,17 @@ export class MediaGenerator {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = this.createTempOutputPath('screenshot', ext);
|
const outputPath = this.createTempOutputPath('screenshot', ext);
|
||||||
|
const startedAt = this.nowMs();
|
||||||
args.push(outputPath);
|
args.push(outputPath);
|
||||||
|
|
||||||
|
this.logMediaDebug(
|
||||||
|
`screenshot start ${inputDescription} timestamp=${timestamp} format=${format} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'}`,
|
||||||
|
);
|
||||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
this.logMediaDebug(
|
||||||
|
`screenshot failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||||
|
);
|
||||||
reject(this.ffmpegError('screenshot generation', error));
|
reject(this.ffmpegError('screenshot generation', error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,6 +397,9 @@ export class MediaGenerator {
|
|||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(outputPath);
|
const data = fs.readFileSync(outputPath);
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
|
this.logMediaDebug(
|
||||||
|
`screenshot complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||||
|
);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -374,10 +471,15 @@ export class MediaGenerator {
|
|||||||
const start = Math.max(0, startTime - safePadding);
|
const start = Math.max(0, startTime - safePadding);
|
||||||
const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps);
|
const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps);
|
||||||
const totalLeadingStillDuration = Math.max(0, leadingStillDuration);
|
const totalLeadingStillDuration = Math.max(0, leadingStillDuration);
|
||||||
|
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||||
|
|
||||||
const clampedCrf = Math.max(0, Math.min(63, crf));
|
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||||
|
|
||||||
|
const encoderDetectionStartedAt = this.nowMs();
|
||||||
const av1Encoder = await this.detectAv1Encoder();
|
const av1Encoder = await this.detectAv1Encoder();
|
||||||
|
this.logMediaDebug(
|
||||||
|
`animated-image encoder ${inputDescription} elapsedMs=${this.elapsedMs(encoderDetectionStartedAt)} encoder=${av1Encoder ?? 'none'}`,
|
||||||
|
);
|
||||||
if (!av1Encoder) {
|
if (!av1Encoder) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).',
|
'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).',
|
||||||
@@ -387,6 +489,7 @@ export class MediaGenerator {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = this.createTempOutputPath('animation', 'avif');
|
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||||
const mediaInput = normalizeMediaInput(videoPath);
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
|
const startedAt = this.nowMs();
|
||||||
|
|
||||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||||
if (av1Encoder === 'libaom-av1') {
|
if (av1Encoder === 'libaom-av1') {
|
||||||
@@ -398,6 +501,9 @@ export class MediaGenerator {
|
|||||||
encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8');
|
encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logMediaDebug(
|
||||||
|
`animated-image start ${inputDescription} start=${start} duration=${duration} padding=${safePadding} fps=${clampedFps} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'} crf=${clampedCrf} encoder=${av1Encoder}`,
|
||||||
|
);
|
||||||
execFile(
|
execFile(
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
[
|
[
|
||||||
@@ -422,6 +528,9 @@ export class MediaGenerator {
|
|||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
this.logMediaDebug(
|
||||||
|
`animated-image failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||||
|
);
|
||||||
reject(this.ffmpegError('animation generation', error));
|
reject(this.ffmpegError('animation generation', error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -429,6 +538,9 @@ export class MediaGenerator {
|
|||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(outputPath);
|
const data = fs.readFileSync(outputPath);
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
|
this.logMediaDebug(
|
||||||
|
`animated-image complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||||
|
);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type MediaInput =
|
|||||||
| string
|
| string
|
||||||
| {
|
| {
|
||||||
path: string;
|
path: string;
|
||||||
|
source?: string;
|
||||||
inputOptions?: MediaInputOptions;
|
inputOptions?: MediaInputOptions;
|
||||||
singleResolvedStream?: boolean;
|
singleResolvedStream?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export interface ResolvedConfig {
|
|||||||
primarySubLanguages: string[];
|
primarySubLanguages: string[];
|
||||||
mediaCache: {
|
mediaCache: {
|
||||||
mode: YoutubeMediaCacheMode;
|
mode: YoutubeMediaCacheMode;
|
||||||
|
maxHeight: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
youtubeSubgen: YoutubeSubgenConfig & {
|
youtubeSubgen: YoutubeSubgenConfig & {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export type YoutubeMediaCacheMode = 'direct' | 'background';
|
|||||||
|
|
||||||
export interface YoutubeMediaCacheConfig {
|
export interface YoutubeMediaCacheConfig {
|
||||||
mode?: YoutubeMediaCacheMode;
|
mode?: YoutubeMediaCacheMode;
|
||||||
|
maxHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YoutubeConfig {
|
export interface YoutubeConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user