mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-24 00:11:27 -07:00
* 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
301 lines
9.3 KiB
TypeScript
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();
|
|
}
|
|
}
|