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
+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,