import { join } from 'node:path'; import { mkdir } from 'node:fs/promises'; import { DATA_PATH } from '@@/data-path'; // The Whisper connection — a single URL, kept in a JSON file rather than a database row. // // There is only ever one whisper.cpp server for the whole install: unlike `photos_config` or // `jellyfin_servers`, nobody has a reason to keep more than one on hand, so a file the owner can also // edit by hand (or drop in with a provisioning script) is simpler than a table that will only ever hold // one row. See `plugins/music/sidecar/indexer.ts` for the same `DATA_PATH//` convention. // // Not committed anywhere: this file lives under DATA_PATH, outside both the platform's and this plugin's // git repositories. Config is data, not definition. const CONFIG_DIR = join(DATA_PATH, 'transcribe'); const CONFIG_PATH = join(CONFIG_DIR, 'config.json'); export type TranscribeConfig = { url: string; /** The torch stage worker (diarization + conversation probe). Optional: without it the plugin * still transcribes, it just never labels speakers — degradation, not failure. */ workerUrl?: string; }; // Cached across requests; invalidated only by `setConfig`. A single small file re-read on every // transcription would be a needless stat + parse on the hot path. let cache: TranscribeConfig | null | undefined; async function loadConfig(): Promise { const file = Bun.file(CONFIG_PATH); if (!(await file.exists())) return null; try { const parsed = (await file.json()) as Partial; if (typeof parsed.url !== 'string' || !parsed.url) return null; const workerUrl = typeof parsed.workerUrl === 'string' && parsed.workerUrl ? parsed.workerUrl : undefined; return { url: parsed.url, ...(workerUrl ? { workerUrl } : {}) }; } catch { return null; } } export async function getConfig(): Promise { if (cache === undefined) cache = await loadConfig(); return cache; } export async function setConfig(config: TranscribeConfig): Promise { await mkdir(CONFIG_DIR, { recursive: true }); await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2)); cache = config; } /** * The URL as the browser is allowed to see it. * * A whisper.cpp URL is never expected to carry credentials — the server has no auth of its own — but if * an owner points this at something behind a reverse proxy with basic auth baked into the URL, userinfo * (`user:pass@`) must not round-trip back out to `GET /config`. Stripped rather than trusted to stay * absent. */ export function redactUrl(url: string): string { try { const parsed = new URL(url); parsed.username = ''; parsed.password = ''; return parsed.toString(); } catch { return url; } } export type ProbeResult = { ok: true } | { ok: false; error: string }; /** * Ask a whisper.cpp server whether it is really there. * * Mirrors the platform's old `POST /server-settings/stt/test`: an empty multipart body to `/inference` * always fails on the Whisper side, but ANY response — including its error — means the server exists and * speaks the expected protocol. A network-level failure (wrong host, nothing listening, timeout) is the * only thing that means "not reachable". */ export async function probeWhisper(url: string): Promise { const base = url.replace(/\/+$/, ''); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { await fetch(`${base}/inference`, { method: 'POST', body: new FormData(), signal: controller.signal }); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'could not reach the server' }; } finally { clearTimeout(timeout); } }