mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
feat(youtube): queue media for background cache and fill fields when rea
- Add `youtube.mediaCache.maxHeight` config option (default 720p) - Background mode creates text-only cards while cache downloads, queues media updates, fills audio/image fields once cached file is ready - Announce cache download start and readiness via overlay/OSD notifications - Skip mpv stream indexes when generating audio from a YouTube cached file
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user