fFix(youtube): recover source URL for background media cache on direct mpv open (#132)

This commit is contained in:
2026-06-28 22:46:11 -07:00
committed by GitHub
parent f65afa6046
commit c942a2cf2d
17 changed files with 580 additions and 30 deletions
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
+1 -1
View File
@@ -1538,7 +1538,7 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
| `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.
`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, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. 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.
+8 -8
View File
@@ -85,7 +85,7 @@ The picker displays each track with its language, kind (manual/auto), and title
SubMiner handles several YouTube subtitle formats transparently:
| Format | Handling |
| ------ | -------- |
| ---------------------- | -------------------------------------------------------- |
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
@@ -94,7 +94,7 @@ For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (Ti
## 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.
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, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It 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.
@@ -105,13 +105,13 @@ Background cache downloads use IPv4 and retry flags to reduce YouTube throttling
```jsonc
{
"youtube": {
"primarySubLanguages": ["ja", "jpn"]
}
"primarySubLanguages": ["ja", "jpn"],
},
}
```
| Option | Type | Description |
| ------ | ---- | ----------- |
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
### Secondary Subtitle Languages
@@ -123,13 +123,13 @@ Secondary track selection uses the shared `secondarySub` config:
"secondarySub": {
"secondarySubLanguages": [],
"autoLoadSecondarySub": false,
"defaultMode": "hover"
}
"defaultMode": "hover",
},
}
```
| Option | Type | Description |
| ------ | ---- | ----------- |
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
+2 -2
View File
File diff suppressed because one or more lines are too long
+92
View File
@@ -768,6 +768,98 @@ test('AnkiIntegration reports partial queued YouTube media updates separately fr
);
});
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
const storedMedia: string[] = [];
const integration = new AnkiIntegration(
{
fields: {
image: 'Picture',
},
media: {
imageFormat: 'jpg',
},
},
{} as never,
{
currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
currentSubStart: 10,
currentSubEnd: 12,
currentTimePos: 11,
} as never,
() => undefined,
undefined,
undefined,
undefined,
{},
undefined,
undefined,
async () => null,
() => true,
() => 'https://www.youtube.com/watch?v=abc123',
);
const internals = integration as unknown as {
client: {
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
storeMediaFile: (filename: string) => Promise<void>;
};
mediaGenerator: {
generateAudio: () => Promise<Buffer>;
generateScreenshot: () => Promise<Buffer>;
};
queuePendingYoutubeMediaUpdateForNote: (job: {
noteId: number;
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
label: string | number;
}) => Promise<boolean>;
showNotification: () => 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 (filename) => {
storedMedia.push(filename);
},
};
internals.mediaGenerator = {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
};
internals.showNotification = async () => undefined;
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
noteId: 404,
noteInfo: {
noteId: 404,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
label: 'resolved source',
});
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(updatedNotes[0]?.noteId, 404);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2);
});
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
+15 -1
View File
@@ -239,6 +239,9 @@ export class AnkiIntegration {
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
null;
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
private getYoutubeMediaSourceUrl:
| (() => Promise<string | null | undefined> | string | null | undefined)
| null = null;
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
constructor(
@@ -257,6 +260,7 @@ export class AnkiIntegration {
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
) {
this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
@@ -271,6 +275,7 @@ export class AnkiIntegration {
this.recordCardsMinedCallback = recordCardsMined ?? null;
this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner();
@@ -356,7 +361,7 @@ export class AnkiIntegration {
),
},
getConfig: () => this.config,
getCurrentVideoPath: () => this.mpvClient.currentVideoPath,
getCurrentVideoPath: () => this.getCurrentYoutubeMediaSourceUrl(),
getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
@@ -474,6 +479,7 @@ export class AnkiIntegration {
getMpvClient: () => this.mpvClient,
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
getDeck: () => this.config.deck,
client: {
@@ -945,6 +951,14 @@ export class AnkiIntegration {
return this.pendingYoutubeMediaQueue.queueFromNote(job);
}
private async getCurrentYoutubeMediaSourceUrl(): Promise<string> {
return (
trimToNonEmptyString(await this.getYoutubeMediaSourceUrl?.()) ??
trimToNonEmptyString(this.mpvClient.currentVideoPath) ??
''
);
}
async handleYoutubeMediaCacheReady(
sourceUrl: string,
cachedPath: string,
+12 -1
View File
@@ -28,6 +28,14 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
return config.media?.generateImage !== false;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export interface CardCreationNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
@@ -90,6 +98,7 @@ interface CardCreationDeps {
getMpvClient: () => MpvClient;
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
getDeck?: () => string | undefined;
client: CardCreationClient;
@@ -705,7 +714,9 @@ export class CardCreationService {
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
if (shouldQueuePendingYoutubeMedia) {
this.deps.queuePendingYoutubeMediaUpdate?.({
sourceUrl: mpvClient.currentVideoPath,
sourceUrl:
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
mpvClient.currentVideoPath,
noteId,
startTime,
endTime,
@@ -32,7 +32,7 @@ export interface PendingYoutubeMediaQueueDeps {
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
>;
getConfig: () => AnkiConnectConfig;
getCurrentVideoPath: () => string | undefined;
getCurrentVideoPath: () => Promise<string | undefined> | string | undefined;
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
shouldRequireRemoteMediaCache: () => boolean;
getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
@@ -104,7 +104,7 @@ export class PendingYoutubeMediaQueue {
context?: SubtitleMiningContext;
label: string | number;
}): Promise<boolean> {
const sourceUrl = trimToNonEmptyString(this.deps.getCurrentVideoPath());
const sourceUrl = trimToNonEmptyString(await this.deps.getCurrentVideoPath());
const getCachedMediaPath = this.deps.getCachedMediaPath;
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
return false;
+2
View File
@@ -45,6 +45,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => (
@@ -114,6 +115,7 @@ export function registerAnkiJimakuIpcRuntime(
options.showOverlayNotification,
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl,
);
integration.start();
options.setAnkiIntegration(integration);
@@ -30,6 +30,7 @@ type CreateAnkiIntegrationArgs = {
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
};
export type OverlayWindowTrackerOptions = {
@@ -72,6 +73,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.showOverlayNotification,
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
args.getYoutubeMediaSourceUrl,
);
}
@@ -144,6 +146,7 @@ export function initializeOverlayRuntime(
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null;
@@ -183,6 +186,7 @@ export function initializeOverlayAnkiIntegration(options: {
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): boolean {
@@ -221,6 +225,9 @@ export function initializeOverlayAnkiIntegration(options: {
...(options.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
: {}),
...(options.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: options.getYoutubeMediaSourceUrl }
: {}),
});
if (options.shouldStartAnkiIntegration?.() !== false) {
integration.start();
+50 -7
View File
@@ -348,6 +348,7 @@ import {
shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
import { createYoutubeMediaCachePlaybackRuntime } from './main/runtime/youtube-media-cache-playback';
import { createYoutubePlaybackRuntime } from './main/runtime/youtube-playback-runtime';
import {
clearYoutubePrimarySubtitleNotificationTimer,
@@ -533,7 +534,6 @@ import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runti
import {
isSameYoutubeMediaPath,
isYoutubeMediaPath,
isYoutubePlaybackActive,
shouldUseCachedYoutubeParsedCues,
} from './main/runtime/youtube-playback';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
@@ -1220,6 +1220,18 @@ const youtubeMediaCache = createYoutubeMediaCacheService({
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
});
const youtubeMediaCachePlaybackRuntime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => getResolvedConfig().youtube.mediaCache,
requestMpvProperty: async (name) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
startYoutubeMediaCache: (url, options) => {
youtubeMediaCache.start(url, options);
},
logWarn: (message) => logger.warn(message),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient,
now: () => Date.now(),
@@ -1683,10 +1695,35 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
});
function isYoutubePlaybackActiveNow(): boolean {
return isYoutubePlaybackActive(
appState.currentMediaPath,
appState.mpvClient?.currentVideoPath ?? null,
);
return Boolean(getCurrentYoutubeMediaCacheSourceUrlSnapshot());
}
function getCurrentYoutubeMediaCacheSourceUrlSnapshot(): string | null {
const currentMediaPath = appState.currentMediaPath?.trim() || null;
if (isYoutubeMediaPath(currentMediaPath)) {
return currentMediaPath;
}
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
if (isYoutubeMediaPath(currentVideoPath)) {
return currentVideoPath;
}
return youtubeMediaCachePlaybackRuntime.getActiveYoutubeSourceUrlSnapshot();
}
async function getCurrentYoutubeMediaCacheSourceUrl(): Promise<string | null> {
const currentMediaPath = appState.currentMediaPath?.trim() || null;
if (isYoutubeMediaPath(currentMediaPath)) {
return currentMediaPath;
}
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
if (isYoutubeMediaPath(currentVideoPath)) {
return currentVideoPath;
}
return await youtubeMediaCachePlaybackRuntime.getActiveYoutubeSourceUrl();
}
function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean {
@@ -1702,13 +1739,16 @@ async function getCachedYoutubeMediaPathForCurrentPlayback(
if (getResolvedConfig().youtube.mediaCache.mode !== 'background') {
return null;
}
if (!isYoutubePlaybackActiveNow()) {
const cacheSourceUrl = isYoutubeMediaPath(currentVideoPath)
? currentVideoPath
: await getCurrentYoutubeMediaCacheSourceUrl();
if (!cacheSourceUrl) {
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.getCachedMediaPath(cacheSourceUrl)) ??
(await youtubeMediaCache.getActiveCachedMediaPath())
);
}
@@ -4442,6 +4482,7 @@ const {
subtitlePrefetchRuntime.cancelPendingInit();
}
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
@@ -5815,6 +5856,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
showDesktopNotification,
showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
@@ -6294,6 +6336,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
},
+4
View File
@@ -128,6 +128,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache'];
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
@@ -323,6 +324,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
...(params.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache }
: {}),
...(params.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: params.getYoutubeMediaSourceUrl }
: {}),
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
+10
View File
@@ -50,6 +50,16 @@ test('media path changes clear rendered subtitle state without clearing same-you
);
});
test('media path changes start the YouTube media cache coordinator', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /youtubeMediaCachePlaybackRuntime\.handleMediaPathChange\(path\);/);
});
test('same media path updates do not reset autoplay ready fallback state', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -43,6 +43,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
getYoutubeMediaSourceUrl?: OverlayRuntimeOptionsMainDeps['getYoutubeMediaSourceUrl'];
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
@@ -83,6 +84,9 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
...(deps.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
: {}),
...(deps.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
: {}),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
@@ -42,6 +42,7 @@ type OverlayRuntimeOptions = {
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
@@ -81,6 +82,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
@@ -111,6 +113,9 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
...(deps.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
: {}),
...(deps.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
: {}),
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createYoutubeMediaCachePlaybackRuntime } from './youtube-media-cache-playback';
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
test('youtube media cache starts when mpv reports a youtube path in background mode', async () => {
const starts: Array<{ url: string; maxHeight: number }> = [];
const runtime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 480 }),
startYoutubeMediaCache: (url, options) => {
starts.push({ url, maxHeight: options.maxHeight ?? 0 });
},
logWarn: () => {},
});
await runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc123');
assert.deepEqual(starts, [
{
url: 'https://www.youtube.com/watch?v=abc123',
maxHeight: 480,
},
]);
});
test('youtube media cache starts from original playlist url when mpv path is a resolved stream', async () => {
const starts: string[] = [];
const propertyRequests: string[] = [];
const runtime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
requestMpvProperty: async (name) => {
propertyRequests.push(name);
if (name === 'playlist-playing-pos') {
return 0;
}
if (name === 'playlist') {
return [{ filename: 'https://www.youtube.com/watch?v=abc123' }];
}
return null;
},
startYoutubeMediaCache: (url) => {
starts.push(url);
},
logWarn: () => {},
});
await runtime.handleMediaPathChange(
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
);
assert.deepEqual(propertyRequests, ['playlist-playing-pos', 'playlist']);
assert.deepEqual(starts, ['https://www.youtube.com/watch?v=abc123']);
assert.equal(await runtime.getActiveYoutubeSourceUrl(), 'https://www.youtube.com/watch?v=abc123');
});
test('youtube media cache can recover playlist source from current playlist marker', async () => {
const starts: string[] = [];
const runtime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
requestMpvProperty: async (name) => {
if (name === 'playlist-playing-pos') {
return -1;
}
if (name === 'playlist') {
return [
{
filename: 'https://example.com/other.mp4',
},
{
filename: 'https://youtu.be/abc123',
current: true,
},
];
}
return null;
},
startYoutubeMediaCache: (url) => {
starts.push(url);
},
logWarn: () => {},
});
await runtime.handleMediaPathChange(
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
);
assert.deepEqual(starts, ['https://youtu.be/abc123']);
assert.equal(await runtime.getActiveYoutubeSourceUrl(), 'https://youtu.be/abc123');
});
test('youtube media source getter awaits in-flight playlist recovery', async () => {
const starts: string[] = [];
const playlist = createDeferred<unknown>();
const runtime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
requestMpvProperty: async (name) => {
if (name === 'playlist-playing-pos') {
return 0;
}
if (name === 'playlist') {
return await playlist.promise;
}
return null;
},
startYoutubeMediaCache: (url) => {
starts.push(url);
},
logWarn: () => {},
});
const pathChange = runtime.handleMediaPathChange(
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
);
const sourceUrl = runtime.getActiveYoutubeSourceUrl();
playlist.resolve([{ filename: 'https://www.youtube.com/watch?v=abc123' }]);
assert.equal(await sourceUrl, 'https://www.youtube.com/watch?v=abc123');
await pathChange;
assert.deepEqual(starts, ['https://www.youtube.com/watch?v=abc123']);
});
test('youtube media cache ignores non-youtube paths and direct mode', async () => {
const starts: string[] = [];
const propertyRequests: string[] = [];
let mode: 'direct' | 'background' = 'background';
const runtime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => ({ mode, maxHeight: 720 }),
requestMpvProperty: async (name) => {
propertyRequests.push(name);
return [
{
filename: 'https://www.youtube.com/watch?v=abc123',
current: true,
},
];
},
startYoutubeMediaCache: (url) => {
starts.push(url);
},
logWarn: () => {},
});
await runtime.handleMediaPathChange('/tmp/video.mkv');
mode = 'direct';
await runtime.handleMediaPathChange('https://youtu.be/abc123');
assert.deepEqual(propertyRequests, []);
assert.deepEqual(starts, []);
});
test('youtube media cache logs synchronous start failures from path changes', async () => {
const warnings: string[] = [];
const runtime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
startYoutubeMediaCache: () => {
throw new Error('yt-dlp missing');
},
logWarn: (message) => {
warnings.push(message);
},
});
await runtime.handleMediaPathChange('https://youtu.be/abc123');
assert.deepEqual(warnings, ['Failed to start YouTube media cache: yt-dlp missing']);
});
@@ -0,0 +1,179 @@
import type { YoutubeMediaCacheMode } from '../../types/integrations';
import { isYoutubeMediaPath } from './youtube-playback';
type PlaylistEntry = {
filename: string;
current: boolean;
playing: boolean;
};
export interface YoutubeMediaCachePlaybackRuntimeDeps {
getMediaCacheConfig: () => {
mode: YoutubeMediaCacheMode;
maxHeight?: number;
};
requestMpvProperty?: (name: string) => Promise<unknown>;
startYoutubeMediaCache: (
url: string,
options: {
mode: YoutubeMediaCacheMode;
maxHeight?: number;
},
) => void;
logWarn: (message: string) => void;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizePlaylistIndex(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
}
function isGoogleVideoStreamPath(mediaPath: string): boolean {
try {
const parsed = new URL(mediaPath);
const host = parsed.hostname.toLowerCase();
return host === 'googlevideo.com' || host.endsWith('.googlevideo.com');
} catch {
return false;
}
}
function normalizePlaylistEntries(raw: unknown): PlaylistEntry[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.map((entry) => {
const item = (entry ?? {}) as {
filename?: unknown;
current?: unknown;
playing?: unknown;
};
return {
filename: trimToNonEmptyString(item.filename) ?? '',
current: item.current === true,
playing: item.playing === true,
};
});
}
function resolvePlaylistEntry(
playlist: PlaylistEntry[],
playingPosValue: unknown,
): PlaylistEntry | null {
if (playlist.length === 0) {
return null;
}
const playingPos = normalizePlaylistIndex(playingPosValue);
if (playingPos !== null && playingPos < playlist.length) {
return playlist[playingPos] ?? null;
}
return playlist.find((entry) => entry.current || entry.playing) ?? null;
}
async function requestMpvPropertySafely(
deps: YoutubeMediaCachePlaybackRuntimeDeps,
name: string,
): Promise<unknown> {
if (!deps.requestMpvProperty) {
return null;
}
try {
return await deps.requestMpvProperty(name);
} catch {
return null;
}
}
async function resolveYoutubeSourceFromPlaylist(
deps: YoutubeMediaCachePlaybackRuntimeDeps,
): Promise<string | null> {
const [playingPosValue, playlistValue] = await Promise.all([
requestMpvPropertySafely(deps, 'playlist-playing-pos'),
requestMpvPropertySafely(deps, 'playlist'),
]);
const playlistEntry = resolvePlaylistEntry(
normalizePlaylistEntries(playlistValue),
playingPosValue,
);
return playlistEntry?.filename && isYoutubeMediaPath(playlistEntry.filename)
? playlistEntry.filename
: null;
}
async function resolveYoutubeSourceUrl(
deps: YoutubeMediaCachePlaybackRuntimeDeps,
mediaPath: string,
): Promise<string | null> {
const directPath = trimToNonEmptyString(mediaPath);
if (directPath && isYoutubeMediaPath(directPath)) {
return directPath;
}
if (!directPath || !isGoogleVideoStreamPath(directPath)) {
return null;
}
return await resolveYoutubeSourceFromPlaylist(deps);
}
export function createYoutubeMediaCachePlaybackRuntime(deps: YoutubeMediaCachePlaybackRuntimeDeps) {
let activeYoutubeSourceUrl: string | null = null;
let activeYoutubeSourceUrlPromise: Promise<string | null> | null = null;
let generation = 0;
const handleMediaPathChange = async (mediaPath: string): Promise<void> => {
const currentGeneration = ++generation;
const config = deps.getMediaCacheConfig();
if (config.mode !== 'background') {
activeYoutubeSourceUrl = null;
activeYoutubeSourceUrlPromise = Promise.resolve(null);
return;
}
if (!isYoutubeMediaPath(mediaPath)) {
activeYoutubeSourceUrl = null;
}
const sourceUrlPromise = resolveYoutubeSourceUrl(deps, mediaPath);
activeYoutubeSourceUrlPromise = sourceUrlPromise.then((sourceUrl) =>
currentGeneration === generation ? sourceUrl : null,
);
const sourceUrl = await sourceUrlPromise;
if (currentGeneration !== generation) {
return;
}
activeYoutubeSourceUrl = sourceUrl;
if (!sourceUrl) {
return;
}
try {
deps.startYoutubeMediaCache(sourceUrl, {
mode: config.mode,
maxHeight: config.maxHeight,
});
} catch (error) {
deps.logWarn(
`Failed to start YouTube media cache: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
return {
getActiveYoutubeSourceUrl: async (): Promise<string | null> =>
activeYoutubeSourceUrlPromise ?? activeYoutubeSourceUrl,
getActiveYoutubeSourceUrlSnapshot: (): string | null => activeYoutubeSourceUrl,
handleMediaPathChange,
};
}