mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
64
src/anki-integration/media-source.test.ts
Normal file
64
src/anki-integration/media-source.test.ts
Normal 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');
|
||||
});
|
||||
84
src/anki-integration/media-source.ts
Normal file
84
src/anki-integration/media-source.ts
Normal 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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user