Compare commits

...

3 Commits

Author SHA1 Message Date
sudacode 9c503c5b14 test(youtube): add test for cache lookup failure fallback in media queue
- Handle getCachedMediaPath errors gracefully with logWarn + return false
- New test verifies cache failure triggers immediate generation fallback
2026-06-23 21:55:23 -07:00
sudacode 028636c76d 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
2026-06-23 20:45:28 -07:00
sudacode 236f22662c feat(youtube): add mediaCache mode and safer stream media extraction
- Add `youtube.mediaCache.mode` config option (`direct` | `background`)
- Resolve EDL stream URLs to single audio/video URLs before ffmpeg extraction
- Pass reconnect, user-agent, and safe headers to ffmpeg for remote streams
- Add background yt-dlp media cache with fallback to direct extraction
- Introduce `MediaInput` structured type replacing bare path strings
2026-06-20 02:25:32 -07:00
51 changed files with 3467 additions and 127 deletions
@@ -0,0 +1,5 @@
type: fixed
area: youtube
- 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; 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.
+5 -1
View File
@@ -618,7 +618,11 @@
"primarySubLanguages": [ "primarySubLanguages": [
"ja", "ja",
"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": {
"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.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
+10 -2
View File
@@ -1520,14 +1520,22 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
```json ```json
{ {
"youtube": { "youtube": {
"primarySubLanguages": ["ja", "jpn"] "primarySubLanguages": ["ja", "jpn"],
"mediaCache": {
"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.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. 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:
+5 -1
View File
@@ -618,7 +618,11 @@
"primarySubLanguages": [ "primarySubLanguages": [
"ja", "ja",
"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": {
"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.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
-68
View File
@@ -1,68 +0,0 @@
## Highlights
### Changed
- **Subtitle Delay Keybindings:** Overlay subtitle delay controls now match mpv's native bindings.
- `z`, `Z`, and `x` adjust subtitle delay; `Ctrl+Shift+Left/Right` step to the adjacent subtitle and show the current delay on the OSD.
- Removes the old SubMiner-specific adjacent-cue delay action in favor of mpv's built-in `sub-step`.
- **Update Notifications:** New installs now default to overlay-only update notifications instead of also sending a system notification.
### Fixed
- **Anki — Highlight Word:** The mined word is now correctly bolded in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence did not already contain bold markup.
- **Anki — Lapis/Kiku Word Cards:** Word cards enriched through SubMiner now correctly include the word-and-sentence marker, restoring sentence context on the card front.
- **Anki — Windows:** Fixed two issues that surfaced after background app launches on Windows.
- Audio clip and image generation now works correctly by recreating missing FFmpeg temp directories before export.
- Known-word cache refreshes no longer fail when no deck is configured.
- **Desktop Notifications:** Restored the SubMiner app icon on system notifications that do not supply their own image.
- **Dictionary — Windows:** The character dictionary auto-sync on `SubMiner mpv` shortcut launches can now fall back to mpv's current video path when app media state is not yet ready.
- **Overlay — macOS Yomitan Popup:** Fixed focus and dismiss behavior for the Yomitan popup on macOS.
- Popup focus is correctly restored after mining a card or reloading the popup.
- Clicking on transparent overlay space now properly closes the popup and passes the click through to mpv, with no hide/reappear cycle.
- **Overlay — Linux Startup:** Fixed several edge cases that could leave the overlay unresponsive or drop subtitles at startup when auto-pause was active.
- The overlay stays interactive during the initial render measurement gap.
- Subtitles paint as plain text immediately on cache misses, before tokenization finishes.
- Temporarily empty subtitle state is now re-parsed correctly before warm readiness resumes playback.
- **Overlay — Playlist Advance:** The visible overlay now stays interactive when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
- **Overlay — Windows:** Fixed shaky subtitle-bar hover and click behavior when a video connects to an already-running background SubMiner instance.
- **Stats — AniList Search:** Manual AniList linking from the stats anime page now searches only the anime title, dropping any generated "Season N" suffix that was causing failed lookups.
- **Updates — Linux:** Improved Linux update reliability for managed support assets.
- Updates now correctly install and refresh both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
- Support-asset refreshes no longer touch unrelated SubMiner data directories, and plugin copies are staged safely before replacing the live runtime plugin.
- Fresh installs now auto-install the managed runtime plugin and rofi theme from the bundled app on first launcher playback if either asset is missing.
### Docs
- **Linux Updates:** Documented how Linux update flows manage the launcher runtime plugin and rofi theme, and that the first launcher playback auto-installs any missing managed support assets from the bundled app.
## What's Changed
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+356
View File
@@ -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[] = [];
+148 -10
View File
@@ -61,7 +61,15 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
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 { resolveMediaGenerationInputPath } from './anki-integration/media-source'; import {
resolveAudioStreamIndexForMediaGeneration,
resolveMediaGenerationInput,
resolveMediaGenerationInputPath,
type MediaGenerationInputResolverOptions,
} from './anki-integration/media-source';
import type { PendingYoutubeMediaUpdate } from './anki-integration/pending-youtube-media';
import { PendingYoutubeMediaQueue } from './anki-integration/pending-youtube-media-queue';
import type { PendingYoutubeMediaQueueReadyOptions } from './anki-integration/pending-youtube-media-queue';
const log = createLogger('anki').child('integration'); const log = createLogger('anki').child('integration');
@@ -225,6 +233,10 @@ export class AnkiIntegration {
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>(); private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>(); private trackedDuplicateNoteIds = new Map<number, number[]>();
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
null;
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
constructor( constructor(
config: AnkiConnectConfig, config: AnkiConnectConfig,
@@ -240,6 +252,8 @@ export class AnkiIntegration {
aiConfig: AiConfig = {}, aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void, recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void, overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig }; this.aiConfig = { ...aiConfig };
@@ -252,6 +266,9 @@ export class AnkiIntegration {
this.overlayNotificationCallback = overlayNotificationCallback || null; this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? null; this.recordCardsMinedCallback = recordCardsMined ?? 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();
@@ -295,6 +312,81 @@ export class AnkiIntegration {
} }
} }
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message),
};
if (this.getCachedMediaPath) {
options.getCachedMediaPath = this.getCachedMediaPath;
}
if (this.shouldRequireRemoteMediaCache?.()) {
options.remoteCacheMode = 'required';
}
return options;
}
private createPendingYoutubeMediaQueue(): PendingYoutubeMediaQueue {
return new PendingYoutubeMediaQueue({
client: {
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
},
mediaGenerator: {
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
this.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
audioPadding,
audioStreamIndex,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) =>
this.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
audioPadding,
options,
),
},
getConfig: () => this.config,
getCurrentVideoPath: () => this.mpvClient.currentVideoPath,
getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
generateAudioFilename: () => this.generateAudioFilename(),
generateImageFilename: () => this.generateImageFilename(),
formatMiscInfoPatternForMediaPath: (
fallbackFilename,
startTimeSeconds,
mediaPath,
mediaTitle,
) =>
this.formatMiscInfoPatternForMediaPath(
fallbackFilename,
startTimeSeconds,
mediaPath,
mediaTitle,
),
showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label, errorSuffix) =>
this.showNotification(noteId, label, errorSuffix),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
});
}
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager { private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
return new KnownWordCacheManager({ return new KnownWordCacheManager({
client: { client: {
@@ -377,6 +469,9 @@ export class AnkiIntegration {
getAiConfig: () => this.aiConfig, getAiConfig: () => this.aiConfig,
getTimingTracker: () => this.timingTracker, getTimingTracker: () => this.timingTracker,
getMpvClient: () => this.mpvClient, getMpvClient: () => this.mpvClient,
...(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) =>
@@ -540,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),
@@ -833,13 +929,38 @@ 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) {
return null; return null;
} }
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); const videoPath = await resolveMediaGenerationInput(
mpvClient,
'audio',
this.getMediaResolverOptions(),
);
if (!videoPath) { if (!videoPath) {
return null; return null;
} }
@@ -850,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),
); );
} }
@@ -862,7 +983,11 @@ export class AnkiIntegration {
return null; return null;
} }
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video'); const videoPath = await resolveMediaGenerationInput(
this.mpvClient,
'video',
this.getMediaResolverOptions(),
);
if (!videoPath) { if (!videoPath) {
return null; return null;
} }
@@ -896,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 =
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig } from '../types/anki';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0]; type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
@@ -266,6 +267,8 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => { test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
const audioPaths: string[] = []; const audioPaths: string[] = [];
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
@@ -338,11 +341,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
}, },
mediaGenerator: { mediaGenerator: {
generateAudio: async (path) => { generateAudio: async (path) => {
audioPaths.push(path); audioPaths.push(recordMediaPath(path));
return Buffer.from('audio'); return Buffer.from('audio');
}, },
generateScreenshot: async (path) => { generateScreenshot: async (path) => {
imagePaths.push(path); imagePaths.push(recordMediaPath(path));
return Buffer.from('image'); return Buffer.from('image');
}, },
generateAnimatedImage: async () => null, generateAnimatedImage: async () => null,
+258 -2
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig } from '../types/anki';
test('CardCreationService counts locally created sentence cards', async () => { test('CardCreationService counts locally created sentence cards', async () => {
@@ -287,6 +288,8 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
test('CardCreationService uses stream-open-filename for remote media generation', async () => { test('CardCreationService uses stream-open-filename for remote media generation', async () => {
const audioPaths: string[] = []; const audioPaths: string[] = [];
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
@@ -345,11 +348,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
}, },
mediaGenerator: { mediaGenerator: {
generateAudio: async (path) => { generateAudio: async (path) => {
audioPaths.push(path); audioPaths.push(recordMediaPath(path));
return Buffer.from('audio'); return Buffer.from('audio');
}, },
generateScreenshot: async (path) => { generateScreenshot: async (path) => {
imagePaths.push(path); imagePaths.push(recordMediaPath(path));
return Buffer.from('image'); return Buffer.from('image');
}, },
generateAnimatedImage: async () => null, generateAnimatedImage: async () => null,
@@ -398,6 +401,259 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); 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[] = [];
+72 -12
View File
@@ -5,12 +5,18 @@ import {
} from '../anki-field-config'; } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki'; import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { AiConfig } from '../types/integrations'; import { AiConfig } from '../types/integrations';
import { MpvClient } from '../types/runtime'; import { MpvClient } from '../types/runtime';
import { resolveSentenceBackText } from './ai'; import { resolveSentenceBackText } from './ai';
import { resolveMediaGenerationInputPath } from './media-source'; import {
resolveMediaGenerationInput,
resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions,
} 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');
@@ -38,14 +44,14 @@ interface CardCreationClient {
interface CardCreationMediaGenerator { interface CardCreationMediaGenerator {
generateAudio( generateAudio(
path: string, path: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
): Promise<Buffer | null>; ): Promise<Buffer | null>;
generateScreenshot( generateScreenshot(
path: string, path: MediaInput,
timestamp: number, timestamp: number,
options: { options: {
format: 'jpg' | 'png' | 'webp'; format: 'jpg' | 'png' | 'webp';
@@ -55,7 +61,7 @@ interface CardCreationMediaGenerator {
}, },
): Promise<Buffer | null>; ): Promise<Buffer | null>;
generateAnimatedImage( generateAnimatedImage(
path: string, path: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
@@ -74,6 +80,9 @@ interface CardCreationDeps {
getAiConfig: () => AiConfig; getAiConfig: () => AiConfig;
getTimingTracker: () => SubtitleTimingTracker; getTimingTracker: () => SubtitleTimingTracker;
getMpvClient: () => MpvClient; getMpvClient: () => MpvClient;
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: () => boolean;
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
client: CardCreationClient; client: CardCreationClient;
mediaGenerator: CardCreationMediaGenerator; mediaGenerator: CardCreationMediaGenerator;
@@ -121,6 +130,19 @@ interface CardCreationDeps {
export class CardCreationService { export class CardCreationService {
constructor(private readonly deps: CardCreationDeps) {} constructor(private readonly deps: CardCreationDeps) {}
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message),
};
if (this.deps.getCachedMediaPath) {
options.getCachedMediaPath = this.deps.getCachedMediaPath;
}
if (this.deps.shouldRequireRemoteMediaCache?.()) {
options.remoteCacheMode = 'required';
}
return options;
}
private getConfiguredAnkiTags(): string[] { private getConfiguredAnkiTags(): string[] {
const tags = this.deps.getConfig().tags; const tags = this.deps.getConfig().tags;
if (!Array.isArray(tags)) { if (!Array.isArray(tags)) {
@@ -246,11 +268,12 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
); );
const mediaResolverOptions = this.getMediaResolverOptions();
const audioSourcePath = this.deps.getConfig().media?.generateAudio const audioSourcePath = this.deps.getConfig().media?.generateAudio
? await resolveMediaGenerationInputPath(mpvClient, 'audio') ? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
: null; : null;
const videoPath = this.deps.getConfig().media?.generateImage const videoPath = this.deps.getConfig().media?.generateImage
? await resolveMediaGenerationInputPath(mpvClient, 'video') ? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
: null; : null;
if (this.deps.getConfig().media?.generateAudio) { if (this.deps.getConfig().media?.generateAudio) {
@@ -522,9 +545,22 @@ export class CardCreationService {
try { try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => { return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video'); const mediaResolverOptions = this.getMediaResolverOptions();
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); const videoPath = await resolveMediaGenerationInput(
if (!videoPath) { mpvClient,
'video',
mediaResolverOptions,
);
const audioSourcePath = await resolveMediaGenerationInput(
mpvClient,
'audio',
mediaResolverOptions,
);
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;
} }
@@ -654,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 {
@@ -704,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;
@@ -740,7 +797,7 @@ export class CardCreationService {
} }
private async mediaGenerateAudio( private async mediaGenerateAudio(
videoPath: string, videoPath: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
@@ -754,12 +811,15 @@ 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,
),
); );
} }
private async generateImageBuffer( private async generateImageBuffer(
videoPath: string, videoPath: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
animatedLeadInSeconds = 0, animatedLeadInSeconds = 0,
+235 -1
View File
@@ -1,7 +1,33 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { resolveMediaGenerationInputPath } from './media-source'; import * as mediaSource from './media-source';
const { resolveMediaGenerationInputPath } = mediaSource;
type StructuredMediaInput = {
path: string;
source: string;
singleResolvedStream: boolean;
inputOptions?: {
reconnect?: boolean;
userAgent?: string;
headers?: Record<string, string>;
};
};
type StructuredMediaResolver = (
mpvClient: Parameters<typeof resolveMediaGenerationInputPath>[0],
kind?: Parameters<typeof resolveMediaGenerationInputPath>[1],
options?: {
getCachedMediaPath?: (
currentVideoPath: string,
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
) => Promise<string | null>;
remoteCacheMode?: 'optional' | 'required';
logDebug?: (message: string) => void;
},
) => Promise<StructuredMediaInput | null>;
test('resolveMediaGenerationInputPath keeps local file paths', async () => { test('resolveMediaGenerationInputPath keeps local file paths', async () => {
const result = await resolveMediaGenerationInputPath({ const result = await resolveMediaGenerationInputPath({
@@ -62,3 +88,211 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
assert.equal(result, 'https://www.youtube.com/watch?v=abc123'); assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
}); });
test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
].join(';');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async (name: string) => {
if (name === 'stream-open-filename') return edlSource;
if (name === 'user-agent') return 'Mozilla/5.0';
if (name === 'http-header-fields') {
return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok'];
}
return null;
},
},
'audio',
);
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.reconnect, true);
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
assert.deepEqual(result?.inputOptions?.headers, {
Referer: 'https://www.youtube.com/',
'X-Test': 'ok',
});
});
test('resolveMediaGenerationInput reads file-local mpv request options', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async (name: string) => {
if (name === 'stream-open-filename') {
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
}
if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent';
if (name === 'options/http-header-fields') return ['X-Shared: ok'];
if (name === 'file-local-options/http-header-fields') {
return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes'];
}
return null;
},
},
'video',
);
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent');
assert.deepEqual(result?.inputOptions?.headers, {
'X-Shared': 'ok',
Referer: 'https://m.youtube.com/',
'X-Local': 'yes',
Origin: 'https://www.youtube.com',
});
});
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
},
);
assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv');
assert.equal(result?.source, 'youtube-cache');
assert.equal(result?.singleResolvedStream, false);
assert.equal(result?.inputOptions, undefined);
});
test('resolveMediaGenerationInput debug-logs sanitized YouTube cache hits', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const logs: string[] = [];
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
logDebug: (message) => logs.push(message),
},
);
assert.equal(result?.source, 'youtube-cache');
assert.match(logs.join('\n'), /kind=video source=youtube-cache/);
assert.match(
logs.join('\n'),
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
);
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback/);
});
test('resolveMediaGenerationInput does not fall back to direct remote streams when cache is required', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => null,
remoteCacheMode: 'required',
},
);
assert.equal(result, null);
});
test('resolveMediaGenerationInput falls back when optional cache lookup fails', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => {
throw new Error('cache unavailable');
},
},
);
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
assert.equal(result?.source, 'stream-open-filename');
});
test('resolveMediaGenerationInput debug-logs sanitized required-cache misses', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const logs: string[] = [];
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => null,
remoteCacheMode: 'required',
logDebug: (message) => logs.push(message),
},
);
assert.equal(result, null);
assert.match(logs.join('\n'), /kind=video source=cache-miss/);
assert.match(logs.join('\n'), /mode=required/);
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback|googlevideo/);
});
+372
View File
@@ -1,7 +1,57 @@
import { isRemoteMediaPath } from '../jimaku/utils'; import { isRemoteMediaPath } from '../jimaku/utils';
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';
export type MediaGenerationInputSource =
| 'current-path'
| 'stream-open-filename'
| 'edl-stream'
| 'youtube-cache';
export interface ResolvedMediaGenerationInput {
path: string;
kind: MediaGenerationKind;
source: MediaGenerationInputSource;
singleResolvedStream: boolean;
inputOptions?: MediaInputOptions;
}
export interface MediaGenerationInputResolverOptions {
getCachedMediaPath?: (
currentVideoPath: string,
kind: MediaGenerationKind,
) => Promise<string | null>;
remoteCacheMode?: 'optional' | 'required';
logDebug?: (message: string) => void;
}
export function resolveAudioStreamIndexForMediaGeneration(
input: MediaInput,
audioStreamIndex: number | null | undefined,
): number | undefined {
if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') {
return undefined;
}
return audioStreamIndex ?? undefined;
}
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
'http-header-fields',
'options/http-header-fields',
'file-local-options/http-header-fields',
] as const;
const USER_AGENT_PROPERTY_NAMES = [
'file-local-options/user-agent',
'options/user-agent',
'user-agent',
] as const;
const REFERRER_PROPERTY_NAMES = [
'file-local-options/referrer',
'options/referrer',
'referrer',
] as const;
function trimToNonEmptyString(value: unknown): string | null { function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') { if (typeof value !== 'string') {
@@ -11,6 +61,17 @@ function trimToNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function normalizeHeaderName(value: string): string | null {
const trimmed = value.trim();
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
return null;
}
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
return null;
}
return trimmed;
}
function extractUrlsFromMpvEdlSource(source: string): string[] { function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms); const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
return [...matches] return [...matches]
@@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource(
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null); return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
} }
function getHostname(value: string): string | null {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return null;
}
}
function matchesHost(hostname: string, expectedHost: string): boolean {
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
}
function isGoogleVideoMediaPath(value: string): boolean {
const host = getHostname(value);
return Boolean(host && matchesHost(host, 'googlevideo.com'));
}
function describeMediaPathForDebugLog(value: string): string {
try {
const url = new URL(value);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
}
return `${url.protocol.replace(/:$/, '')}:`;
} catch {
// Not a URL; treat as a local file path below.
}
if (value.startsWith('edl://')) {
return 'edl:';
}
return `local:${value}`;
}
function logMediaResolutionDebug(
options: MediaGenerationInputResolverOptions,
message: string,
): void {
if (!options.logDebug) {
return;
}
try {
options.logDebug(`[media-source] ${message}`);
} catch {
// Debug logging should not affect media generation.
}
}
function logResolvedMediaGenerationInput(
options: MediaGenerationInputResolverOptions,
currentVideoPath: string,
result: ResolvedMediaGenerationInput,
): void {
logMediaResolutionDebug(
options,
[
`kind=${result.kind}`,
`source=${result.source}`,
`input=${describeMediaPathForDebugLog(result.path)}`,
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
`singleResolvedStream=${result.singleResolvedStream}`,
].join(' '),
);
}
function logMediaGenerationInputMiss(
options: MediaGenerationInputResolverOptions,
kind: MediaGenerationKind,
currentVideoPath: string,
reason: string,
): void {
logMediaResolutionDebug(
options,
[
`kind=${kind}`,
'source=cache-miss',
`reason=${reason}`,
`mode=${options.remoteCacheMode ?? 'optional'}`,
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
].join(' '),
);
}
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
const lowerName = name.toLowerCase();
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
headers[name] = value;
}
}
function parseMpvHeaderField(value: string): [string, string] | null {
const separatorIndex = value.indexOf(':');
if (separatorIndex <= 0) {
return null;
}
const name = normalizeHeaderName(value.slice(0, separatorIndex));
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
if (!name || !headerValue) {
return null;
}
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
}
function toHeaderFields(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string');
}
if (typeof value === 'string') {
return value
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
return [];
}
async function requestOptionalMpvProperty(
mpvClient: Pick<MpvClient, 'requestProperty'>,
name: string,
): Promise<unknown> {
if (!mpvClient.requestProperty) {
return null;
}
try {
return await mpvClient.requestProperty(name);
} catch {
return null;
}
}
async function requestFirstNonEmptyStringProperty(
mpvClient: Pick<MpvClient, 'requestProperty'>,
names: readonly string[],
): Promise<string | null> {
for (const name of names) {
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
if (value) {
return value;
}
}
return null;
}
async function resolveRemoteInputOptions(
mpvClient: Pick<MpvClient, 'requestProperty'>,
resolvedPath: string,
): Promise<MediaInputOptions | undefined> {
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
return undefined;
}
const headers: Record<string, string> = {};
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
const mpvHeaderFields = toHeaderFields(
await requestOptionalMpvProperty(mpvClient, propertyName),
);
for (const field of mpvHeaderFields) {
const parsed = parseMpvHeaderField(field);
if (parsed) {
headers[parsed[0]] = parsed[1];
}
}
}
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
if (referrer) {
setHeaderIfMissing(headers, 'Referer', referrer);
}
if (isGoogleVideoMediaPath(resolvedPath)) {
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
}
return {
reconnect: true,
...(userAgent ? { userAgent } : {}),
...(Object.keys(headers).length > 0 ? { headers } : {}),
};
}
async function toResolvedMediaGenerationInput(
mpvClient: Pick<MpvClient, 'requestProperty'>,
path: string,
kind: MediaGenerationKind,
source: MediaGenerationInputSource,
singleResolvedStream: boolean,
): Promise<ResolvedMediaGenerationInput> {
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
return {
path,
kind,
source,
singleResolvedStream,
...(inputOptions ? { inputOptions } : {}),
};
}
export async function resolveMediaGenerationInput(
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
kind: MediaGenerationKind = 'video',
options: MediaGenerationInputResolverOptions = {},
): Promise<ResolvedMediaGenerationInput | null> {
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
if (!currentVideoPath) {
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
return null;
}
if (!isRemoteMediaPath(currentVideoPath)) {
const result: ResolvedMediaGenerationInput = {
path: currentVideoPath,
kind,
source: 'current-path',
singleResolvedStream: false,
};
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
let cachedPath: string | null = null;
if (options.getCachedMediaPath) {
try {
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
} catch {
cachedPath = null;
}
}
if (cachedPath) {
const result: ResolvedMediaGenerationInput = {
path: cachedPath,
kind,
source: 'youtube-cache',
singleResolvedStream: false,
};
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
if (options.remoteCacheMode === 'required') {
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
return null;
}
if (!mpvClient?.requestProperty) {
const result: ResolvedMediaGenerationInput = {
path: currentVideoPath,
kind,
source: 'current-path',
singleResolvedStream: false,
};
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
try {
const streamOpenFilename = trimToNonEmptyString(
await mpvClient.requestProperty('stream-open-filename'),
);
if (streamOpenFilename?.startsWith('edl://')) {
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
if (preferredUrl) {
const result = await toResolvedMediaGenerationInput(
mpvClient,
preferredUrl,
kind,
'edl-stream',
true,
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
const result = await toResolvedMediaGenerationInput(
mpvClient,
streamOpenFilename,
kind,
'stream-open-filename',
false,
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
if (streamOpenFilename) {
const result = await toResolvedMediaGenerationInput(
mpvClient,
streamOpenFilename,
kind,
'stream-open-filename',
isRemoteMediaPath(streamOpenFilename),
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
} catch {
// Fall back to the current path when mpv does not expose a resolved stream URL.
}
const result = await toResolvedMediaGenerationInput(
mpvClient,
currentVideoPath,
kind,
'current-path',
false,
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
export async function resolveMediaGenerationInputPath( export async function resolveMediaGenerationInputPath(
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined, mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
kind: MediaGenerationKind = 'video', kind: MediaGenerationKind = 'video',
@@ -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' } }]);
});
+23 -5
View File
@@ -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,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnkiConnectConfig } from '../types/anki';
import { PendingYoutubeMediaQueue, type PendingYoutubeMediaQueueDeps } from './pending-youtube-media-queue';
function createDeps(
overrides: Partial<PendingYoutubeMediaQueueDeps> = {},
): PendingYoutubeMediaQueueDeps {
const warnings: unknown[][] = [];
const deps: PendingYoutubeMediaQueueDeps & { warnings: unknown[][] } = {
client: {
notesInfo: async () => [],
updateNoteFields: async () => {},
storeMediaFile: async () => {},
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
generateAnimatedImage: async () => Buffer.from('image'),
},
getConfig: () =>
({
media: { generateAudio: true, generateImage: true },
fields: {},
}) as AnkiConnectConfig,
getCurrentVideoPath: () => 'https://www.youtube.com/watch?v=abc123',
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0,
generateAudioFilename: () => 'audio.mp3',
generateImageFilename: () => 'image.webp',
formatMiscInfoPatternForMediaPath: () => '',
showStatusNotification: () => {},
showNotification: async () => {},
logInfo: () => {},
logWarn: (...args) => {
warnings.push(args);
},
logError: () => {},
warnings,
...overrides,
};
return deps;
}
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
const deps = createDeps({
getCachedMediaPath: async () => {
throw new Error('cache unavailable');
},
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
assert.equal(queued, false);
assert.deepEqual((deps as typeof deps & { warnings: unknown[][] }).warnings, [
[
'Failed to read YouTube cache state; falling back to immediate media generation:',
'cache unavailable',
],
]);
});
@@ -0,0 +1,341 @@
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;
}
let cachedPath: string | null = null;
try {
cachedPath = await getCachedMediaPath(sourceUrl, 'video');
} catch (error) {
this.deps.logWarn(
'Failed to read YouTube cache state; falling back to immediate media generation:',
error instanceof Error ? error.message : String(error),
);
return false;
}
if (cachedPath) {
return false;
}
const config = this.deps.getConfig();
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
this.enqueue({
sourceUrl,
noteId: job.noteId,
startTime: mediaRange.startTime,
endTime: mediaRange.endTime,
label: job.label,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName:
this.deps.resolveConfiguredFieldName(
job.noteInfo,
config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
) ?? undefined,
miscInfoFieldName:
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
generateAudio: 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);
}
+52
View File
@@ -80,6 +80,8 @@ test('loads defaults when config is missing', () => {
assert.equal('remoteControlDeviceName' in config.jellyfin, false); assert.equal('remoteControlDeviceName' in config.jellyfin, false);
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.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);
@@ -1750,6 +1752,56 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.youtubeSubgen.fixWithAi, true); assert.equal(config.youtubeSubgen.fixWithAi, true);
}); });
test('parses YouTube media cache config and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"youtube": {
"mediaCache": {
"mode": "background",
"maxHeight": 480
}
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background');
assert.equal(validService.getConfig().youtube.mediaCache.maxHeight, 480);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"youtube": {
"mediaCache": {
"mode": "always",
"maxHeight": -1
}
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().youtube.mediaCache.mode,
DEFAULT_CONFIG.youtube.mediaCache.mode,
);
assert.equal(
invalidService.getConfig().youtube.mediaCache.maxHeight,
DEFAULT_CONFIG.youtube.mediaCache.maxHeight,
);
assert.ok(
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', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
+4
View File
@@ -111,6 +111,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
}, },
youtube: { youtube: {
primarySubLanguages: ['ja', 'jpn'], primarySubLanguages: ['ja', 'jpn'],
mediaCache: {
mode: 'direct',
maxHeight: 720,
},
}, },
subsync: { subsync: {
alass_path: '', alass_path: '',
+18
View File
@@ -119,6 +119,24 @@ export function buildCoreConfigOptionRegistry(
description: description:
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.', 'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
}, },
{
path: 'youtube.mediaCache.mode',
kind: 'enum',
enumValues: ['direct', 'background'],
enumLabels: {
direct: 'Direct stream extraction',
background: 'Background media cache',
},
defaultValue: defaultConfig.youtube.mediaCache.mode,
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',
+1
View File
@@ -32,6 +32,7 @@ export interface ConfigOptionRegistryEntry {
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES. * `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
*/ */
enumValues?: readonly string[]; enumValues?: readonly string[];
enumLabels?: Record<string, string>;
/** /**
* Optional settings UI subset when legacy/runtime-valid enum options should remain * Optional settings UI subset when legacy/runtime-valid enum options should remain
* editable in config files but hidden from new UI choices, for example * editable in config files but hidden from new UI choices, for example
+33
View File
@@ -297,6 +297,39 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'Expected string array.', 'Expected string array.',
); );
} }
if (isObject(src.youtube.mediaCache)) {
const mode = src.youtube.mediaCache.mode;
if (mode === 'direct' || mode === 'background') {
resolved.youtube.mediaCache.mode = mode;
} else if (mode !== undefined) {
warn(
'youtube.mediaCache.mode',
mode,
resolved.youtube.mediaCache.mode,
"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) {
warn(
'youtube.mediaCache',
src.youtube.mediaCache,
resolved.youtube.mediaCache,
'Expected object.',
);
}
} }
if (isObject(src.subsync)) { if (isObject(src.subsync)) {
+14
View File
@@ -165,6 +165,20 @@ test('settings registry exposes specialized controls for config-assisted inputs'
assert.equal(field('discordPresence.presenceStyle').control, 'select'); assert.equal(field('discordPresence.presenceStyle').control, 'select');
}); });
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
const mediaCacheMode = field('youtube.mediaCache.mode');
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
assert.equal(mediaCacheMode.control, 'select');
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
assert.deepEqual(mediaCacheMode.enumLabels, {
direct: 'Direct stream extraction',
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', () => {
const primaryVisible = fields const primaryVisible = fields
.filter( .filter(
+1
View File
@@ -720,6 +720,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.settingsEnumValues || option?.enumValues ...(option?.settingsEnumValues || option?.enumValues
? { enumValues: option.settingsEnumValues ?? option.enumValues } ? { enumValues: option.settingsEnumValues ?? option.enumValues }
: {}), : {}),
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: restartBehaviorForPath(leaf.path), restartBehavior: restartBehaviorForPath(leaf.path),
advanced: advanced:
leaf.path.startsWith('controller.') || leaf.path.startsWith('controller.') ||
+7
View File
@@ -40,6 +40,11 @@ export interface AnkiJimakuIpcRuntimeOptions {
getAnkiIntegration: () => AnkiIntegration | null; getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void; setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => 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: () => (
@@ -107,6 +112,8 @@ export function registerAnkiJimakuIpcRuntime(
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
undefined, undefined,
options.showOverlayNotification, options.showOverlayNotification,
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
); );
integration.start(); integration.start();
options.setAnkiIntegration(integration); options.setAnkiIntegration(integration);
+21
View File
@@ -25,6 +25,11 @@ type CreateAnkiIntegrationArgs = {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
knownWordCacheStatePath: string; knownWordCacheStatePath: string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
}; };
export type OverlayWindowTrackerOptions = { export type OverlayWindowTrackerOptions = {
@@ -65,6 +70,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.aiConfig, args.aiConfig,
undefined, undefined,
args.showOverlayNotification, args.showOverlayNotification,
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
); );
} }
@@ -132,6 +139,11 @@ export function initializeOverlayRuntime(
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration?: () => boolean; shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null; backendOverride: string | null;
@@ -166,6 +178,11 @@ export function initializeOverlayAnkiIntegration(options: {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration?: () => boolean; shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): boolean { }): boolean {
@@ -200,6 +217,10 @@ export function initializeOverlayAnkiIntegration(options: {
showOverlayNotification: options.showOverlayNotification, showOverlayNotification: options.showOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback, createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(), knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
...(options.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
: {}),
}); });
if (options.shouldStartAnkiIntegration?.() !== false) { if (options.shouldStartAnkiIntegration?.() !== false) {
integration.start(); integration.start();
@@ -0,0 +1,268 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { createYoutubeMediaCacheService } from './media-cache';
class FakeYtDlpProcess extends EventEmitter {
killed = false;
stdout = new EventEmitter();
stderr = new EventEmitter();
kill(): boolean {
this.killed = true;
return true;
}
}
type SpawnCall = {
command: string;
args: string[];
options?: { stdio?: Array<'ignore' | 'pipe'> };
};
function makeTempCacheRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-media-cache-test-'));
}
test('YouTube media cache does nothing in direct mode', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
return new FakeYtDlpProcess();
},
});
cache.start('https://youtu.be/demo', { mode: 'direct' });
assert.deepEqual(spawnCalls, []);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache exposes the downloaded file after the background job completes', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
const readyEvents: Array<{ url: string; path: string }> = [];
const startedEvents: Array<{ url: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onDownloadStarted: (event) => {
startedEvents.push(event);
},
onReady: (event) => {
readyEvents.push(event);
},
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.deepEqual(startedEvents, [{ url: 'https://youtu.be/demo' }]);
assert.equal(spawnCalls.length, 1);
assert.equal(spawnCalls[0]?.command, 'yt-dlp');
assert.ok(spawnCalls[0]?.args.includes('--no-playlist'));
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.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
const outputPath = path.join(outputDir, 'media.mkv');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
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 {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache restarts when a ready cached file was deleted externally', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
const outputPath = path.join(outputDir, 'media.mkv');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
fs.rmSync(outputPath);
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.equal(spawnCalls.length, 2);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache drops old sessions when a new background cache starts', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/first', { mode: 'background' });
const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof firstOutputTemplate, 'string');
const firstOutputDir = path.dirname(firstOutputTemplate!);
fs.mkdirSync(firstOutputDir, { recursive: true });
fs.writeFileSync(path.join(firstOutputDir, 'media.mkv'), 'cached media');
cache.start('https://youtu.be/second', { mode: 'background' });
assert.equal(spawnedProcesses[0]?.killed, true);
assert.equal(fs.existsSync(firstOutputDir), false);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/first'), null);
assert.equal(spawnCalls.length, 2);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache cleanup kills downloads and removes temp files', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'media.mkv'), 'cached media');
cache.cleanup();
assert.equal(spawnedProcesses[0]?.killed, true);
assert.equal(fs.existsSync(outputDir), false);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
+258
View File
@@ -0,0 +1,258 @@
import { spawn as spawnProcess } from 'node:child_process';
import * as crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
type SpawnedProcess = EventEmitter & {
killed?: boolean;
kill?: (signal?: NodeJS.Signals | number) => boolean;
};
type SpawnProcess = (
command: string,
args: string[],
options?: { stdio?: Array<'ignore' | 'pipe'> },
) => SpawnedProcess;
interface MediaCacheSession {
url: string;
dir: string;
process: SpawnedProcess | null;
readyPath: string | null;
state: MediaCacheSessionState;
}
export interface YoutubeMediaCacheStartOptions {
mode: YoutubeMediaCacheMode;
maxHeight?: number;
}
export interface YoutubeMediaCacheServiceDeps {
cacheRoot?: string;
getYtDlpCommand?: () => string;
spawn?: SpawnProcess;
onDownloadStarted?: (event: { url: string }) => void;
onReady?: (event: { url: string; path: string }) => void;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
}
const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']);
const DEFAULT_MAX_HEIGHT = 720;
function cacheKeyForUrl(url: string): string {
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
}
function isFinalMediaFile(fileName: string): boolean {
if (!fileName.startsWith('media.')) {
return false;
}
if (fileName.endsWith('.part') || fileName.endsWith('.ytdl') || fileName.endsWith('.tmp')) {
return false;
}
return MEDIA_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase());
}
function findReadyMediaPath(dir: string): string | null {
try {
const files = fs.readdirSync(dir);
const mediaFile = files.find(isFinalMediaFile);
return mediaFile ? path.join(dir, mediaFile) : null;
} catch {
return null;
}
}
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 [
'--no-playlist',
'--no-warnings',
'-f',
getFormatSelector(normalizeMaxHeight(maxHeight)),
'--merge-output-format',
'mkv',
'-o',
outputTemplate,
url,
];
}
export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDeps = {}) {
const cacheRoot = deps.cacheRoot ?? path.join(os.tmpdir(), 'subminer-youtube-media-cache');
const getYtDlpCommand = deps.getYtDlpCommand ?? getYoutubeYtDlpCommand;
const spawn: SpawnProcess =
deps.spawn ??
((command, args, options) =>
spawnProcess(command, args, options ?? {}) as unknown as SpawnedProcess);
const sessions = new Map<string, MediaCacheSession>();
let activeKey: string | null = null;
const getSessionDir = (url: string): string => path.join(cacheRoot, cacheKeyForUrl(url));
const removeSession = (key: string): void => {
const session = sessions.get(key);
if (!session) {
return;
}
if (session.state === 'running' && session.process?.kill && !session.process.killed) {
session.process.kill();
}
sessions.delete(key);
if (activeKey === key) {
activeKey = null;
}
try {
fs.rmSync(session.dir, { recursive: true, force: true });
} catch {
// Temp cache cleanup should not block shutdown or playback startup.
}
};
const removeInactiveSessions = (keyToKeep: string): void => {
for (const key of [...sessions.keys()]) {
if (key !== keyToKeep) {
removeSession(key);
}
}
};
const getCachedMediaPath = async (url: string): Promise<string | null> => {
const key = cacheKeyForUrl(url);
const session = sessions.get(key);
if (session?.readyPath && fs.existsSync(session.readyPath)) {
return session.readyPath;
}
const readyPath = findReadyMediaPath(session?.dir ?? getSessionDir(url));
if (readyPath) {
sessions.set(key, {
url,
dir: path.dirname(readyPath),
process: null,
readyPath,
state: 'ready',
});
return readyPath;
}
return null;
};
const getActiveCachedMediaPath = async (): Promise<string | null> => {
if (!activeKey) {
return null;
}
const session = sessions.get(activeKey);
return session ? getCachedMediaPath(session.url) : null;
};
const start = (url: string, options: YoutubeMediaCacheStartOptions): void => {
if (options.mode !== 'background') {
return;
}
const key = cacheKeyForUrl(url);
activeKey = key;
const existingSession = sessions.get(key);
if (existingSession?.state === 'running') {
removeInactiveSessions(key);
return;
}
if (existingSession) {
if (
existingSession.state === 'ready' &&
((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) ||
findReadyMediaPath(existingSession.dir))
) {
removeInactiveSessions(key);
return;
}
removeSession(key);
activeKey = key;
}
removeInactiveSessions(key);
const dir = getSessionDir(url);
fs.mkdirSync(dir, { recursive: true });
const outputTemplate = path.join(dir, 'media.%(ext)s');
const args = createYtDlpArgs(url, outputTemplate, options.maxHeight);
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
const session: MediaCacheSession = {
url,
dir,
process: child,
readyPath: null,
state: 'running',
};
sessions.set(key, session);
deps.logInfo?.(`Started YouTube media cache download for ${url}`);
deps.onDownloadStarted?.({ url });
child.once('error', (error) => {
const currentSession = sessions.get(key);
if (currentSession !== session) {
return;
}
session.state = 'failed';
session.process = null;
deps.logWarn?.(
`YouTube media cache download failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
child.once('close', (code) => {
const currentSession = sessions.get(key);
if (currentSession !== session) {
return;
}
session.process = null;
if (code === 0) {
const readyPath = findReadyMediaPath(dir);
if (readyPath) {
session.state = 'ready';
session.readyPath = readyPath;
deps.logInfo?.(`YouTube media cache ready at ${readyPath}`);
deps.onReady?.({ url, path: readyPath });
return;
}
}
session.state = 'failed';
deps.logWarn?.(`YouTube media cache download exited without a usable media file.`);
});
};
const cleanup = (): void => {
for (const key of [...sessions.keys()]) {
removeSession(key);
}
activeKey = null;
};
return {
cleanup,
getActiveCachedMediaPath,
getCachedMediaPath,
start,
};
}
+70
View File
@@ -332,6 +332,7 @@ import {
acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks, acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate'; } from './core/services/youtube/generate';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { import {
@@ -1172,6 +1173,36 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
}, },
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
}); });
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),
logWarn: (message) => logger.warn(message),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({ const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
now: () => Date.now(), now: () => Date.now(),
@@ -1316,6 +1347,12 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
}, },
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
startYoutubeMediaCache: (url) => {
youtubeMediaCache.start(url, {
mode: getResolvedConfig().youtube.mediaCache.mode,
maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight,
});
},
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
@@ -1635,6 +1672,30 @@ function isYoutubePlaybackActiveNow(): boolean {
); );
} }
function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean {
return (
getResolvedConfig().youtube.mediaCache.mode === 'background' && isYoutubePlaybackActiveNow()
);
}
async function getCachedYoutubeMediaPathForCurrentPlayback(
currentVideoPath: string,
_kind: 'audio' | 'video',
): Promise<string | null> {
if (getResolvedConfig().youtube.mediaCache.mode !== 'background') {
return null;
}
if (!isYoutubePlaybackActiveNow()) {
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 (
(await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ??
(await youtubeMediaCache.getActiveCachedMediaPath())
);
}
function reportYoutubeSubtitleFailure(message: string): void { function reportYoutubeSubtitleFailure(message: string): void {
const type = getConfiguredStatusNotificationType(); const type = getConfiguredStatusNotificationType();
if (type === 'none') { if (type === 'none') {
@@ -2636,6 +2697,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({
}); });
const { const {
flushQueuedOverlayNotifications, flushQueuedOverlayNotifications,
flushQueuedMpvOsdNotifications,
openAnkiCardFromNotification, openAnkiCardFromNotification,
toggleNotificationHistoryPanel, toggleNotificationHistoryPanel,
showConfiguredPlaybackFeedback, showConfiguredPlaybackFeedback,
@@ -3801,6 +3863,7 @@ const {
}, },
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => { stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop(); void appState.discordPresenceService?.stop();
@@ -4286,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',
@@ -5731,6 +5795,9 @@ 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) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
showDesktopNotification, showDesktopNotification,
showOverlayNotification, showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
@@ -6207,6 +6274,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showOverlayNotification, showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
shouldStartAnkiIntegration: () => shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
}, },
+6
View File
@@ -126,6 +126,8 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration']; getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
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'];
@@ -317,6 +319,10 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
getAnkiIntegration: params.getAnkiIntegration, getAnkiIntegration: params.getAnkiIntegration,
setAnkiIntegration: params.setAnkiIntegration, setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
...(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,
+42
View File
@@ -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(
@@ -41,18 +41,20 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
cleanup(); cleanup();
assert.equal(calls.length, 32); assert.equal(calls.length, 33);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
}); });
@@ -92,6 +94,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
throw new Error('stop failed'); throw new Error('stop failed');
}, },
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
@@ -67,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupJellyfinSubtitleCache(); deps.cleanupJellyfinSubtitleCache();
} }
deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
}; };
} }
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -92,6 +93,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
@@ -147,6 +149,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -197,6 +200,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
@@ -142,6 +143,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
}); });
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}, },
@@ -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,
@@ -41,6 +41,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void; bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void; releaseOverlayOwner?: () => void;
@@ -77,6 +79,10 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
showOverlayNotification: deps.showOverlayNotification, showOverlayNotification: deps.showOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(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,
@@ -37,6 +37,11 @@ type OverlayRuntimeOptions = {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void; bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void; releaseOverlayOwner?: () => void;
@@ -71,6 +76,11 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void; bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void; releaseOverlayOwner?: () => void;
@@ -97,6 +107,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
showOverlayNotification: deps.showOverlayNotification, showOverlayNotification: deps.showOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback, createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
...(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,
@@ -146,3 +146,135 @@ test('youtube playback runtime resolves the socket path lazily for windows start
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock'))); assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
}); });
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
const calls: string[] = [];
let resolveCache: (() => void) | undefined;
const cachePromise = new Promise<void>((resolve) => {
resolveCache = resolve;
});
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: async (url) => {
calls.push(`cache:${url}`);
await cachePromise;
calls.push('cache-done');
},
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',
});
const prepareIndex = calls.indexOf('prepare: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.notEqual(prepareIndex, -1);
assert.notEqual(cacheIndex, -1);
assert.notEqual(runFlowIndex, -1);
assert.ok(prepareIndex < cacheIndex);
assert.ok(cacheIndex < runFlowIndex);
assert.equal(calls.includes('cache-done'), false);
const resolveCacheNow = resolveCache;
assert.ok(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'),
),
);
});
@@ -20,6 +20,7 @@ export type YoutubePlaybackRuntimeDeps = {
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>; launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>; waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>; prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: { runYoutubePlaybackFlow: (request: {
url: string; url: string;
mode: NonNullable<CliArgs['youtubeMode']>; mode: NonNullable<CliArgs['youtubeMode']>;
@@ -126,6 +127,18 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
if (!mediaReady) { if (!mediaReady) {
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) {
void new Promise<void>((resolve) => {
resolve(deps.startYoutubeMediaCache?.(request.url));
})
.catch((error) => {
deps.logWarn(
`Failed to start YouTube media cache: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}
await deps.runYoutubePlaybackFlow({ await deps.runYoutubePlaybackFlow({
url: request.url, url: request.url,
+130 -3
View File
@@ -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');
@@ -25,7 +29,7 @@ async function withStubbedFfmpeg(
" console.log(' V..... libaom-av1');", " console.log(' V..... libaom-av1');",
' process.exit(0);', ' process.exit(0);',
'}', '}',
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');", "fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');",
'const outputPath = args.at(-1);', 'const outputPath = args.at(-1);',
"fs.writeFileSync(outputPath, 'avif', 'utf8');", "fs.writeFileSync(outputPath, 'avif', 'utf8');",
].join('\n'), ].join('\n'),
@@ -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);
@@ -61,7 +65,7 @@ async function withStubbedFfmpeg(
} }
function readFfmpegArgs(argsPath: string): string[] { function readFfmpegArgs(argsPath: string): string[] {
return fs.readFileSync(argsPath, 'utf8').trim().split('\n'); return JSON.parse(fs.readFileSync(argsPath, 'utf8')) as string[];
} }
test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => { test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => {
@@ -182,3 +186,126 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as
assert.equal(fs.existsSync(path.dirname(outputPath!)), true); assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
}); });
}); });
test('generateAudio adds remote input options before the ffmpeg input', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio(
{
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
inputOptions: {
reconnect: true,
userAgent: 'Mozilla/5.0',
headers: {
Referer: 'https://www.youtube.com/',
Origin: 'https://www.youtube.com',
},
},
},
10,
12,
);
const args = readFfmpegArgs(argsPath);
const inputIndex = args.indexOf('-i');
assert.ok(inputIndex > 0);
assert.ok(args.indexOf('-reconnect') > -1);
assert.ok(args.indexOf('-reconnect') < inputIndex);
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
assert.equal(
args[args.indexOf('-headers') + 1],
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
);
});
});
test('generateAudio skips stale audio stream maps for single resolved streams', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio(
{
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
singleResolvedStream: true,
},
10,
12,
0,
22,
);
const args = readFfmpegArgs(argsPath);
assert.equal(args.includes('-map'), false);
});
});
test('generateAudio keeps explicit audio stream maps for normal media paths', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 10, 12, 0, 2);
const args = readFfmpegArgs(argsPath);
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/);
});
+143 -7
View File
@@ -21,9 +21,12 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { createLogger } from './logger'; import { createLogger } from './logger';
import { normalizeMediaInput, type MediaInput } from './media-input';
const log = createLogger('media'); const log = createLogger('media');
export type { MediaInput, MediaInputOptions } from './media-input';
function normalizeAnimatedImageFps(fps: number | undefined): number { function normalizeAnimatedImageFps(fps: number | undefined): number {
const fallbackFps = 10; const fallbackFps = 10;
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps; const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
@@ -69,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);
@@ -83,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 });
@@ -181,7 +259,7 @@ export class MediaGenerator {
} }
async generateAudio( async generateAudio(
videoPath: string, videoPath: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
padding: number = 0, padding: number = 0,
@@ -190,12 +268,24 @@ export class MediaGenerator {
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
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 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 args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath]; const startedAt = this.nowMs();
const args: string[] = [
'-ss',
start.toString(),
'-t',
duration.toString(),
...mediaInput.inputArgs,
'-i',
mediaInput.path,
];
if ( if (
!mediaInput.singleResolvedStream &&
typeof audioStreamIndex === 'number' && typeof audioStreamIndex === 'number' &&
Number.isInteger(audioStreamIndex) && Number.isInteger(audioStreamIndex) &&
audioStreamIndex >= 0 audioStreamIndex >= 0
@@ -205,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;
} }
@@ -214,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);
@@ -223,7 +322,7 @@ export class MediaGenerator {
} }
async generateScreenshot( async generateScreenshot(
videoPath: string, videoPath: MediaInput,
timestamp: number, timestamp: number,
options: { options: {
format: 'jpg' | 'png' | 'webp'; format: 'jpg' | 'png' | 'webp';
@@ -239,8 +338,18 @@ export class MediaGenerator {
png: 'png', png: 'png',
webp: 'webp', webp: 'webp',
}; };
const mediaInput = normalizeMediaInput(videoPath);
const inputDescription = describeMediaInputForDebugLog(videoPath);
const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1']; const args: string[] = [
'-ss',
timestamp.toString(),
...mediaInput.inputArgs,
'-i',
mediaInput.path,
'-vframes',
'1',
];
const vfParts: string[] = []; const vfParts: string[] = [];
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) { if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
@@ -270,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;
} }
@@ -281,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);
@@ -334,7 +453,7 @@ export class MediaGenerator {
} }
async generateAnimatedImage( async generateAnimatedImage(
videoPath: string, videoPath: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
padding: number = 0, padding: number = 0,
@@ -352,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).',
@@ -364,6 +488,8 @@ 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 startedAt = this.nowMs();
const encoderArgs: string[] = ['-c:v', av1Encoder]; const encoderArgs: string[] = ['-c:v', av1Encoder];
if (av1Encoder === 'libaom-av1') { if (av1Encoder === 'libaom-av1') {
@@ -375,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',
[ [
@@ -382,8 +511,9 @@ export class MediaGenerator {
start.toString(), start.toString(),
'-t', '-t',
duration.toString(), duration.toString(),
...mediaInput.inputArgs,
'-i', '-i',
videoPath, mediaInput.path,
'-vf', '-vf',
buildAnimatedImageVideoFilter({ buildAnimatedImageVideoFilter({
fps: clampedFps, fps: clampedFps,
@@ -398,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;
} }
@@ -405,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);
+86
View File
@@ -0,0 +1,86 @@
export interface MediaInputOptions {
reconnect?: boolean;
userAgent?: string;
headers?: Record<string, string>;
}
export type MediaInput =
| string
| {
path: string;
source?: string;
inputOptions?: MediaInputOptions;
singleResolvedStream?: boolean;
};
export type NormalizedMediaInput = {
path: string;
inputArgs: string[];
singleResolvedStream: boolean;
};
const BLOCKED_FFMPEG_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeHeaderName(value: string): string | null {
const trimmed = value.trim();
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
return null;
}
if (BLOCKED_FFMPEG_HEADER_NAMES.has(trimmed.toLowerCase())) {
return null;
}
return trimmed;
}
function serializeFfmpegHeaders(headers: Record<string, string> | undefined): string | null {
if (!headers) {
return null;
}
const lines: string[] = [];
for (const [rawName, rawValue] of Object.entries(headers)) {
const name = normalizeHeaderName(rawName);
const value = trimToNonEmptyString(rawValue);
if (!name || !value) {
continue;
}
lines.push(`${name}: ${value.replace(/[\r\n]+/g, ' ')}`);
}
return lines.length > 0 ? `${lines.join('\r\n')}\r\n` : null;
}
export function normalizeMediaInput(input: MediaInput): NormalizedMediaInput {
if (typeof input === 'string') {
return { path: input, inputArgs: [], singleResolvedStream: false };
}
const inputArgs: string[] = [];
if (input.inputOptions?.reconnect) {
inputArgs.push('-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5');
}
const userAgent = trimToNonEmptyString(input.inputOptions?.userAgent);
if (userAgent) {
inputArgs.push('-user_agent', userAgent);
}
const headers = serializeFfmpegHeaders(input.inputOptions?.headers);
if (headers) {
inputArgs.push('-headers', headers);
}
return {
path: input.path,
inputArgs,
singleResolvedStream: input.singleResolvedStream === true,
};
}
+29
View File
@@ -93,3 +93,32 @@ test('select controls show config-only current values without offering them othe
restoreDocument(); restoreDocument();
} }
}); });
test('select controls use configured option labels', () => {
const restoreDocument = installDocumentStub();
try {
const field = {
...createSelectField(),
enumValues: ['direct', 'background'],
enumLabels: {
direct: 'Direct stream extraction',
background: 'Background media cache',
},
} as ConfigSettingsField & { enumLabels: Record<string, string> };
const control = renderControl(field, {
valueForField: () => 'direct',
valueForPath: () => undefined,
updateDraft: () => {},
resetDraftPath: () => {},
setFieldError: () => {},
}) as HTMLSelectElement;
assert.deepEqual(
Array.from(control.options).map((option) => option.textContent),
['Direct stream extraction', 'Background media cache'],
);
} finally {
restoreDocument();
}
});
+1 -1
View File
@@ -227,7 +227,7 @@ export function renderControl(
for (const enumValue of enumValues) { for (const enumValue of enumValues) {
const option = createElement('option') as HTMLOptionElement; const option = createElement('option') as HTMLOptionElement;
option.value = enumValue; option.value = enumValue;
option.textContent = enumValue; option.textContent = field.enumLabels?.[enumValue] ?? enumValue;
option.selected = enumValue === value; option.selected = enumValue === value;
select.append(option); select.append(option);
} }
+5
View File
@@ -16,6 +16,7 @@ import type {
StatsConfig, StatsConfig,
YomitanConfig, YomitanConfig,
YoutubeConfig, YoutubeConfig,
YoutubeMediaCacheMode,
YoutubeSubgenConfig, YoutubeSubgenConfig,
} from './integrations'; } from './integrations';
import type { import type {
@@ -345,6 +346,10 @@ export interface ResolvedConfig {
}; };
youtube: YoutubeConfig & { youtube: YoutubeConfig & {
primarySubLanguages: string[]; primarySubLanguages: string[];
mediaCache: {
mode: YoutubeMediaCacheMode;
maxHeight: number;
};
}; };
youtubeSubgen: YoutubeSubgenConfig & { youtubeSubgen: YoutubeSubgenConfig & {
whisperBin: string; whisperBin: string;
+8
View File
@@ -122,8 +122,16 @@ export interface AiConfig {
requestTimeoutMs?: number; requestTimeoutMs?: number;
} }
export type YoutubeMediaCacheMode = 'direct' | 'background';
export interface YoutubeMediaCacheConfig {
mode?: YoutubeMediaCacheMode;
maxHeight?: number;
}
export interface YoutubeConfig { export interface YoutubeConfig {
primarySubLanguages?: string[]; primarySubLanguages?: string[];
mediaCache?: YoutubeMediaCacheConfig;
} }
export interface YoutubeSubgenConfig { export interface YoutubeSubgenConfig {
+1
View File
@@ -43,6 +43,7 @@ export interface ConfigSettingsField {
control: ConfigSettingsControl; control: ConfigSettingsControl;
defaultValue: unknown; defaultValue: unknown;
enumValues?: readonly string[]; enumValues?: readonly string[];
enumLabels?: Record<string, string>;
restartBehavior: ConfigSettingsRestartBehavior; restartBehavior: ConfigSettingsRestartBehavior;
advanced?: boolean; advanced?: boolean;
secret?: boolean; secret?: boolean;