feat(youtube): queue media for background cache and fill fields when rea

- Add `youtube.mediaCache.maxHeight` config option (default 720p)
- Background mode creates text-only cards while cache downloads, queues media updates, fills audio/image fields once cached file is ready
- Announce cache download start and readiness via overlay/OSD notifications
- Skip mpv stream indexes when generating audio from a YouTube cached file
This commit is contained in:
2026-06-23 20:45:28 -07:00
parent 236f22662c
commit 028636c76d
38 changed files with 2047 additions and 67 deletions
+2
View File
@@ -44,6 +44,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => (
@@ -112,6 +113,7 @@ export function registerAnkiJimakuIpcRuntime(
undefined,
options.showOverlayNotification,
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
);
integration.start();
options.setAnkiIntegration(integration);
@@ -29,6 +29,7 @@ type CreateAnkiIntegrationArgs = {
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
};
export type OverlayWindowTrackerOptions = {
@@ -70,6 +71,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
undefined,
args.showOverlayNotification,
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
);
}
@@ -141,6 +143,7 @@ export function initializeOverlayRuntime(
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null;
@@ -179,6 +182,7 @@ export function initializeOverlayAnkiIntegration(options: {
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): boolean {
@@ -214,6 +218,9 @@ export function initializeOverlayAnkiIntegration(options: {
createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
...(options.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
: {}),
});
if (options.shouldStartAnkiIntegration?.() !== false) {
integration.start();
@@ -55,11 +55,19 @@ test('YouTube media cache exposes the downloaded file after the background job c
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();
@@ -70,10 +78,15 @@ test('YouTube media cache exposes the downloaded file after the background job c
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.deepEqual(startedEvents, [{ url: 'https://youtu.be/demo' }]);
assert.equal(spawnCalls.length, 1);
assert.equal(spawnCalls[0]?.command, 'yt-dlp');
assert.ok(spawnCalls[0]?.args.includes('--no-playlist'));
assert.ok(spawnCalls[0]?.args.includes('--merge-output-format'));
assert.equal(
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
'bestvideo*[height<=720]+bestaudio/best[height<=720]',
);
assert.deepEqual(spawnCalls[0]?.options?.stdio, ['ignore', 'ignore', 'ignore']);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
@@ -88,6 +101,59 @@ test('YouTube media cache exposes the downloaded file after the background job c
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
assert.deepEqual(readyEvents, [{ url: 'https://youtu.be/demo', path: outputPath }]);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache can disable the download height cap', () => {
const cacheRoot = makeTempCacheRoot();
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new FakeYtDlpProcess();
},
});
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 0 });
assert.equal(spawnCalls.length, 1);
assert.equal(
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
'bestvideo*+bestaudio/best',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache applies the configured download height cap', () => {
const cacheRoot = makeTempCacheRoot();
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new FakeYtDlpProcess();
},
});
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
assert.equal(spawnCalls.length, 1);
assert.equal(
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
+22 -3
View File
@@ -31,17 +31,21 @@ interface MediaCacheSession {
export interface YoutubeMediaCacheStartOptions {
mode: YoutubeMediaCacheMode;
maxHeight?: number;
}
export interface YoutubeMediaCacheServiceDeps {
cacheRoot?: string;
getYtDlpCommand?: () => string;
spawn?: SpawnProcess;
onDownloadStarted?: (event: { url: string }) => void;
onReady?: (event: { url: string; path: string }) => void;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
}
const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']);
const DEFAULT_MAX_HEIGHT = 720;
function cacheKeyForUrl(url: string): string {
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
@@ -67,12 +71,25 @@ function findReadyMediaPath(dir: string): string | null {
}
}
function createYtDlpArgs(url: string, outputTemplate: string): string[] {
function getFormatSelector(maxHeight: number): string {
return maxHeight > 0
? `bestvideo*[height<=${maxHeight}]+bestaudio/best[height<=${maxHeight}]`
: 'bestvideo*+bestaudio/best';
}
function normalizeMaxHeight(maxHeight: number | undefined): number {
if (maxHeight === undefined) {
return DEFAULT_MAX_HEIGHT;
}
return Number.isInteger(maxHeight) && maxHeight >= 0 ? maxHeight : DEFAULT_MAX_HEIGHT;
}
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
return [
'--no-playlist',
'--no-warnings',
'-f',
'bestvideo*+bestaudio/best',
getFormatSelector(normalizeMaxHeight(maxHeight)),
'--merge-output-format',
'mkv',
'-o',
@@ -177,7 +194,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
const dir = getSessionDir(url);
fs.mkdirSync(dir, { recursive: true });
const outputTemplate = path.join(dir, 'media.%(ext)s');
const args = createYtDlpArgs(url, outputTemplate);
const args = createYtDlpArgs(url, outputTemplate, options.maxHeight);
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
const session: MediaCacheSession = {
url,
@@ -188,6 +205,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
};
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);
@@ -215,6 +233,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
session.state = 'ready';
session.readyPath = readyPath;
deps.logInfo?.(`YouTube media cache ready at ${readyPath}`);
deps.onReady?.({ url, path: readyPath });
return;
}
}