Files
SubMiner/src/anki-integration/runtime.ts
sudacode 5feed360ca 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
2026-03-24 00:01:24 -07:00

301 lines
9.3 KiB
TypeScript

import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
import {
getKnownWordCacheLifecycleConfig,
getKnownWordCacheRefreshIntervalMinutes,
getKnownWordCacheScopeForConfig,
} from './known-word-cache';
export interface AnkiIntegrationRuntimeProxyServer {
start(options: { host: string; port: number; upstreamUrl: string }): void;
stop(): void;
waitUntilReady(): Promise<void>;
}
interface AnkiIntegrationRuntimeDeps {
initialConfig: AnkiConnectConfig;
pollingRunner: {
start(): void;
stop(): void;
};
knownWordCache: {
startLifecycle(): void;
stopLifecycle(): void;
clearKnownWordCacheState(): void;
};
proxyServerFactory: () => AnkiIntegrationRuntimeProxyServer;
logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
onConfigChanged?: (config: AnkiConnectConfig) => void;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeAnkiAiConfig(
config: AnkiConnectConfig['ai'],
): NonNullable<AnkiConnectConfig['ai']> {
if (config && typeof config === 'object') {
return {
enabled: config.enabled === true,
model: trimToNonEmptyString(config.model) ?? '',
systemPrompt: trimToNonEmptyString(config.systemPrompt) ?? '',
};
}
return {
enabled: config === true,
model: '',
systemPrompt: '',
};
}
export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiConnectConfig {
const resolvedUrl = trimToNonEmptyString(config.url) ?? DEFAULT_ANKI_CONNECT_CONFIG.url;
const proxySource =
config.proxy && typeof config.proxy === 'object'
? (config.proxy as NonNullable<AnkiConnectConfig['proxy']>)
: {};
const normalizedProxyPort =
typeof proxySource.port === 'number' &&
Number.isInteger(proxySource.port) &&
proxySource.port >= 1 &&
proxySource.port <= 65535
? proxySource.port
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.port;
const normalizedProxyHost =
trimToNonEmptyString(proxySource.host) ?? DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host;
const normalizedProxyUpstreamUrl = trimToNonEmptyString(proxySource.upstreamUrl) ?? resolvedUrl;
return {
...DEFAULT_ANKI_CONNECT_CONFIG,
...config,
url: resolvedUrl,
fields: {
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
...(config.fields ?? {}),
},
proxy: {
...DEFAULT_ANKI_CONNECT_CONFIG.proxy,
...(config.proxy ?? {}),
enabled: proxySource.enabled === true,
host: normalizedProxyHost,
port: normalizedProxyPort,
upstreamUrl: normalizedProxyUpstreamUrl,
},
ai: normalizeAnkiAiConfig(config.ai),
media: {
...DEFAULT_ANKI_CONNECT_CONFIG.media,
...(config.media ?? {}),
},
knownWords: {
...DEFAULT_ANKI_CONNECT_CONFIG.knownWords,
...(config.knownWords ?? {}),
},
nPlusOne: {
...DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne,
...(config.nPlusOne ?? {}),
},
behavior: {
...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
...(config.behavior ?? {}),
},
metadata: {
...DEFAULT_ANKI_CONNECT_CONFIG.metadata,
...(config.metadata ?? {}),
},
isLapis: {
...DEFAULT_ANKI_CONNECT_CONFIG.isLapis,
...(config.isLapis ?? {}),
},
isKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}),
},
} as AnkiConnectConfig;
}
export class AnkiIntegrationRuntime {
private config: AnkiConnectConfig;
private proxyServer: AnkiIntegrationRuntimeProxyServer | null = null;
private started = false;
constructor(private readonly deps: AnkiIntegrationRuntimeDeps) {
this.config = normalizeAnkiIntegrationConfig(deps.initialConfig);
}
getConfig(): AnkiConnectConfig {
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();
}
this.deps.knownWordCache.startLifecycle();
this.startTransport();
this.started = true;
}
stop(): void {
this.stopTransport();
this.deps.knownWordCache.stopLifecycle();
this.started = false;
this.deps.logInfo('Stopped AnkiConnect integration');
}
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
? this.getKnownWordCacheLifecycleConfig(this.config)
: null;
const previousTransportKey = this.getTransportConfigKey(this.config);
const mergedConfig: AnkiConnectConfig = {
...this.config,
...patch,
knownWords:
patch.knownWords !== undefined
? {
...(this.config.knownWords ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords),
...patch.knownWords,
}
: this.config.knownWords,
nPlusOne:
patch.nPlusOne !== undefined
? {
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
...patch.nPlusOne,
}
: this.config.nPlusOne,
fields:
patch.fields !== undefined
? { ...this.config.fields, ...patch.fields }
: this.config.fields,
media:
patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media,
behavior:
patch.behavior !== undefined
? { ...this.config.behavior, ...patch.behavior }
: this.config.behavior,
proxy:
patch.proxy !== undefined ? { ...this.config.proxy, ...patch.proxy } : this.config.proxy,
metadata:
patch.metadata !== undefined
? { ...this.config.metadata, ...patch.metadata }
: this.config.metadata,
isLapis:
patch.isLapis !== undefined
? { ...this.config.isLapis, ...patch.isLapis }
: this.config.isLapis,
isKiku:
patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku,
};
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config);
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
if (this.started) {
this.deps.knownWordCache.stopLifecycle();
}
this.deps.knownWordCache.clearKnownWordCacheState();
} else if (this.started && !wasKnownWordCacheEnabled && nextKnownWordCacheEnabled) {
this.deps.knownWordCache.startLifecycle();
} else if (
this.started &&
wasKnownWordCacheEnabled &&
nextKnownWordCacheEnabled &&
previousKnownWordCacheConfig !== null &&
previousKnownWordCacheConfig !== this.getKnownWordCacheLifecycleConfig(this.config)
) {
this.deps.knownWordCache.startLifecycle();
}
const nextTransportKey = this.getTransportConfigKey(this.config);
if (this.started && previousTransportKey !== nextTransportKey) {
this.stopTransport();
this.startTransport();
}
}
private getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
return getKnownWordCacheLifecycleConfig(config);
}
private getKnownWordRefreshIntervalMinutes(config: AnkiConnectConfig): number {
return getKnownWordCacheRefreshIntervalMinutes(config);
}
private getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string {
return getKnownWordCacheScopeForConfig(config);
}
getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer {
if (!this.proxyServer) {
this.proxyServer = this.deps.proxyServerFactory();
}
return this.proxyServer;
}
private isProxyTransportEnabled(config: AnkiConnectConfig = this.config): boolean {
return config.proxy?.enabled === true;
}
private getTransportConfigKey(config: AnkiConnectConfig = this.config): string {
if (this.isProxyTransportEnabled(config)) {
return [
'proxy',
config.proxy?.host ?? '',
String(config.proxy?.port ?? ''),
config.proxy?.upstreamUrl ?? '',
].join(':');
}
return ['polling', String(config.pollingRate ?? DEFAULT_ANKI_CONNECT_CONFIG.pollingRate)].join(
':',
);
}
private startTransport(): void {
if (this.isProxyTransportEnabled()) {
const proxyHost = this.config.proxy?.host ?? '127.0.0.1';
const proxyPort = this.config.proxy?.port ?? 8766;
const upstreamUrl = this.config.proxy?.upstreamUrl ?? this.config.url ?? '';
this.getOrCreateProxyServer().start({
host: proxyHost,
port: proxyPort,
upstreamUrl,
});
this.deps.logInfo(
`Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`,
);
return;
}
this.deps.logInfo(
'Starting AnkiConnect integration with polling rate:',
this.config.pollingRate,
);
this.deps.pollingRunner.start();
}
private stopTransport(): void {
this.deps.pollingRunner.stop();
this.proxyServer?.stop();
}
}