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

This commit is contained in:
2026-06-27 19:20:18 -07:00
committed by GitHub
parent d199376364
commit 5326ad32f5
52 changed files with 4065 additions and 165 deletions
@@ -0,0 +1,7 @@
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 while honoring height changes between downloads.
- Cleaned stale YouTube background media cache files on startup and before new background downloads, and stopped in-flight background downloads when switching back to direct mode.
- Hardened YouTube media cache downloads with IPv4/extractor retry flags and made failed background downloads notify users and clear queued media updates instead of leaving them pending silently.
+5 -1
View File
@@ -618,7 +618,11 @@
"primarySubLanguages": [
"ja",
"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.
// ==========================================
+12 -2
View File
@@ -1520,14 +1520,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
```json
{
"youtube": {
"primarySubLanguages": ["ja", "jpn"]
"primarySubLanguages": ["ja", "jpn"],
"mediaCache": {
"mode": "direct",
"maxHeight": 720
}
}
}
```
| Option | Values | Description |
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| `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. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
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. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. 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. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
Current launcher behavior:
+5 -1
View File
@@ -618,7 +618,11 @@
"primarySubLanguages": [
"ja",
"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.
// ==========================================
+7
View File
@@ -88,6 +88,12 @@ SubMiner handles several YouTube subtitle formats transparently:
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
## Card Media Cache
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
## Configuration Reference
### Primary Subtitle Languages
@@ -131,6 +137,7 @@ Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
-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 { AnkiIntegration } from './anki-integration';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
import type { MediaInput } from './media-input';
import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = {
@@ -24,6 +25,13 @@ interface IntegrationTestContext {
stateDir: string;
}
function describeMediaInputForTest(input: MediaInput): string {
if (typeof input === 'string') {
return input;
}
return `${input.path}:${input.source ?? 'raw'}`;
}
function createIntegrationTestContext(
options: {
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)']);
});
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 () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
+160 -12
View File
@@ -61,7 +61,18 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
import {
resolveAudioStreamIndexForMediaGeneration,
resolveMediaGenerationInput,
resolveMediaGenerationInputPath,
type MediaGenerationInputResolverOptions,
} from './anki-integration/media-source';
import type { PendingYoutubeMediaUpdate } from './anki-integration/pending-youtube-media';
import { PendingYoutubeMediaQueue } from './anki-integration/pending-youtube-media-queue';
import type {
PendingYoutubeMediaQueueFailedOptions,
PendingYoutubeMediaQueueReadyOptions,
} from './anki-integration/pending-youtube-media-queue';
const log = createLogger('anki').child('integration');
@@ -225,6 +236,10 @@ export class AnkiIntegration {
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>();
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
null;
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
constructor(
config: AnkiConnectConfig,
@@ -240,6 +255,8 @@ export class AnkiIntegration {
aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
) {
this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
@@ -252,6 +269,9 @@ export class AnkiIntegration {
this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? null;
this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner();
this.cardCreationService = this.createCardCreationService();
@@ -295,6 +315,81 @@ export class AnkiIntegration {
}
}
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message),
};
if (this.getCachedMediaPath) {
options.getCachedMediaPath = this.getCachedMediaPath;
}
if (this.shouldRequireRemoteMediaCache?.()) {
options.remoteCacheMode = 'required';
}
return options;
}
private createPendingYoutubeMediaQueue(): PendingYoutubeMediaQueue {
return new PendingYoutubeMediaQueue({
client: {
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
},
mediaGenerator: {
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
this.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
audioPadding,
audioStreamIndex,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) =>
this.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
audioPadding,
options,
),
},
getConfig: () => this.config,
getCurrentVideoPath: () => this.mpvClient.currentVideoPath,
getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
generateAudioFilename: () => this.generateAudioFilename(),
generateImageFilename: () => this.generateImageFilename(),
formatMiscInfoPatternForMediaPath: (
fallbackFilename,
startTimeSeconds,
mediaPath,
mediaTitle,
) =>
this.formatMiscInfoPatternForMediaPath(
fallbackFilename,
startTimeSeconds,
mediaPath,
mediaTitle,
),
showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label, errorSuffix) =>
this.showNotification(noteId, label, errorSuffix),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
});
}
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
return new KnownWordCacheManager({
client: {
@@ -377,6 +472,9 @@ export class AnkiIntegration {
getAiConfig: () => this.aiConfig,
getTimingTracker: () => this.timingTracker,
getMpvClient: () => this.mpvClient,
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
getDeck: () => this.config.deck,
client: {
addNote: (deck, modelName, fields, tags) =>
@@ -540,6 +638,7 @@ export class AnkiIntegration {
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showStatusNotification(message),
@@ -833,13 +932,45 @@ export class AnkiIntegration {
};
}
private queuePendingYoutubeMediaUpdate(job: PendingYoutubeMediaUpdate): void {
this.pendingYoutubeMediaQueue.enqueue(job);
}
private async queuePendingYoutubeMediaUpdateForNote(job: {
noteId: number;
noteInfo: NoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}): Promise<boolean> {
return this.pendingYoutubeMediaQueue.queueFromNote(job);
}
async handleYoutubeMediaCacheReady(
sourceUrl: string,
cachedPath: string,
options?: PendingYoutubeMediaQueueReadyOptions,
): Promise<void> {
await this.pendingYoutubeMediaQueue.handleReady(sourceUrl, cachedPath, options);
}
async handleYoutubeMediaCacheFailed(
sourceUrl: string,
options?: PendingYoutubeMediaQueueFailedOptions,
): Promise<void> {
await this.pendingYoutubeMediaQueue.handleFailed(sourceUrl, options);
}
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
const mpvClient = this.mpvClient;
if (!mpvClient || !mpvClient.currentVideoPath) {
return null;
}
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
const videoPath = await resolveMediaGenerationInput(
mpvClient,
'audio',
this.getMediaResolverOptions(),
);
if (!videoPath) {
return null;
}
@@ -850,7 +981,7 @@ export class AnkiIntegration {
startTime,
endTime,
this.config.media?.audioPadding,
this.mpvClient.currentAudioStreamIndex,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
);
}
@@ -862,7 +993,11 @@ export class AnkiIntegration {
return null;
}
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
const videoPath = await resolveMediaGenerationInput(
this.mpvClient,
'video',
this.getMediaResolverOptions(),
);
if (!videoPath) {
return null;
}
@@ -896,17 +1031,30 @@ export class AnkiIntegration {
}
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
return this.formatMiscInfoPatternForMediaPath(
fallbackFilename,
startTimeSeconds,
this.mpvClient.currentVideoPath || '',
this.mpvClient.currentMediaTitle ?? undefined,
);
}
private formatMiscInfoPatternForMediaPath(
fallbackFilename: string,
startTimeSeconds: number | undefined,
mediaPath: string,
mediaTitle?: string,
): string {
if (!this.config.metadata?.pattern) {
return '';
}
const currentVideoPath = this.mpvClient.currentVideoPath || '';
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
const videoFilename = extractFilenameFromMediaPath(mediaPath);
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
? mediaTitle || videoFilename
: videoFilename || mediaTitle) || fallbackFilename;
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? resolvedMediaTitle || videoFilename
: videoFilename || resolvedMediaTitle) || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
@@ -1457,7 +1605,7 @@ export class AnkiIntegration {
miscInfoValue?: string;
} = {};
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
if (this.config.media?.generateAudio !== false && this.mpvClient?.currentVideoPath) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.generateAudio();
@@ -1477,7 +1625,7 @@ export class AnkiIntegration {
}
}
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
if (this.config.media?.generateImage !== false && this.mpvClient?.currentVideoPath) {
try {
const animatedLeadInSeconds = noteInfo
? await this.getAnimatedImageLeadInSeconds(noteInfo)
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
@@ -266,6 +267,8 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
const audioPaths: string[] = [];
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
@@ -338,11 +341,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
},
mediaGenerator: {
generateAudio: async (path) => {
audioPaths.push(path);
audioPaths.push(recordMediaPath(path));
return Buffer.from('audio');
},
generateScreenshot: async (path) => {
imagePaths.push(path);
imagePaths.push(recordMediaPath(path));
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
+258 -2
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
test('CardCreationService counts locally created sentence cards', async () => {
@@ -287,6 +288,8 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
const audioPaths: string[] = [];
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
@@ -345,11 +348,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
},
mediaGenerator: {
generateAudio: async (path) => {
audioPaths.push(path);
audioPaths.push(recordMediaPath(path));
return Buffer.from('audio');
},
generateScreenshot: async (path) => {
imagePaths.push(path);
imagePaths.push(recordMediaPath(path));
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
@@ -398,6 +401,259 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
});
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
},
media: {
generateAudio: true,
generateImage: false,
imageFormat: 'jpg',
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
currentSubText: '字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentTimePos: 11,
currentAudioStreamIndex: 0,
}) as never,
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
shouldRequireRemoteMediaCache: () => true,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Sentence: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
audioCalls.push({ path: typeof path === 'string' ? path : path.path, audioStreamIndex });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: (noteInfo, preferredName) => {
if (!preferredName) return null;
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
},
resolveNoteFieldName: (noteInfo, preferredName) => {
if (!preferredName) return null;
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
},
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
});
const created = await service.createSentenceCard('テスト', 10, 12);
assert.equal(created, true);
assert.deepEqual(audioCalls, [
{
path: '/tmp/subminer-youtube-media-cache/media.mkv',
audioStreamIndex: undefined,
},
]);
});
test('CardCreationService queues YouTube media when required cache is not ready', async () => {
const mediaCalls: string[] = [];
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const queuedUpdates: Array<{
sourceUrl: string;
noteId: number;
startTime: number;
endTime: number;
label: string | number;
audioFieldName?: string;
imageFieldName?: string;
miscInfoFieldName?: string;
generateAudio: boolean;
generateImage: boolean;
}> = [];
let streamRequests = 0;
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
media: {
generateAudio: true,
generateImage: true,
imageFormat: 'jpg',
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
currentSubText: '字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentTimePos: 11,
currentAudioStreamIndex: 2,
requestProperty: async () => {
streamRequests += 1;
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
},
}) as never,
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
queuePendingYoutubeMediaUpdate: (job) => {
queuedUpdates.push(job);
},
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Sentence: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
MiscInfo: { value: '' },
},
},
],
updateNoteFields: async (noteId, fields) => {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => {
mediaCalls.push('audio');
return Buffer.from('audio');
},
generateScreenshot: async () => {
mediaCalls.push('image');
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: (noteInfo, preferredName) => {
if (!preferredName) return null;
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
},
resolveNoteFieldName: (noteInfo, preferredName) => {
if (!preferredName) return null;
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
},
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
});
const created = await service.createSentenceCard('テスト', 10, 12);
assert.equal(created, true);
assert.equal(streamRequests, 0);
assert.deepEqual(mediaCalls, []);
assert.deepEqual(queuedUpdates, [
{
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
noteId: 42,
startTime: 10,
endTime: 12,
label: 'テスト',
audioFieldName: 'SentenceAudio',
imageFieldName: 'Picture',
miscInfoFieldName: 'MiscInfo',
generateAudio: true,
generateImage: true,
},
]);
assert.deepEqual(updates, []);
});
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
const duplicateLookupExpressions: string[] = [];
+102 -26
View File
@@ -5,15 +5,29 @@ import {
} from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { AiConfig } from '../types/integrations';
import { MpvClient } from '../types/runtime';
import { resolveSentenceBackText } from './ai';
import { resolveMediaGenerationInputPath } from './media-source';
import {
resolveMediaGenerationInput,
resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions,
} from './media-source';
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
const log = createLogger('anki').child('integration.card-creation');
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
return config.media?.generateAudio !== false;
}
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
return config.media?.generateImage !== false;
}
export interface CardCreationNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
@@ -38,14 +52,14 @@ interface CardCreationClient {
interface CardCreationMediaGenerator {
generateAudio(
path: string,
path: MediaInput,
startTime: number,
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
): Promise<Buffer | null>;
generateScreenshot(
path: string,
path: MediaInput,
timestamp: number,
options: {
format: 'jpg' | 'png' | 'webp';
@@ -55,7 +69,7 @@ interface CardCreationMediaGenerator {
},
): Promise<Buffer | null>;
generateAnimatedImage(
path: string,
path: MediaInput,
startTime: number,
endTime: number,
audioPadding?: number,
@@ -74,6 +88,9 @@ interface CardCreationDeps {
getAiConfig: () => AiConfig;
getTimingTracker: () => SubtitleTimingTracker;
getMpvClient: () => MpvClient;
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: () => boolean;
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
getDeck?: () => string | undefined;
client: CardCreationClient;
mediaGenerator: CardCreationMediaGenerator;
@@ -121,6 +138,19 @@ interface CardCreationDeps {
export class CardCreationService {
constructor(private readonly deps: CardCreationDeps) {}
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message),
};
if (this.deps.getCachedMediaPath) {
options.getCachedMediaPath = this.deps.getCachedMediaPath;
}
if (this.deps.shouldRequireRemoteMediaCache?.()) {
options.remoteCacheMode = 'required';
}
return options;
}
private getConfiguredAnkiTags(): string[] {
const tags = this.deps.getConfig().tags;
if (!Array.isArray(tags)) {
@@ -246,14 +276,18 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
);
const audioSourcePath = this.deps.getConfig().media?.generateAudio
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
const audioSourcePath = generateAudio
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
: null;
const videoPath = this.deps.getConfig().media?.generateImage
? await resolveMediaGenerationInputPath(mpvClient, 'video')
const videoPath = generateImage
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
: null;
if (this.deps.getConfig().media?.generateAudio) {
if (generateAudio) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = audioSourcePath
@@ -281,7 +315,7 @@ export class CardCreationService {
}
}
if (this.deps.getConfig().media?.generateImage) {
if (generateImage) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
@@ -441,7 +475,7 @@ export class CardCreationService {
errors.push('audio');
}
if (this.deps.getConfig().media?.generateImage) {
if (shouldGenerateImage(this.deps.getConfig())) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
@@ -522,9 +556,23 @@ export class CardCreationService {
try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
if (!videoPath) {
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
const videoPath = generateImage
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
: null;
const audioSourcePath = generateAudio
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
: null;
const missingRequestedMediaInput =
(generateImage && !videoPath) || (generateAudio && !audioSourcePath);
const shouldQueuePendingYoutubeMedia =
missingRequestedMediaInput &&
this.deps.shouldRequireRemoteMediaCache?.() === true &&
typeof this.deps.queuePendingYoutubeMediaUpdate === 'function';
if (missingRequestedMediaInput && !shouldQueuePendingYoutubeMedia) {
this.deps.showOsdNotification('No video loaded');
return false;
}
@@ -534,13 +582,13 @@ export class CardCreationService {
const sentenceField = sentenceCardConfig.sentenceField;
const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio';
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
const translationField = config.fields?.translation || 'SelectionText';
let resolvedMiscInfoField: string | null = null;
let resolvedSentenceAudioField: string = audioFieldName;
fields[sentenceField] = sentence;
const ankiAiConfig = this.deps.getConfig().ai;
const ankiAiConfig = config.ai;
const ankiAiEnabled =
typeof ankiAiConfig === 'object' && ankiAiConfig !== null
? ankiAiConfig.enabled === true
@@ -563,7 +611,7 @@ export class CardCreationService {
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = 'x';
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
fields[getConfiguredWordFieldName(config)] = sentence;
}
const pendingNoteInfo = this.createPendingNoteInfo(fields);
@@ -572,7 +620,7 @@ export class CardCreationService {
);
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
pendingNoteFields,
this.deps.getConfig(),
config,
).trim();
let duplicateNoteIds: number[] = [];
if (
@@ -590,7 +638,7 @@ export class CardCreationService {
}
}
const deck = this.deps.getConfig().deck || 'Default';
const deck = config.deck || 'Default';
let noteId: number;
try {
noteId = await this.deps.client.addNote(
@@ -636,7 +684,7 @@ export class CardCreationService {
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
createdNoteInfo,
this.deps.getConfig().fields?.miscInfo,
config.fields?.miscInfo,
);
const cardTypeFields: Record<string, string> = {};
@@ -654,8 +702,31 @@ export class CardCreationService {
errors.push('card type fields');
}
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
if (shouldQueuePendingYoutubeMedia) {
this.deps.queuePendingYoutubeMediaUpdate?.({
sourceUrl: mpvClient.currentVideoPath,
noteId,
startTime,
endTime,
label,
audioFieldName: resolvedSentenceAudioField,
imageFieldName: config.fields?.image,
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
generateAudio,
generateImage,
});
await this.deps.showNotification(noteId, label, 'media queued');
return true;
}
if (missingRequestedMediaInput) {
return false;
}
const mediaFields: Record<string, string> = {};
if (generateAudio) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = audioSourcePath
@@ -672,12 +743,14 @@ export class CardCreationService {
log.error('Failed to generate sentence audio:', (error as Error).message);
errors.push('audio');
}
}
if (generateImage) {
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
const imageField = this.deps.getConfig().fields?.image;
const imageField = config.fields?.image;
if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
mediaFields[imageField] = `<img src="${imageFilename}">`;
@@ -687,8 +760,9 @@ export class CardCreationService {
log.error('Failed to generate sentence image:', (error as Error).message);
errors.push('image');
}
}
if (this.deps.getConfig().fields?.miscInfo) {
if (config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
if (miscInfo && resolvedMiscInfoField) {
mediaFields[resolvedMiscInfoField] = miscInfo;
@@ -704,7 +778,6 @@ export class CardCreationService {
}
}
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix);
return true;
@@ -740,7 +813,7 @@ export class CardCreationService {
}
private async mediaGenerateAudio(
videoPath: string,
videoPath: MediaInput,
startTime: number,
endTime: number,
): Promise<Buffer | null> {
@@ -754,12 +827,15 @@ export class CardCreationService {
startTime,
endTime,
this.deps.getConfig().media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(
videoPath,
mpvClient.currentAudioStreamIndex ?? undefined,
),
);
}
private async generateImageBuffer(
videoPath: string,
videoPath: MediaInput,
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
+235 -1
View File
@@ -1,7 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveMediaGenerationInputPath } from './media-source';
import * as mediaSource from './media-source';
const { resolveMediaGenerationInputPath } = mediaSource;
type StructuredMediaInput = {
path: string;
source: string;
singleResolvedStream: boolean;
inputOptions?: {
reconnect?: boolean;
userAgent?: string;
headers?: Record<string, string>;
};
};
type StructuredMediaResolver = (
mpvClient: Parameters<typeof resolveMediaGenerationInputPath>[0],
kind?: Parameters<typeof resolveMediaGenerationInputPath>[1],
options?: {
getCachedMediaPath?: (
currentVideoPath: string,
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
) => Promise<string | null>;
remoteCacheMode?: 'optional' | 'required';
logDebug?: (message: string) => void;
},
) => Promise<StructuredMediaInput | null>;
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
const result = await resolveMediaGenerationInputPath({
@@ -62,3 +88,211 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
});
test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
].join(';');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async (name: string) => {
if (name === 'stream-open-filename') return edlSource;
if (name === 'user-agent') return 'Mozilla/5.0';
if (name === 'http-header-fields') {
return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok'];
}
return null;
},
},
'audio',
);
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.reconnect, true);
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
assert.deepEqual(result?.inputOptions?.headers, {
Referer: 'https://www.youtube.com/',
'X-Test': 'ok',
});
});
test('resolveMediaGenerationInput reads file-local mpv request options', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async (name: string) => {
if (name === 'stream-open-filename') {
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
}
if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent';
if (name === 'options/http-header-fields') return ['X-Shared: ok'];
if (name === 'file-local-options/http-header-fields') {
return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes'];
}
return null;
},
},
'video',
);
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent');
assert.deepEqual(result?.inputOptions?.headers, {
'X-Shared': 'ok',
Referer: 'https://m.youtube.com/',
'X-Local': 'yes',
Origin: 'https://www.youtube.com',
});
});
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
},
);
assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv');
assert.equal(result?.source, 'youtube-cache');
assert.equal(result?.singleResolvedStream, false);
assert.equal(result?.inputOptions, undefined);
});
test('resolveMediaGenerationInput debug-logs sanitized YouTube cache hits', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const logs: string[] = [];
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
logDebug: (message) => logs.push(message),
},
);
assert.equal(result?.source, 'youtube-cache');
assert.match(logs.join('\n'), /kind=video source=youtube-cache/);
assert.match(
logs.join('\n'),
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
);
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback/);
});
test('resolveMediaGenerationInput does not fall back to direct remote streams when cache is required', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => null,
remoteCacheMode: 'required',
},
);
assert.equal(result, null);
});
test('resolveMediaGenerationInput falls back when optional cache lookup fails', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => {
throw new Error('cache unavailable');
},
},
);
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
assert.equal(result?.source, 'stream-open-filename');
});
test('resolveMediaGenerationInput debug-logs sanitized required-cache misses', async () => {
const resolver = (
mediaSource as typeof mediaSource & {
resolveMediaGenerationInput?: StructuredMediaResolver;
}
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const logs: string[] = [];
const result = await resolver!(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
},
'video',
{
getCachedMediaPath: async () => null,
remoteCacheMode: 'required',
logDebug: (message) => logs.push(message),
},
);
assert.equal(result, null);
assert.match(logs.join('\n'), /kind=video source=cache-miss/);
assert.match(logs.join('\n'), /mode=required/);
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback|googlevideo/);
});
+372
View File
@@ -1,7 +1,57 @@
import { isRemoteMediaPath } from '../jimaku/utils';
import type { MediaInput, MediaInputOptions } from '../media-input';
import type { MpvClient } from '../types/runtime';
export type MediaGenerationKind = 'audio' | 'video';
export type MediaGenerationInputSource =
| 'current-path'
| 'stream-open-filename'
| 'edl-stream'
| 'youtube-cache';
export interface ResolvedMediaGenerationInput {
path: string;
kind: MediaGenerationKind;
source: MediaGenerationInputSource;
singleResolvedStream: boolean;
inputOptions?: MediaInputOptions;
}
export interface MediaGenerationInputResolverOptions {
getCachedMediaPath?: (
currentVideoPath: string,
kind: MediaGenerationKind,
) => Promise<string | null>;
remoteCacheMode?: 'optional' | 'required';
logDebug?: (message: string) => void;
}
export function resolveAudioStreamIndexForMediaGeneration(
input: MediaInput,
audioStreamIndex: number | null | undefined,
): number | undefined {
if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') {
return undefined;
}
return audioStreamIndex ?? undefined;
}
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
'http-header-fields',
'options/http-header-fields',
'file-local-options/http-header-fields',
] as const;
const USER_AGENT_PROPERTY_NAMES = [
'file-local-options/user-agent',
'options/user-agent',
'user-agent',
] as const;
const REFERRER_PROPERTY_NAMES = [
'file-local-options/referrer',
'options/referrer',
'referrer',
] as const;
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
@@ -11,6 +61,17 @@ function trimToNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function normalizeHeaderName(value: string): string | null {
const trimmed = value.trim();
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
return null;
}
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
return null;
}
return trimmed;
}
function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
return [...matches]
@@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource(
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
}
function getHostname(value: string): string | null {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return null;
}
}
function matchesHost(hostname: string, expectedHost: string): boolean {
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
}
function isGoogleVideoMediaPath(value: string): boolean {
const host = getHostname(value);
return Boolean(host && matchesHost(host, 'googlevideo.com'));
}
function describeMediaPathForDebugLog(value: string): string {
try {
const url = new URL(value);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
}
return `${url.protocol.replace(/:$/, '')}:`;
} catch {
// Not a URL; treat as a local file path below.
}
if (value.startsWith('edl://')) {
return 'edl:';
}
return `local:${value}`;
}
function logMediaResolutionDebug(
options: MediaGenerationInputResolverOptions,
message: string,
): void {
if (!options.logDebug) {
return;
}
try {
options.logDebug(`[media-source] ${message}`);
} catch {
// Debug logging should not affect media generation.
}
}
function logResolvedMediaGenerationInput(
options: MediaGenerationInputResolverOptions,
currentVideoPath: string,
result: ResolvedMediaGenerationInput,
): void {
logMediaResolutionDebug(
options,
[
`kind=${result.kind}`,
`source=${result.source}`,
`input=${describeMediaPathForDebugLog(result.path)}`,
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
`singleResolvedStream=${result.singleResolvedStream}`,
].join(' '),
);
}
function logMediaGenerationInputMiss(
options: MediaGenerationInputResolverOptions,
kind: MediaGenerationKind,
currentVideoPath: string,
reason: string,
): void {
logMediaResolutionDebug(
options,
[
`kind=${kind}`,
'source=cache-miss',
`reason=${reason}`,
`mode=${options.remoteCacheMode ?? 'optional'}`,
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
].join(' '),
);
}
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
const lowerName = name.toLowerCase();
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
headers[name] = value;
}
}
function parseMpvHeaderField(value: string): [string, string] | null {
const separatorIndex = value.indexOf(':');
if (separatorIndex <= 0) {
return null;
}
const name = normalizeHeaderName(value.slice(0, separatorIndex));
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
if (!name || !headerValue) {
return null;
}
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
}
function toHeaderFields(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string');
}
if (typeof value === 'string') {
return value
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
return [];
}
async function requestOptionalMpvProperty(
mpvClient: Pick<MpvClient, 'requestProperty'>,
name: string,
): Promise<unknown> {
if (!mpvClient.requestProperty) {
return null;
}
try {
return await mpvClient.requestProperty(name);
} catch {
return null;
}
}
async function requestFirstNonEmptyStringProperty(
mpvClient: Pick<MpvClient, 'requestProperty'>,
names: readonly string[],
): Promise<string | null> {
for (const name of names) {
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
if (value) {
return value;
}
}
return null;
}
async function resolveRemoteInputOptions(
mpvClient: Pick<MpvClient, 'requestProperty'>,
resolvedPath: string,
): Promise<MediaInputOptions | undefined> {
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
return undefined;
}
const headers: Record<string, string> = {};
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
const mpvHeaderFields = toHeaderFields(
await requestOptionalMpvProperty(mpvClient, propertyName),
);
for (const field of mpvHeaderFields) {
const parsed = parseMpvHeaderField(field);
if (parsed) {
headers[parsed[0]] = parsed[1];
}
}
}
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
if (referrer) {
setHeaderIfMissing(headers, 'Referer', referrer);
}
if (isGoogleVideoMediaPath(resolvedPath)) {
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
}
return {
reconnect: true,
...(userAgent ? { userAgent } : {}),
...(Object.keys(headers).length > 0 ? { headers } : {}),
};
}
async function toResolvedMediaGenerationInput(
mpvClient: Pick<MpvClient, 'requestProperty'>,
path: string,
kind: MediaGenerationKind,
source: MediaGenerationInputSource,
singleResolvedStream: boolean,
): Promise<ResolvedMediaGenerationInput> {
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
return {
path,
kind,
source,
singleResolvedStream,
...(inputOptions ? { inputOptions } : {}),
};
}
export async function resolveMediaGenerationInput(
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
kind: MediaGenerationKind = 'video',
options: MediaGenerationInputResolverOptions = {},
): Promise<ResolvedMediaGenerationInput | null> {
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
if (!currentVideoPath) {
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
return null;
}
if (!isRemoteMediaPath(currentVideoPath)) {
const result: ResolvedMediaGenerationInput = {
path: currentVideoPath,
kind,
source: 'current-path',
singleResolvedStream: false,
};
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
let cachedPath: string | null = null;
if (options.getCachedMediaPath) {
try {
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
} catch {
cachedPath = null;
}
}
if (cachedPath) {
const result: ResolvedMediaGenerationInput = {
path: cachedPath,
kind,
source: 'youtube-cache',
singleResolvedStream: false,
};
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
if (options.remoteCacheMode === 'required') {
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
return null;
}
if (!mpvClient?.requestProperty) {
const result: ResolvedMediaGenerationInput = {
path: currentVideoPath,
kind,
source: 'current-path',
singleResolvedStream: false,
};
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
try {
const streamOpenFilename = trimToNonEmptyString(
await mpvClient.requestProperty('stream-open-filename'),
);
if (streamOpenFilename?.startsWith('edl://')) {
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
if (preferredUrl) {
const result = await toResolvedMediaGenerationInput(
mpvClient,
preferredUrl,
kind,
'edl-stream',
true,
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
const result = await toResolvedMediaGenerationInput(
mpvClient,
streamOpenFilename,
kind,
'stream-open-filename',
false,
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
if (streamOpenFilename) {
const result = await toResolvedMediaGenerationInput(
mpvClient,
streamOpenFilename,
kind,
'stream-open-filename',
isRemoteMediaPath(streamOpenFilename),
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
} catch {
// Fall back to the current path when mpv does not expose a resolved stream URL.
}
const result = await toResolvedMediaGenerationInput(
mpvClient,
currentVideoPath,
kind,
'current-path',
false,
);
logResolvedMediaGenerationInput(options, currentVideoPath, result);
return result;
}
export async function resolveMediaGenerationInputPath(
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
kind: MediaGenerationKind = 'video',
@@ -409,3 +409,59 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
assert.deepEqual(imageContext, sidebarContext);
assert.equal(miscInfoStartTime, 10);
});
test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => {
const harness = createWorkflowHarness();
const queuedUpdates: Array<{
noteId: number;
noteInfo: NoteUpdateWorkflowNoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}> = [];
const mediaCalls: string[] = [];
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
},
media: {
generateAudio: true,
generateImage: true,
},
behavior: {},
});
harness.deps.generateAudio = async () => {
mediaCalls.push('audio');
return Buffer.from('audio');
};
harness.deps.generateImage = async () => {
mediaCalls.push('image');
return Buffer.from('image');
};
harness.deps.queuePendingYoutubeMediaUpdate = async (job) => {
queuedUpdates.push(job);
return true;
};
await harness.workflow.execute(42);
assert.deepEqual(mediaCalls, []);
assert.equal(queuedUpdates.length, 1);
assert.equal(queuedUpdates[0]?.noteId, 42);
assert.equal(queuedUpdates[0]?.label, 'taberu');
assert.equal(queuedUpdates[0]?.context, undefined);
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
});
+24 -5
View File
@@ -85,6 +85,12 @@ export interface NoteUpdateWorkflowDeps {
) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
queuePendingYoutubeMediaUpdate?: (job: {
noteId: number;
noteInfo: NoteUpdateWorkflowNoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}) => Promise<boolean>;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
@@ -195,6 +201,7 @@ export class NoteUpdateWorkflow {
sentenceField,
config.fields?.sentence,
);
const noteLabel = hasExpressionText ? expressionText : noteId;
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) {
@@ -227,7 +234,19 @@ export class NoteUpdateWorkflow {
}
}
if (config.media?.generateAudio) {
const generateAudio = config.media?.generateAudio !== false;
const generateImage = config.media?.generateImage !== false;
const mediaCacheQueued =
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
? await this.deps.queuePendingYoutubeMediaUpdate({
noteId,
noteInfo,
context: subtitleMiningContext ?? undefined,
label: noteLabel,
})
: false;
if (!mediaCacheQueued && generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
@@ -252,7 +271,7 @@ export class NoteUpdateWorkflow {
}
}
if (config.media?.generateImage) {
if (!mediaCacheQueued && generateImage) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename();
@@ -287,7 +306,7 @@ export class NoteUpdateWorkflow {
}
}
if (config.fields?.miscInfo) {
if (!mediaCacheQueued && config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || '',
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
@@ -305,8 +324,8 @@ export class NoteUpdateWorkflow {
if (updatePerformed) {
await this.deps.client.updateNoteFields(noteId, updatedFields);
await this.deps.addConfiguredTagsToNote(noteId);
this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId);
await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId);
this.deps.logInfo('Updated card fields for:', noteLabel);
await this.deps.showNotification(noteId, noteLabel);
}
if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) {
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnkiConnectConfig } from '../types/anki';
import {
PendingYoutubeMediaQueue,
type PendingYoutubeMediaQueueDeps,
} from './pending-youtube-media-queue';
function createDeps(
overrides: Partial<PendingYoutubeMediaQueueDeps> = {},
): PendingYoutubeMediaQueueDeps {
const warnings: unknown[][] = [];
const deps: PendingYoutubeMediaQueueDeps & { warnings: unknown[][] } = {
client: {
notesInfo: async () => [],
updateNoteFields: async () => {},
storeMediaFile: async () => {},
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
generateAnimatedImage: async () => Buffer.from('image'),
},
getConfig: () =>
({
media: { generateAudio: true, generateImage: true },
fields: {},
}) as AnkiConnectConfig,
getCurrentVideoPath: () => 'https://www.youtube.com/watch?v=abc123',
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0,
generateAudioFilename: () => 'audio.mp3',
generateImageFilename: () => 'image.webp',
formatMiscInfoPatternForMediaPath: () => '',
showStatusNotification: () => {},
showNotification: async () => {},
logInfo: () => {},
logWarn: (...args) => {
warnings.push(args);
},
logError: () => {},
warnings,
...overrides,
};
return deps;
}
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
const deps = createDeps({
getCachedMediaPath: async () => {
throw new Error('cache unavailable');
},
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
assert.equal(queued, false);
assert.deepEqual((deps as typeof deps & { warnings: unknown[][] }).warnings, [
[
'Failed to read YouTube cache state; falling back to immediate media generation:',
'cache unavailable',
],
]);
});
test('PendingYoutubeMediaQueue drains matching queued jobs when the cache download fails', async () => {
const statusMessages: string[] = [];
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
const updatedNotes: number[] = [];
const deps = createDeps({
client: {
notesInfo: async (noteIds) =>
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
updateNoteFields: async (noteId) => {
updatedNotes.push(noteId);
},
storeMediaFile: async () => {},
},
showStatusNotification: (message) => {
statusMessages.push(message);
},
showNotification: async (noteId, label, suffix) => {
notifications.push({ noteId, label, suffix });
},
});
const queue = new PendingYoutubeMediaQueue(deps);
queue.enqueue({
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
noteId: 42,
startTime: 1,
endTime: 2,
label: 'queued',
generateAudio: true,
generateImage: true,
});
await queue.handleFailed('https://youtu.be/abc123');
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.deepEqual(updatedNotes, []);
assert.deepEqual(notifications, [{ noteId: 42, label: 'queued', suffix: 'media cache failed' }]);
assert.equal(
statusMessages.includes('YouTube media cache failed. Media was not added to 1 queued card.'),
true,
);
});
test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queuing from notes', async () => {
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
const storedMedia: string[] = [];
const deps = createDeps({
client: {
notesInfo: async (noteIds) =>
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
updateNoteFields: async (noteId, fields) => {
updatedNotes.push({ noteId, fields });
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
},
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(storedMedia.length, 2);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
});
test('PendingYoutubeMediaQueue only announces a download once per source while jobs collect', () => {
const statusMessages: string[] = [];
const deps = createDeps({
showStatusNotification: (message) => {
statusMessages.push(message);
},
});
const queue = new PendingYoutubeMediaQueue(deps);
queue.enqueue({
sourceUrl: 'https://youtu.be/abc123',
noteId: 1,
startTime: 1,
endTime: 2,
label: 'first',
generateAudio: true,
generateImage: false,
});
queue.enqueue({
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
noteId: 2,
startTime: 3,
endTime: 4,
label: 'second',
generateAudio: false,
generateImage: true,
});
assert.equal(statusMessages.length, 1);
});
@@ -0,0 +1,391 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { MediaInput } from '../media-input';
import type { MediaGenerator } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
import type { SubtitleMiningContext } from '../types/subtitle';
import { youtubeMediaUrlsMatch, type PendingYoutubeMediaUpdate } from './pending-youtube-media';
import type { MediaGenerationInputResolverOptions } from './media-source';
type PendingYoutubeMediaUpdateResult = 'updated' | 'partial' | 'failed';
export interface PendingYoutubeMediaQueueReadyOptions {
notifyNoQueued?: boolean;
}
export interface PendingYoutubeMediaQueueFailedOptions {
notifyStatus?: boolean;
}
export interface PendingYoutubeMediaNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
export interface PendingYoutubeMediaQueueDeps {
client: {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
};
mediaGenerator: Pick<
MediaGenerator,
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
>;
getConfig: () => AnkiConnectConfig;
getCurrentVideoPath: () => string | undefined;
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
shouldRequireRemoteMediaCache: () => boolean;
getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
startTime: number;
endTime: number;
};
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
resolveConfiguredFieldName: (
noteInfo: PendingYoutubeMediaNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
generateAudioFilename: () => string;
generateImageFilename: () => string;
formatMiscInfoPatternForMediaPath: (
fallbackFilename: string,
startTimeSeconds: number | undefined,
mediaPath: string,
mediaTitle?: string,
) => string;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
return config.media?.generateAudio !== false;
}
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
return config.media?.generateImage !== false;
}
export class PendingYoutubeMediaQueue {
private updates: PendingYoutubeMediaUpdate[] = [];
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
enqueue(job: PendingYoutubeMediaUpdate): void {
if (!job.generateAudio && !job.generateImage) {
return;
}
const isFirstQueuedForSource = !this.updates.some((existing) =>
youtubeMediaUrlsMatch(existing.sourceUrl, job.sourceUrl),
);
this.updates.push(job);
this.deps.logInfo('Queued YouTube media update for note:', job.noteId);
if (isFirstQueuedForSource) {
this.deps.showStatusNotification(
'YouTube media cache is still downloading. Card media will be added when the cache is ready.',
);
}
}
async queueFromNote(job: {
noteId: number;
noteInfo: PendingYoutubeMediaNoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}): Promise<boolean> {
const sourceUrl = trimToNonEmptyString(this.deps.getCurrentVideoPath());
const getCachedMediaPath = this.deps.getCachedMediaPath;
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
return false;
}
let cachedPath: string | null = null;
try {
cachedPath = await getCachedMediaPath(sourceUrl, 'video');
} catch (error) {
this.deps.logWarn(
'Failed to read YouTube cache state; falling back to immediate media generation:',
error instanceof Error ? error.message : String(error),
);
return false;
}
if (cachedPath) {
return false;
}
const config = this.deps.getConfig();
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
this.enqueue({
sourceUrl,
noteId: job.noteId,
startTime: mediaRange.startTime,
endTime: mediaRange.endTime,
label: job.label,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName:
this.deps.resolveConfiguredFieldName(
job.noteInfo,
config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
) ?? undefined,
miscInfoFieldName:
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config),
});
return true;
}
async handleReady(
sourceUrl: string,
cachedPath: string,
options: PendingYoutubeMediaQueueReadyOptions = {},
): Promise<void> {
const jobs = this.takeMatchingUpdates(sourceUrl);
if (jobs.length === 0) {
if (options.notifyNoQueued !== false) {
this.deps.showStatusNotification('YouTube media cache ready.');
}
return;
}
this.deps.showStatusNotification(
`YouTube media cache ready. Adding media to ${jobs.length} queued card${
jobs.length === 1 ? '' : 's'
}.`,
);
let updatedCount = 0;
let partialCount = 0;
let failedCount = 0;
for (const job of jobs) {
try {
const result = await this.applyUpdate(job, cachedPath);
if (result === 'updated') {
updatedCount += 1;
} else if (result === 'partial') {
partialCount += 1;
} else {
failedCount += 1;
}
} catch (error) {
failedCount += 1;
this.deps.logError(
'Failed to apply queued YouTube media update:',
error instanceof Error ? error.message : String(error),
);
}
}
if (partialCount > 0 || failedCount > 0) {
this.deps.showStatusNotification(
`Queued YouTube media finished with ${updatedCount} updated, ${partialCount} partial, and ${failedCount} failed.`,
);
}
}
async handleFailed(
sourceUrl: string,
options: PendingYoutubeMediaQueueFailedOptions = {},
): Promise<void> {
const jobs = this.takeMatchingUpdates(sourceUrl);
if (jobs.length === 0) {
if (options.notifyStatus !== false) {
this.deps.showStatusNotification('YouTube media cache failed.');
}
return;
}
if (options.notifyStatus !== false) {
this.deps.showStatusNotification(
`YouTube media cache failed. Media was not added to ${jobs.length} queued card${
jobs.length === 1 ? '' : 's'
}.`,
);
}
this.deps.logWarn('Discarding queued YouTube media updates after cache failure:', jobs.length);
for (const job of jobs) {
try {
await this.deps.showNotification(job.noteId, job.label, 'media cache failed');
} catch (error) {
this.deps.logWarn(
'Failed to show queued YouTube media failure notification:',
error instanceof Error ? error.message : String(error),
);
}
}
}
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
const matched: PendingYoutubeMediaUpdate[] = [];
const remaining: PendingYoutubeMediaUpdate[] = [];
for (const job of this.updates) {
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
matched.push(job);
} else {
remaining.push(job);
}
}
this.updates = remaining;
return matched;
}
private async applyUpdate(
job: PendingYoutubeMediaUpdate,
cachedPath: string,
): Promise<PendingYoutubeMediaUpdateResult> {
const notesInfoResult = await this.deps.client.notesInfo([job.noteId]);
const notesInfo = notesInfoResult as unknown as PendingYoutubeMediaNoteInfo[];
const noteInfo = notesInfo[0];
if (!noteInfo) {
this.deps.logWarn('Queued YouTube media target note not found:', job.noteId);
return 'failed';
}
const config = this.deps.getConfig();
const mediaFields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
const cachedMediaInput: MediaInput = {
path: cachedPath,
source: 'youtube-cache',
};
if (job.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.mediaGenerator.generateAudio(
cachedMediaInput,
job.startTime,
job.endTime,
config.media?.audioPadding,
undefined,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioField =
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
if (audioField) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
mediaFields[audioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
config.behavior?.overwriteAudio !== false,
);
}
miscInfoFilename = audioFilename;
}
} catch (error) {
errors.push('audio');
this.deps.logError('Failed to generate queued YouTube audio:', (error as Error).message);
}
}
if (job.generateImage) {
try {
const imageFilename = this.deps.generateImageFilename();
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageBuffer = await this.generateImageFromInput(
cachedMediaInput,
job.startTime,
job.endTime,
animatedLeadInSeconds,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
const imageField =
job.imageFieldName ||
this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
);
if (imageField) {
const existingImage = noteInfo.fields[imageField]?.value || '';
mediaFields[imageField] = this.deps.mergeFieldValue(
existingImage,
`<img src="${imageFilename}">`,
config.behavior?.overwriteImage !== false,
);
} else {
this.deps.logWarn(
'Image field not found on queued YouTube media note, skipping image update',
);
}
miscInfoFilename = imageFilename;
}
} catch (error) {
errors.push('image');
this.deps.logError('Failed to generate queued YouTube image:', (error as Error).message);
}
}
if (config.fields?.miscInfo && miscInfoFilename) {
const miscInfoField =
job.miscInfoFieldName ||
this.deps.resolveConfiguredFieldName(noteInfo, config.fields.miscInfo);
const miscInfo = this.deps.formatMiscInfoPatternForMediaPath(
miscInfoFilename,
job.startTime,
job.sourceUrl,
);
if (miscInfoField && miscInfo) {
mediaFields[miscInfoField] = miscInfo;
}
}
if (Object.keys(mediaFields).length === 0) {
return 'failed';
}
await this.deps.client.updateNoteFields(job.noteId, mediaFields);
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
await this.deps.showNotification(job.noteId, job.label, errorSuffix);
this.deps.logInfo('Applied queued YouTube media update for note:', job.noteId);
return errors.length === 0 ? 'updated' : 'partial';
}
private async generateImageFromInput(
videoPath: MediaInput,
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
): Promise<Buffer | null> {
const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') {
return this.deps.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
config.media?.audioPadding,
{
fps: config.media?.animatedFps,
maxWidth: config.media?.animatedMaxWidth,
maxHeight: config.media?.animatedMaxHeight,
crf: config.media?.animatedCrf,
leadingStillDuration: animatedLeadInSeconds,
},
);
}
const timestamp = startTime + (endTime - startTime) / 2;
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: config.media?.imageFormat as 'jpg' | 'png' | 'webp',
quality: config.media?.imageQuality,
maxWidth: config.media?.imageMaxWidth,
maxHeight: config.media?.imageMaxHeight,
});
}
}
@@ -0,0 +1,53 @@
export interface PendingYoutubeMediaUpdate {
sourceUrl: string;
noteId: number;
startTime: number;
endTime: number;
label: string | number;
audioFieldName?: string;
imageFieldName?: string;
miscInfoFieldName?: string;
generateAudio: boolean;
generateImage: boolean;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function getYoutubeVideoId(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
const host = parsed.hostname.toLowerCase();
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
return trimToNonEmptyString(parsed.pathname.replace(/^\/+/, '').split('/')[0]);
}
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
const watchId = trimToNonEmptyString(parsed.searchParams.get('v'));
if (watchId) {
return watchId;
}
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts[0] === 'shorts' || parts[0] === 'embed' || parts[0] === 'live') {
return trimToNonEmptyString(parts[1]);
}
}
} catch {
return null;
}
return null;
}
export function youtubeMediaUrlsMatch(a: string, b: string): boolean {
if (a.trim() === b.trim()) {
return true;
}
const aVideoId = getYoutubeVideoId(a);
const bVideoId = getYoutubeVideoId(b);
return Boolean(aVideoId && bVideoId && aVideoId === bVideoId);
}
+52
View File
@@ -80,6 +80,8 @@ test('loads defaults when config is missing', () => {
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
assert.equal('deviceId' 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.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false);
@@ -1750,6 +1752,56 @@ test('parses global shortcuts and startup settings', () => {
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', () => {
const dir = makeTempDir();
fs.writeFileSync(
+4
View File
@@ -111,6 +111,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
youtube: {
primarySubLanguages: ['ja', 'jpn'],
mediaCache: {
mode: 'direct',
maxHeight: 720,
},
},
subsync: {
alass_path: '',
+18
View File
@@ -119,6 +119,24 @@ export function buildCoreConfigOptionRegistry(
description:
'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',
kind: 'boolean',
+1
View File
@@ -32,6 +32,7 @@ export interface ConfigOptionRegistryEntry {
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
*/
enumValues?: readonly string[];
enumLabels?: Record<string, string>;
/**
* Optional settings UI subset when legacy/runtime-valid enum options should remain
* 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.',
);
}
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)) {
+14
View File
@@ -165,6 +165,20 @@ test('settings registry exposes specialized controls for config-assisted inputs'
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', () => {
const primaryVisible = fields
.filter(
+1
View File
@@ -720,6 +720,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.settingsEnumValues || option?.enumValues
? { enumValues: option.settingsEnumValues ?? option.enumValues }
: {}),
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: restartBehaviorForPath(leaf.path),
advanced:
leaf.path.startsWith('controller.') ||
+7
View File
@@ -40,6 +40,11 @@ export interface AnkiJimakuIpcRuntimeOptions {
getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => (
@@ -107,6 +112,8 @@ export function registerAnkiJimakuIpcRuntime(
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
undefined,
options.showOverlayNotification,
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
);
integration.start();
options.setAnkiIntegration(integration);
+21
View File
@@ -25,6 +25,11 @@ type CreateAnkiIntegrationArgs = {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
knownWordCacheStatePath: string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
};
export type OverlayWindowTrackerOptions = {
@@ -65,6 +70,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.aiConfig,
undefined,
args.showOverlayNotification,
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
);
}
@@ -132,6 +139,11 @@ export function initializeOverlayRuntime(
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null;
@@ -166,6 +178,11 @@ export function initializeOverlayAnkiIntegration(options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): boolean {
@@ -200,6 +217,10 @@ export function initializeOverlayAnkiIntegration(options: {
showOverlayNotification: options.showOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
...(options.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
: {}),
});
if (options.shouldStartAnkiIntegration?.() !== false) {
integration.start();
@@ -0,0 +1,540 @@
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('--force-ipv4'));
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--retries') + 1], '5');
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--fragment-retries') + 1], '5');
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--extractor-retries') + 1], '5');
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 clears the active cache when direct mode starts', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/background', { 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.getActiveCachedMediaPath(), outputPath);
cache.start('https://youtu.be/direct', { mode: 'direct' });
assert.equal(await cache.getActiveCachedMediaPath(), null);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), outputPath);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache cancels the active download when direct mode starts', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
const readyEvents: Array<{ url: string; path: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
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/background', { 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');
cache.start('https://youtu.be/direct', { mode: 'direct' });
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(spawnedProcesses[0]?.killed, true);
assert.deepEqual(readyEvents, []);
assert.equal(await cache.getActiveCachedMediaPath(), null);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache reports failed background downloads', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const failedEvents: Array<{ url: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onFailed: (event) => {
failedEvents.push(event);
},
spawn: () => {
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
spawnedProcesses[0]?.emit('close', 1);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache only reports a failed download once when error is followed by close', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const failedEvents: Array<{ url: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onFailed: (event) => {
failedEvents.push(event);
},
spawn: () => {
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
spawnedProcesses[0]?.emit('error', new Error('spawn failed'));
spawnedProcesses[0]?.emit('close', 1);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]);
} 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 ready sessions when the height cap changes', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
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', maxHeight: 720 });
const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof firstOutputTemplate, 'string');
const firstOutputDir = path.dirname(firstOutputTemplate!);
const firstOutputPath = path.join(firstOutputDir, 'media.mkv');
fs.mkdirSync(firstOutputDir, { recursive: true });
fs.writeFileSync(firstOutputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), firstOutputPath);
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
assert.equal(spawnCalls.length, 2);
assert.equal(fs.existsSync(firstOutputPath), false);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
assert.equal(
spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1],
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache restarts running sessions when the height cap changes', () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
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', maxHeight: 720 });
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
assert.equal(spawnedProcesses[0]?.killed, true);
assert.equal(spawnCalls.length, 2);
assert.equal(
spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1],
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache removes stale files from previous runs on startup', () => {
const cacheRoot = makeTempCacheRoot();
const staleDir = path.join(cacheRoot, 'stale-session');
const spawnCalls: SpawnCall[] = [];
try {
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media');
createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
return new FakeYtDlpProcess();
},
});
assert.equal(fs.existsSync(staleDir), false);
assert.deepEqual(spawnCalls, []);
} 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 removes stale disk siblings before starting a new cache', () => {
const cacheRoot = makeTempCacheRoot();
const staleDir = path.join(cacheRoot, 'stale-sibling');
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
return new FakeYtDlpProcess();
},
});
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media');
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.equal(fs.existsSync(staleDir), false);
assert.equal(spawnCalls.length, 1);
} 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 });
}
});
+307
View File
@@ -0,0 +1,307 @@
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;
maxHeight: number;
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;
onFailed?: (event: { url: 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',
'--force-ipv4',
'--retries',
'5',
'--fragment-retries',
'5',
'--extractor-retries',
'5',
'-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 removeCacheDir = (dir: string): void => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Temp cache cleanup should not block shutdown or playback startup.
}
};
const removeCacheRootEntriesExcept = (dirsToKeep: string[]): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
} catch {
return;
}
const keepDirs = new Set(dirsToKeep.map((dir) => path.resolve(dir)));
for (const entry of entries) {
const entryPath = path.join(cacheRoot, entry.name);
if (keepDirs.has(path.resolve(entryPath))) {
continue;
}
removeCacheDir(entryPath);
}
};
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;
}
removeCacheDir(session.dir);
};
const removeInactiveSessions = (keyToKeep: string): void => {
for (const key of [...sessions.keys()]) {
if (key !== keyToKeep) {
removeSession(key);
}
}
};
removeCacheRootEntriesExcept([]);
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),
maxHeight: session?.maxHeight ?? DEFAULT_MAX_HEIGHT,
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') {
if (activeKey) {
const activeSession = sessions.get(activeKey);
if (activeSession?.state === 'running') {
removeSession(activeKey);
}
}
activeKey = null;
return;
}
const key = cacheKeyForUrl(url);
const maxHeight = normalizeMaxHeight(options.maxHeight);
activeKey = key;
const dir = getSessionDir(url);
const existingSession = sessions.get(key);
const canReuseExistingSession = existingSession?.maxHeight === maxHeight;
if (existingSession?.state === 'running' && canReuseExistingSession) {
removeInactiveSessions(key);
removeCacheRootEntriesExcept([existingSession.dir]);
return;
}
if (existingSession) {
if (
canReuseExistingSession &&
existingSession.state === 'ready' &&
((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) ||
findReadyMediaPath(existingSession.dir))
) {
removeInactiveSessions(key);
removeCacheRootEntriesExcept([existingSession.dir]);
return;
}
removeSession(key);
activeKey = key;
}
removeInactiveSessions(key);
removeCacheRootEntriesExcept([dir]);
fs.mkdirSync(dir, { recursive: true });
const outputTemplate = path.join(dir, 'media.%(ext)s');
const args = createYtDlpArgs(url, outputTemplate, maxHeight);
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
const session: MediaCacheSession = {
url,
dir,
maxHeight,
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)
}`,
);
deps.onFailed?.({ url });
});
child.once('close', (code) => {
const currentSession = sessions.get(key);
if (currentSession !== session) {
return;
}
if (session.state === 'failed') {
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.`);
deps.onFailed?.({ url });
});
};
const cleanup = (): void => {
for (const key of [...sessions.keys()]) {
removeSession(key);
}
activeKey = null;
};
return {
cleanup,
getActiveCachedMediaPath,
getCachedMediaPath,
start,
};
}
+87
View File
@@ -332,6 +332,7 @@ import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import {
@@ -1172,6 +1173,53 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
},
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)
}`,
);
});
},
onFailed: (event) => {
showConfiguredStatusNotification('YouTube media cache failed.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'error',
persistent: false,
});
void appState.ankiIntegration
?.handleYoutubeMediaCacheFailed(event.url, { notifyStatus: false })
.catch((error) => {
logger.warn(
`Failed to drain queued YouTube media updates after cache failure: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
},
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient,
now: () => Date.now(),
@@ -1316,6 +1364,12 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
},
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
startYoutubeMediaCache: (url) => {
youtubeMediaCache.start(url, {
mode: getResolvedConfig().youtube.mediaCache.mode,
maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight,
});
},
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
@@ -1635,6 +1689,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 {
const type = getConfiguredStatusNotificationType();
if (type === 'none') {
@@ -2636,6 +2714,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({
});
const {
flushQueuedOverlayNotifications,
flushQueuedMpvOsdNotifications,
openAnkiCardFromNotification,
toggleNotificationHistoryPanel,
showConfiguredPlaybackFeedback,
@@ -3801,6 +3880,7 @@ const {
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop();
@@ -4286,6 +4366,7 @@ const {
},
onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -5731,6 +5812,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
);
},
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
showDesktopNotification,
showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
@@ -6207,6 +6291,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
},
+6
View File
@@ -126,6 +126,8 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
@@ -317,6 +319,10 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
getAnkiIntegration: params.getAnkiIntegration,
setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}),
...(params.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache }
: {}),
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
+42
View File
@@ -514,6 +514,48 @@ test('configured overlay notifications require visible ready overlay window', ()
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', () => {
const source = readMainSource();
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'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 32);
assert.equal(calls.length, 33);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
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'));
});
@@ -92,6 +94,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
throw new Error('stop failed');
},
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -67,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupJellyfinSubtitleCache();
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService();
};
}
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
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('stop-jellyfin-remote'));
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('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
@@ -147,6 +149,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -197,6 +200,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -142,6 +143,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {},
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[] = [];
notifyConfiguredStatus('Overlay loading...', {
@@ -43,10 +43,10 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay both status',
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[] = [];
notifyConfiguredStatus('Overlay loading...', {
@@ -61,7 +61,7 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only
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', () => {
@@ -97,6 +97,37 @@ test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
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', () => {
const calls: string[] = [];
@@ -5,6 +5,7 @@ export interface ConfiguredStatusNotificationDeps {
getNotificationType: () => NotificationType | undefined;
isOverlayReady?: () => boolean;
showOsd: (message: string) => boolean | void;
queueOsd?: (message: string, options: ConfiguredStatusNotificationOptions) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
}
@@ -50,8 +51,7 @@ export function notifyConfiguredStatus(
}
if (showOverlay) {
const overlayReady = deps.isOverlayReady?.() ?? true;
if (deps.showOverlayNotification && overlayReady) {
if (deps.showOverlayNotification) {
deps.showOverlayNotification({
id: options.id,
title: options.title ?? 'SubMiner',
@@ -65,7 +65,10 @@ export function notifyConfiguredStatus(
}
if (showOsd) {
deps.showOsd(message);
const shown = deps.showOsd(message);
if (shown === false && delivery !== 'feedback') {
deps.queueOsd?.(message, options);
}
}
if (desktopEnabled && shouldShowDesktop(type)) {
@@ -32,7 +32,7 @@ export interface OverlayNotificationsRuntimeDeps {
getMainOverlayWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
showMpvOsd: (message: string) => void;
showMpvOsd: (message: string) => boolean | void;
getMpvClient: () => MpvIpcClient | null;
getAnkiIntegration: () => AnkiIntegration | null;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
@@ -42,6 +42,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
isVisibleOverlayContentReady: () => boolean;
getConfiguredStatusNotificationType: () => NotificationType;
flushQueuedOverlayNotifications: () => void;
flushQueuedMpvOsdNotifications: () => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification: (id: string) => void;
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
@@ -95,11 +96,38 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
});
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
null;
const queuedConfiguredOsdNotifications = new Map<
string,
{ message: string; options: ConfiguredStatusNotificationOptions }
>();
function flushQueuedOverlayNotifications(): void {
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 {
overlayNotificationDelivery.send(payload);
}
@@ -145,6 +173,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
isOverlayReady: () => isVisibleOverlayContentReady(),
showOsd: (text) => deps.showMpvOsd(text),
queueOsd: (text, queueOptions) => queueConfiguredOsdNotification(text, queueOptions),
showOverlayNotification,
showDesktopNotification: (title, notificationOptions) =>
showDesktopNotification(title, notificationOptions),
@@ -238,6 +267,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
isVisibleOverlayContentReady,
getConfiguredStatusNotificationType,
flushQueuedOverlayNotifications,
flushQueuedMpvOsdNotifications,
showOverlayNotification,
dismissOverlayNotification,
openAnkiCardFromNotification,
@@ -41,6 +41,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
@@ -77,6 +79,10 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
showOverlayNotification: deps.showOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
...(deps.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
: {}),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
@@ -37,6 +37,11 @@ type OverlayRuntimeOptions = {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
@@ -71,6 +76,11 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
@@ -97,6 +107,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
showOverlayNotification: deps.showOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
...(deps.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
: {}),
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
bindOverlayOwner: deps.bindOverlayOwner,
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')));
});
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>;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -126,6 +127,18 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
if (!mediaReady) {
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({
url: request.url,
+132 -3
View File
@@ -8,6 +8,10 @@ import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator
async function withStubbedFfmpeg(
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
options: {
logDebug?: (message: string) => void;
now?: () => number;
} = {},
): Promise<void> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
const binDir = path.join(root, 'bin');
@@ -25,7 +29,7 @@ async function withStubbedFfmpeg(
" console.log(' V..... libaom-av1');",
' 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);',
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
].join('\n'),
@@ -44,7 +48,7 @@ async function withStubbedFfmpeg(
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
const generator = new MediaGenerator(tempDir);
const generator = new MediaGenerator(tempDir, options);
try {
await run(generator, argsPath);
@@ -61,7 +65,7 @@ async function withStubbedFfmpeg(
}
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', () => {
@@ -182,3 +186,128 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as
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_on_network_error') + 1], '1');
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
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 os from 'os';
import { createLogger } from './logger';
import { normalizeMediaInput, type MediaInput } from './media-input';
const log = createLogger('media');
export type { MediaInput, MediaInputOptions } from './media-input';
function normalizeAnimatedImageFps(fps: number | undefined): number {
const fallbackFps = 10;
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
@@ -69,12 +72,65 @@ export function buildAnimatedImageVideoFilter(options: {
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 {
private tempDir: string;
private notifyIconDir: string;
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.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
this.ensureDirectory(this.tempDir);
@@ -83,6 +139,28 @@ export class MediaGenerator {
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 {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -181,7 +259,7 @@ export class MediaGenerator {
}
async generateAudio(
videoPath: string,
videoPath: MediaInput,
startTime: number,
endTime: number,
padding: number = 0,
@@ -190,12 +268,24 @@ export class MediaGenerator {
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime - safePadding);
const duration = endTime - start + safePadding;
const mediaInput = normalizeMediaInput(videoPath);
const inputDescription = describeMediaInputForDebugLog(videoPath);
return new Promise((resolve, reject) => {
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 (
!mediaInput.singleResolvedStream &&
typeof audioStreamIndex === 'number' &&
Number.isInteger(audioStreamIndex) &&
audioStreamIndex >= 0
@@ -205,8 +295,14 @@ export class MediaGenerator {
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) => {
if (error) {
this.logMediaDebug(
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
);
reject(this.ffmpegError('audio generation', error));
return;
}
@@ -214,6 +310,9 @@ export class MediaGenerator {
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
this.logMediaDebug(
`audio complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
);
resolve(data);
} catch (err) {
reject(err);
@@ -223,7 +322,7 @@ export class MediaGenerator {
}
async generateScreenshot(
videoPath: string,
videoPath: MediaInput,
timestamp: number,
options: {
format: 'jpg' | 'png' | 'webp';
@@ -239,8 +338,18 @@ export class MediaGenerator {
png: 'png',
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[] = [];
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
@@ -270,10 +379,17 @@ export class MediaGenerator {
return new Promise((resolve, reject) => {
const outputPath = this.createTempOutputPath('screenshot', ext);
const startedAt = this.nowMs();
args.push(outputPath);
this.logMediaDebug(
`screenshot start ${inputDescription} timestamp=${timestamp} format=${format} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'}`,
);
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
if (error) {
this.logMediaDebug(
`screenshot failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
);
reject(this.ffmpegError('screenshot generation', error));
return;
}
@@ -281,6 +397,9 @@ export class MediaGenerator {
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
this.logMediaDebug(
`screenshot complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
);
resolve(data);
} catch (err) {
reject(err);
@@ -334,7 +453,7 @@ export class MediaGenerator {
}
async generateAnimatedImage(
videoPath: string,
videoPath: MediaInput,
startTime: number,
endTime: number,
padding: number = 0,
@@ -352,10 +471,15 @@ export class MediaGenerator {
const start = Math.max(0, startTime - safePadding);
const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps);
const totalLeadingStillDuration = Math.max(0, leadingStillDuration);
const inputDescription = describeMediaInputForDebugLog(videoPath);
const clampedCrf = Math.max(0, Math.min(63, crf));
const encoderDetectionStartedAt = this.nowMs();
const av1Encoder = await this.detectAv1Encoder();
this.logMediaDebug(
`animated-image encoder ${inputDescription} elapsedMs=${this.elapsedMs(encoderDetectionStartedAt)} encoder=${av1Encoder ?? 'none'}`,
);
if (!av1Encoder) {
throw new Error(
'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) => {
const outputPath = this.createTempOutputPath('animation', 'avif');
const mediaInput = normalizeMediaInput(videoPath);
const startedAt = this.nowMs();
const encoderArgs: string[] = ['-c:v', av1Encoder];
if (av1Encoder === 'libaom-av1') {
@@ -375,6 +501,9 @@ export class MediaGenerator {
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(
'ffmpeg',
[
@@ -382,8 +511,9 @@ export class MediaGenerator {
start.toString(),
'-t',
duration.toString(),
...mediaInput.inputArgs,
'-i',
videoPath,
mediaInput.path,
'-vf',
buildAnimatedImageVideoFilter({
fps: clampedFps,
@@ -398,6 +528,9 @@ export class MediaGenerator {
{ timeout: 60000 },
(error) => {
if (error) {
this.logMediaDebug(
`animated-image failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
);
reject(this.ffmpegError('animation generation', error));
return;
}
@@ -405,6 +538,9 @@ export class MediaGenerator {
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
this.logMediaDebug(
`animated-image complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
);
resolve(data);
} catch (err) {
reject(err);
+97
View File
@@ -0,0 +1,97 @@
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_on_network_error',
'1',
'-reconnect_on_http_error',
'403,5xx',
'-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();
}
});
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) {
const option = createElement('option') as HTMLOptionElement;
option.value = enumValue;
option.textContent = enumValue;
option.textContent = field.enumLabels?.[enumValue] ?? enumValue;
option.selected = enumValue === value;
select.append(option);
}
+5
View File
@@ -16,6 +16,7 @@ import type {
StatsConfig,
YomitanConfig,
YoutubeConfig,
YoutubeMediaCacheMode,
YoutubeSubgenConfig,
} from './integrations';
import type {
@@ -345,6 +346,10 @@ export interface ResolvedConfig {
};
youtube: YoutubeConfig & {
primarySubLanguages: string[];
mediaCache: {
mode: YoutubeMediaCacheMode;
maxHeight: number;
};
};
youtubeSubgen: YoutubeSubgenConfig & {
whisperBin: string;
+8
View File
@@ -122,8 +122,16 @@ export interface AiConfig {
requestTimeoutMs?: number;
}
export type YoutubeMediaCacheMode = 'direct' | 'background';
export interface YoutubeMediaCacheConfig {
mode?: YoutubeMediaCacheMode;
maxHeight?: number;
}
export interface YoutubeConfig {
primarySubLanguages?: string[];
mediaCache?: YoutubeMediaCacheConfig;
}
export interface YoutubeSubgenConfig {
+1
View File
@@ -43,6 +43,7 @@ export interface ConfigSettingsField {
control: ConfigSettingsControl;
defaultValue: unknown;
enumValues?: readonly string[];
enumLabels?: Record<string, string>;
restartBehavior: ConfigSettingsRestartBehavior;
advanced?: boolean;
secret?: boolean;