feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)

* fix: harden preload argv parsing for popup windows

* fix: align youtube playback with shared overlay startup

* fix: unwrap mpv youtube streams for anki media mining

* docs: update docs for youtube subtitle and mining flow

* refactor: unify cli and runtime wiring for startup and youtube flow

* feat: update subtitle sidebar overlay behavior

* chore: add shared log-file source for diagnostics

* fix(ci): add changelog fragment for immersion changes

* fix: address CodeRabbit review feedback

* fix: persist canonical title from youtube metadata

* style: format stats library tab

* fix: address latest review feedback

* style: format stats library files

* test: stub launcher youtube deps in CI

* test: isolate launcher youtube flow deps

* test: stub launcher youtube deps in failing case

* test: force x11 backend in launcher ci harness

* test: address latest review feedback

* fix(launcher): preserve user YouTube ytdl raw options

* docs(backlog): update task tracking notes

* fix(immersion): special-case youtube media paths in runtime and tracking

* feat(stats): improve YouTube media metadata and picker key handling

* fix(ci): format stats media library hook

* fix: address latest CodeRabbit review items

* docs: update youtube release notes and docs

* feat: auto-load youtube subtitles before manual picker

* fix: restore app-owned youtube subtitle flow

* docs: update youtube playback docs and config copy

* refactor: remove legacy youtube launcher mode plumbing

* fix: refine youtube subtitle startup binding

* docs: clarify youtube subtitle startup behavior

* fix: address PR #31 latest review follow-ups

* fix: address PR #31 follow-up review comments

* test: harden youtube picker test harness

* udpate backlog

* fix: add timeout to youtube metadata probe

* docs: refresh youtube and stats docs

* update backlog

* update backlog

* chore: release v0.9.0
This commit is contained in:
2026-03-24 00:01:24 -07:00
committed by GitHub
parent c17f0a4080
commit 5feed360ca
219 changed files with 12778 additions and 1052 deletions

View File

@@ -35,6 +35,9 @@ export class AnkiConnectProxyServer {
private pendingNoteIdSet = new Set<number>();
private inFlightNoteIds = new Set<number>();
private processingQueue = false;
private readyPromise: Promise<void> | null = null;
private resolveReady: (() => void) | null = null;
private rejectReady: ((error: Error) => void) | null = null;
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
this.client = axios.create({
@@ -48,6 +51,13 @@ export class AnkiConnectProxyServer {
return this.server !== null;
}
waitUntilReady(): Promise<void> {
if (!this.server || this.server.listening) {
return Promise.resolve();
}
return this.readyPromise ?? Promise.resolve();
}
start(options: StartProxyOptions): void {
this.stop();
@@ -58,15 +68,26 @@ export class AnkiConnectProxyServer {
return;
}
this.readyPromise = new Promise<void>((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.server = http.createServer((req, res) => {
void this.handleRequest(req, res, options.upstreamUrl);
});
this.server.on('error', (error) => {
this.rejectReady?.(error as Error);
this.resolveReady = null;
this.rejectReady = null;
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
});
this.server.listen(options.port, options.host, () => {
this.resolveReady?.();
this.resolveReady = null;
this.rejectReady = null;
this.deps.logInfo(
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
);
@@ -79,6 +100,10 @@ export class AnkiConnectProxyServer {
this.server = null;
this.deps.logInfo('[anki-proxy] Stopped');
}
this.rejectReady?.(new Error('AnkiConnect proxy stopped before becoming ready'));
this.readyPromise = null;
this.resolveReady = null;
this.rejectReady = null;
this.pendingNoteIds = [];
this.pendingNoteIdSet.clear();
this.inFlightNoteIds.clear();

View File

@@ -283,3 +283,117 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
assert.equal(calls.notesInfo, 1);
assert.equal(calls.updateNoteFields, 1);
});
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
const audioPaths: string[] = [];
const imagePaths: string[] = [];
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',
'!global_tags,title=test',
].join(';');
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
},
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: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
requestProperty: async (name: string) => {
assert.equal(name, 'stream-open-filename');
return edlSource;
},
}) as never,
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) => {
audioPaths.push(path);
return Buffer.from('audio');
},
generateScreenshot: async (path) => {
imagePaths.push(path);
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('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
});

View File

@@ -8,6 +8,7 @@ import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { MpvClient } from '../types';
import { resolveSentenceBackText } from './ai';
import { resolveMediaGenerationInputPath } from './media-source';
const log = createLogger('anki').child('integration.card-creation');
@@ -501,7 +502,12 @@ export class CardCreationService {
this.deps.showOsdNotification('Creating sentence card...');
try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = mpvClient.currentVideoPath;
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
if (!videoPath) {
this.deps.showOsdNotification('No video loaded');
return false;
}
const fields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
@@ -605,7 +611,9 @@ export class CardCreationService {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime);
const audioBuffer = audioSourcePath
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
: null;
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveMediaGenerationInputPath } from './media-source';
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
const result = await resolveMediaGenerationInputPath({
currentVideoPath: '/tmp/video.mkv',
});
assert.equal(result, '/tmp/video.mkv');
});
test('resolveMediaGenerationInputPath prefers stream-open-filename for remote media', async () => {
const requests: string[] = [];
const result = await resolveMediaGenerationInputPath({
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async (name: string) => {
requests.push(name);
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
},
});
assert.equal(result, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
assert.deepEqual(requests, ['stream-open-filename']);
});
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
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',
'!global_tags,title=test',
].join(';');
const audioResult = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'audio',
);
const videoResult = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'video',
);
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
});
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
const result = await resolveMediaGenerationInputPath({
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => {
throw new Error('property unavailable');
},
});
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
});

View File

@@ -0,0 +1,84 @@
import { isRemoteMediaPath } from '../jimaku/utils';
import type { MpvClient } from '../types';
export type MediaGenerationKind = 'audio' | 'video';
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
return [...matches]
.map((match) => trimToNonEmptyString(match[1]))
.filter((value): value is string => value !== null);
}
function classifyMediaUrl(url: string): MediaGenerationKind | null {
try {
const mime = new URL(url).searchParams.get('mime')?.toLowerCase() ?? '';
if (mime.startsWith('audio/')) {
return 'audio';
}
if (mime.startsWith('video/')) {
return 'video';
}
} catch {
// Ignore malformed URLs and fall back to stream order.
}
return null;
}
function resolvePreferredUrlFromMpvEdlSource(
source: string,
kind: MediaGenerationKind,
): string | null {
const urls = extractUrlsFromMpvEdlSource(source);
if (urls.length === 0) {
return null;
}
const typedMatch = urls.find((url) => classifyMediaUrl(url) === kind);
if (typedMatch) {
return typedMatch;
}
// mpv EDL sources usually list audio streams first and video streams last, so
// when classifyMediaUrl cannot identify a typed URL we fall back to stream order.
return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null;
}
export async function resolveMediaGenerationInputPath(
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
kind: MediaGenerationKind = 'video',
): Promise<string | null> {
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
if (!currentVideoPath) {
return null;
}
if (!isRemoteMediaPath(currentVideoPath) || !mpvClient?.requestProperty) {
return currentVideoPath;
}
try {
const streamOpenFilename = trimToNonEmptyString(
await mpvClient.requestProperty('stream-open-filename'),
);
if (streamOpenFilename?.startsWith('edl://')) {
return resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind) ?? streamOpenFilename;
}
if (streamOpenFilename) {
return streamOpenFilename;
}
} catch {
// Fall back to the current path when mpv does not expose a resolved stream URL.
}
return currentVideoPath;
}

View File

@@ -26,6 +26,7 @@ function createRuntime(
start: ({ host, port, upstreamUrl }) =>
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
stop: () => calls.push('proxy:stop'),
waitUntilReady: async () => undefined,
}),
logInfo: () => undefined,
logWarn: () => undefined,
@@ -80,6 +81,44 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled',
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
});
test('AnkiIntegrationRuntime waits for proxy readiness when proxy mode is enabled', async () => {
let releaseReady!: () => void;
const waitUntilReadyCalls: string[] = [];
const readyPromise = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { runtime } = createRuntime(
{
proxy: {
enabled: true,
host: '127.0.0.1',
port: 9999,
upstreamUrl: 'http://upstream:8765',
},
},
{
proxyServerFactory: () => ({
start: () => undefined,
stop: () => undefined,
waitUntilReady: async () => {
waitUntilReadyCalls.push('proxy:wait-until-ready');
await readyPromise;
},
}),
},
);
runtime.start();
const waitPromise = runtime.waitUntilReady().then(() => {
waitUntilReadyCalls.push('proxy:ready');
});
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready']);
releaseReady();
await waitPromise;
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready', 'proxy:ready']);
});
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
const { runtime, calls } = createRuntime({
knownWords: {

View File

@@ -9,6 +9,7 @@ import {
export interface AnkiIntegrationRuntimeProxyServer {
start(options: { host: string; port: number; upstreamUrl: string }): void;
stop(): void;
waitUntilReady(): Promise<void>;
}
interface AnkiIntegrationRuntimeDeps {
@@ -131,6 +132,13 @@ export class AnkiIntegrationRuntime {
return this.config;
}
waitUntilReady(): Promise<void> {
if (!this.started || !this.isProxyTransportEnabled()) {
return Promise.resolve();
}
return this.getOrCreateProxyServer().waitUntilReady();
}
start(): void {
if (this.started) {
this.stop();