diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 2070e31d..8e249cd1 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -271,7 +271,15 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete live in `screens/scenes.mjs`, the instance calls in `screens/instance.mjs`, and the contact sheet in `screens/sheet.mjs`. - `.claude/skills/verify/fixtures.mjs` runs as `feed`, `feed-b`, `transcripts`, - `private`, `library`, `retitle`, `requests`, `audio [vault path]` or `stop`. + `private`, `library`, `retitle`, `requests`, `server`, `transcriptions`, + `audio [vault path]` or `stop`. `server` prints the address of an + OpenAI-compatible transcription server on the same port, for the + "OpenAI-compatible server" provider's Server address. It answers + `POST /v1/audio/transcriptions` with verbose_json words, two sentences 12 s + apart, whatever the audio, and json's text when asked for json. The model + `json-only` refuses verbose_json with a 400, as OpenAI's gpt-transcribe does. + `transcriptions` prints each request it answered: its Authorization header, + model, response format, timestamp granularities, file type and size. `private` prints a feed at `priv3.supercast.com`, an address PodNotes keeps in SecretStorage as it does a Supercast subscriber's. `library` prints the URL of a manifest of eight podcasts, one whose feed answers 404, and diff --git a/.claude/skills/verify/features/transcripts.md b/.claude/skills/verify/features/transcripts.md index bdd46de4..a63dda42 100644 --- a/.claude/skills/verify/features/transcripts.md +++ b/.claude/skills/verify/features/transcripts.md @@ -20,6 +20,7 @@ from `Transcript template`, and opens. - `transcript-deepgram` does the same with `nova-3`. - `transcript-openrouter` offers `Grok STT 2.0` (recommended), `ElevenLabs Scribe v2`, `MAI-Transcribe 2`, `Voxtral Mini Transcribe 3` and `Qwen3 ASR 1.7B`, filtered by OpenRouter's public model catalog. Grok gets uploads as base64 JSON routed to xAI, Scribe v2 the address of a public episode, and an upload goes in parts of at most 18 MB and 1,800 s, or 12 MB and 900 s for Voxtral and Qwen3 ASR, which label no speakers. - `transcript-openai` sends OpenAI the audio as `gpt-transcribe` uploads of at most 24 MB and 2,070 s, one at a time, and writes paragraphs without timestamps or speaker labels. +- `transcript-openai-compatible` sends the audio to the address under `Server address`, with the model typed under `Model` and the key only when one is selected. It asks for verbose_json with word and segment times, and once the server refuses that, asks for json for that part and every later one. It writes timed paragraphs from words, punctuated from the answer's text when the words carry none, or from segments, and untimed ones from text. A server on this machine or a private network is reached. - `transcript-existing` refuses a second run while the note exists. ## How to get to it (user POV) @@ -59,7 +60,11 @@ Preconditions: - **OpenAI.** Choose `OpenAI` with its fake key. `Untranscribed Episode` downloads its WAV and `$NET log` shows a `POST https://api.openai.com/v1/audio/transcriptions` answered by the fixture provider. The note holds the fixture text with no timestamps. - In parts. Write an MP3 longer than 2,070 s into the vault, for example 70 min in 4 MB: `ffmpeg -f lavfi -i sine=frequency=440:duration=4200 -ac 1 -ar 8000 -b:a 8k "$VAULT/Fixtures/Long Fixture.mp3"`. Play it ([local-files.md](./local-files.md)) and run the audio command. `$NET log` shows three `POST https://api.openai.com/v1/audio/transcriptions` entries of about 1.4 MB each, one after the other, and the note holds the fixture text three times with no timestamps. - Speaker labels. With `Speaker labels` on, as in a library that labelled speakers with OpenAI on 2.24.0, the progress notice ends with `Saved without speaker labels, because GPT Transcribe does not label speakers. These providers do: ElevenLabs, Deepgram, OpenRouter.` and stays until clicked. -- **A 2.24.0 library.** Write a 2.24.0 `data.json` state into the vault (`transcript.diarization` with `enabled` and `provider`, no `transcript.provider`), run `CLI --reload`, and read `app.plugins.plugins.podnotes.settings.transcript.provider`: `deepgram` for speaker labels with Deepgram, `openai` for speaker labels with OpenAI or a linked OpenAI key, `elevenlabs` otherwise. `data.json` is unchanged until the next save, which adds `transcript.provider`, `transcript.model`, `elevenLabsSecretId` and `openRouterSecretId` and leaves `transcript.diarization.provider` as it was. +- **OpenAI-compatible server.** `SERVER=$(node .claude/skills/verify/fixtures.mjs server)` prints `http://podnotes.test:/v1`, a transcription route on the fixture server, which the instance reaches at 127.0.0.1 over the real network. `net.mjs` does not answer it. Choose `OpenAI-compatible server`: the rows read `Transcription provider`, `Server address`, `OpenAI-compatible server API key`, `Model` as a text field, then the shared rows, and `Speaker labels` reads `OpenAI-compatible server does not label speakers.` Fill `input[name="Server address"]` with `$SERVER` and `input[name="Model"]` with any model ID. An address without `http://` or `https://` shows `The address must start with http:// or https://.` under the field. + - Local file. Play an MP3 in the vault and run the audio command with no key. The note has `[00:00:00]` `This text came from the fixture server.` and `[00:00:12]` `A second sentence starts twelve seconds in.`, and `fixtures.mjs transcriptions` shows one request with `authorization` null, the model, `verbose_json`, `["word","segment"]` and the file's type and size. + - json only. With the model `json-only`, `fixtures.mjs transcriptions` shows a `verbose_json` request, then a `json` one with no granularities, and the note holds the text without timestamps. For an MP3 over 24 MB, which goes in two parts, such as `ffmpeg -f lavfi -i sine=frequency=440:duration=1900 -ac 1 -b:a 128k "$VAULT/Fixtures/Big Fixture.mp3"`, the requests read `verbose_json`, `json`, `json`, and the second `json` carries the other part's bytes. + - Refusals. An empty `Model` gives `Enter a model for your OpenAI-compatible server in the transcript settings.` and an empty or non-http(s) address `Enter the address of your OpenAI-compatible server, starting with http:// or https://, in the transcript settings.`, with no request. A port nothing listens on gives `Transcription failed: PodNotes could not reach the server at podnotes.test:. Check the address in the transcript settings.` after three sends. +- **A 2.24.0 library.** Write a 2.24.0 `data.json` state into the vault (`transcript.diarization` with `enabled` and `provider`, no `transcript.provider`), run `CLI --reload`, and read `app.plugins.plugins.podnotes.settings.transcript.provider`: `deepgram` for speaker labels with Deepgram, `openai` for speaker labels with OpenAI or a linked OpenAI key, `elevenlabs` otherwise. `data.json` is unchanged until the next save, which adds `transcript.provider`, `transcript.model`, `transcript.serverUrl`, `elevenLabsSecretId`, `openRouterSecretId` and `openAICompatibleSecretId` and leaves `transcript.diarization.provider` as it was. - **Proof.** Screenshot the opened note (`npm run capture:e2e-obsidian -- screenshot .obsidian-e2e-artifacts/transcripts/note.png`), keep the note text, `$NET log` and `fixtures.mjs requests` for every run, and read `data.json` to show no new field. ## Gotchas @@ -68,7 +73,7 @@ Preconditions: - To test the feed cache across a restart, reload inside Obsidian (`CLI --reload`, or `app:reload`). `npm run stop:e2e-obsidian` deletes the profile, and the cache with it. - `net.mjs` sees `window.fetch` and `requestUrl`. The player streams audio through the media element, which only `fixtures.mjs requests` shows. -- A secret id must be `podnotes--api-key` or `podnotes--api-key-`, where the provider is `elevenlabs`, `deepgram`, `openrouter` or `openai`. Any other id reads as missing on this device. +- A secret id must be `podnotes--api-key` or `podnotes--api-key-`, where the provider is `elevenlabs`, `deepgram`, `openrouter`, `openai` or `openai-compatible`. Any other id reads as missing on this device. - The progress notice hides about 5 s after a transcript is saved. A failure, or a transcript saved with a warning, stays until clicked. Notices also cover the PodNotes view's head and the full player's collapse button, and `ui.mjs` refuses a covered click, so dismiss them first. - After editing `fixtures.mjs`, run `fixtures.mjs stop` before `fixtures.mjs transcripts`. A running server keeps serving the old routes. - Every fixture transcript cue falls inside the 60 s WAV, so its links seek within the audio. diff --git a/.claude/skills/verify/fixtures.mjs b/.claude/skills/verify/fixtures.mjs index 230ddd22..37e75b1a 100755 --- a/.claude/skills/verify/fixtures.mjs +++ b/.claude/skills/verify/fixtures.mjs @@ -16,6 +16,11 @@ // episode whose media answers 404 // node .claude/skills/verify/fixtures.mjs retitle rename the first feed's episodes, keeping their GUIDs, until the server stops // node .claude/skills/verify/fixtures.mjs requests print the paths the server has answered, oldest first +// node .claude/skills/verify/fixtures.mjs server same server, print the address of its OpenAI-compatible +// transcription route, which answers verbose_json. The model +// "json-only" refuses verbose_json with a 400, as +// gpt-transcribe does, and answers json +// node .claude/skills/verify/fixtures.mjs transcriptions print each request the transcription route answered, as JSON // node .claude/skills/verify/fixtures.mjs audio [path] write a 60 s WAV into this worktree's vault // (default path: "Fixtures/Local Fixture.wav") // node .claude/skills/verify/fixtures.mjs stop stop the feed server @@ -41,6 +46,13 @@ const transcriptsUrl = `${base}/transcripts.xml`; const PRIVATE_ROUTE = "/feeds/verify-subscriber-token/feed.xml"; const privateUrl = `http://${SUPERCAST_FIXTURE_HOST}:${port}${PRIVATE_ROUTE}`; const libraryUrl = `${base}/library.json`; +const serverUrl = `${base}/v1`; +const TRANSCRIPTIONS_ROUTE = "/v1/audio/transcriptions"; +const JSON_ONLY_MODEL = "json-only"; +const SPOKEN = [ + "This text came from the fixture server.", + "A second sentence starts twelve seconds in.", +]; const local = `http://127.0.0.1:${port}`; const FEED_TITLE = "PodNotes Fixture"; const FEED_B_TITLE = "PodNotes Fixture B"; @@ -538,6 +550,75 @@ function libraryManifest() { }; } +/** + * Answers the OpenAI-compatible transcription route as a Whisper server does: verbose_json + * with a word every 0.4 s and the second sentence 12 s in, or json's text. + * @param {http.IncomingMessage} request @param {http.ServerResponse} response + * @param {object[]} transcriptions + */ +async function answerTranscription(request, response, transcriptions) { + /** @type {Buffer[]} */ + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const form = await new Request(local, { + method: "POST", + headers: { "content-type": request.headers["content-type"] ?? "" }, + body: Buffer.concat(chunks), + }).formData(); + const file = form.get("file"); + const model = form.get("model"); + const format = form.get("response_format"); + transcriptions.push({ + authorization: request.headers.authorization ?? null, + model, + responseFormat: format, + timestampGranularities: form.getAll("timestamp_granularities[]"), + fileType: file instanceof Blob ? file.type : null, + fileBytes: file instanceof Blob ? file.size : 0, + }); + /** @param {number} status @param {unknown} body */ + const answer = (status, body) => + response + .writeHead(status, { "Content-Type": "application/json" }) + .end(JSON.stringify(body)); + if (model === JSON_ONLY_MODEL && format === "verbose_json") { + answer(400, { + error: { + message: `response_format 'verbose_json' is not compatible with model '${JSON_ONLY_MODEL}'. Use 'json' or 'text' instead.`, + type: "invalid_request_error", + param: "response_format", + code: "unsupported_value", + }, + }); + return; + } + const text = SPOKEN.join(" "); + if (format !== "verbose_json") { + answer(200, { text }); + return; + } + const timed = SPOKEN.map((sentence, index) => + sentence.split(" ").map((word, at) => ({ + word, + start: index * 12 + at * 0.4, + end: index * 12 + at * 0.4 + 0.3, + })), + ); + answer(200, { + task: "transcribe", + language: "english", + duration: 15, + text, + words: timed.flat(), + segments: timed.map((words, id) => ({ + id, + start: words[0].start, + end: words[words.length - 1].end, + text: ` ${SPOKEN[id]}`, + })), + }); +} + function serve() { const audio = wav(); /** @type {Record} */ @@ -614,6 +695,8 @@ function serve() { } /** @type {string[]} */ const requests = []; + /** @type {object[]} */ + const transcriptions = []; const server = http.createServer(async (request, response) => { if (request.method === "POST" && request.url === "/__retitle") { routes["/feed.xml"][1] = Buffer.from(feedXml(audio.length, FEED_TITLE, "", true)); @@ -630,8 +713,17 @@ function serve() { response.end(JSON.stringify(requests)); return; } + if (request.url === "/__transcriptions") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(transcriptions)); + return; + } const fromPodNotes = request.headers.host?.startsWith(host); if (fromPodNotes) requests.push(`${request.method} ${request.url}`); + if (request.method === "POST" && request.url === TRANSCRIPTIONS_ROUTE) { + await answerTranscription(request, response, transcriptions); + return; + } const url = request.url ?? ""; let route = routes[url]; if (!route && videos[url]) { @@ -737,6 +829,15 @@ async function printRequests() { for (const line of /** @type {string[]} */ (await response.json())) console.log(line); } +async function printTranscriptions() { + const response = await fetch(`${local}/__transcriptions`, { + signal: AbortSignal.timeout(1000), + }); + for (const entry of /** @type {object[]} */ (await response.json())) { + console.log(JSON.stringify(entry)); + } +} + async function retitleFeed() { const response = await fetch(`${local}/__retitle`, { method: "POST", @@ -764,11 +865,13 @@ try { else if (verb === "library") await startFeed(libraryUrl); else if (verb === "retitle") await retitleFeed(); else if (verb === "requests") await printRequests(); + else if (verb === "server") await startFeed(serverUrl); + else if (verb === "transcriptions") await printTranscriptions(); else if (verb === "audio") await writeAudio(arg); else if (verb === "stop") await stopFeed(); else throw new Error( - "Usage: fixtures.mjs feed | feed-b | transcripts | private | library | retitle | requests | audio [vault-relative path] | stop", + "Usage: fixtures.mjs feed | feed-b | transcripts | private | library | retitle | requests | server | transcriptions | audio [vault-relative path] | stop", ); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); diff --git a/AGENTS.md b/AGENTS.md index aa22c9d5..af5df965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ svelte-check's native API path passes when a tsconfig matches no files, which `check:a11y` in temporary workspaces and requires it to fail on that case and when the native compiler cannot start. -After `npm run build`, `npm run size` enforces 330,000 raw bytes and 106,162 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts. +After `npm run build`, `npm run size` enforces 334,096 raw bytes and 108,302 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts. Before opening a PR or cutting a release, run the CI-equivalent checks locally: diff --git a/obsidian-e2e.config.mjs b/obsidian-e2e.config.mjs index 6a4ad568..b3daf06b 100644 --- a/obsidian-e2e.config.mjs +++ b/obsidian-e2e.config.mjs @@ -116,11 +116,13 @@ export default { deepgramSecretId: "", elevenLabsSecretId: "", openRouterSecretId: "", + openAICompatibleSecretId: "", transcript: { path: "transcripts/{{podcast}}/{{title}}.md", template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}", provider: "elevenlabs", model: "", + serverUrl: "", diarization: { enabled: true, speakerTemplate: "**{{speaker}}:** ", diff --git a/scripts/size-baseline.md b/scripts/size-baseline.md index df33fe7e..31f0fc21 100644 --- a/scripts/size-baseline.md +++ b/scripts/size-baseline.md @@ -12,7 +12,7 @@ main.js raw: 307131 bytes; budget 330000 bytes; 22869 bytes remaining main.js gzip (level 9): 97161 bytes; budget 100000 bytes; 2839 bytes remaining ``` -The gzip budget is now 107,181 bytes. On 2026-10-14 it rose from 100,000 for +The gzip budget is now 108,302 bytes and the raw budget 334,096. On 2026-10-14 it rose from 100,000 for the full player's Transcript panel, which the owner approved at about 1.9 KB and which measured 2,576 bytes. The coordinator set 103,000 to keep the panel's Find and Follow chip and to leave room for fixes in flight. The @@ -82,6 +82,49 @@ main.js raw: 328482 bytes; budget 330000 bytes; 1518 bytes remaining main.js gzip (level 9): 107184 bytes; budget 107281 bytes; 97 bytes remaining ``` +Later on 2026-11-14 both budgets rose for the OpenAI-compatible server +provider, the first rise of the raw budget. The branch it builds on measured +327,446 raw and 106,873 gzip bytes. The provider, its settings rows, gate and +failure sentences measured 3,293 raw and 895 gzip bytes more. Each budget rose +by its delta, to 333,293 and 107,865, so the headroom stays as it was. With +them: + +```text +main.js raw: 330739 bytes; budget 333293 bytes; 2554 bytes remaining +main.js gzip (level 9): 107768 bytes; budget 107865 bytes; 97 bytes remaining +``` + +Both budgets then rose when the OpenAI-compatible branch merged the OpenRouter +models' branch and was stacked on it. Apart, the two measured 107,768 and +107,184 gzip bytes. Merged, they measured 331,775 raw and 108,094 gzip bytes. +One verbose_json reader shared by both providers and a json fallback asked for +once per job brought that to 331,542 and 108,060. Each budget is that plus the +headroom kept above, 2,554 raw and 97 gzip bytes. With them: + +```text +main.js raw: 331542 bytes; budget 334096 bytes; 2554 bytes remaining +main.js gzip (level 9): 108060 bytes; budget 108157 bytes; 97 bytes remaining +``` + +Joining Whisper's word pieces then brought gzip to 108,114 bytes, 43 under the +budget. Words that find their punctuation in the answer's text again after a +stretch the words leave out measured 298 raw and 125 gzip bytes more. The gzip +budget rose by those 125 bytes, so its 43 bytes of headroom stay. With them: + +```text +main.js raw: 332075 bytes; budget 334096 bytes; 2021 bytes remaining +main.js gzip (level 9): 108239 bytes; budget 108282 bytes; 43 bytes remaining +``` + +A server address that carries a query, such as a token, keeps the query after +the transcriptions path. That measured 36 raw and 20 gzip bytes more, and the +gzip budget rose by those 20 bytes. With it: + +```text +main.js raw: 332111 bytes; budget 334096 bytes; 1985 bytes remaining +main.js gzip (level 9): 108259 bytes; budget 108302 bytes; 43 bytes remaining +``` + Gzip uses Node's `gzipSync` at level 9, without a filename or timestamp. Budgets use decimal bytes. The brief's 96,533-byte reference is unexplained. This report fixes the compressor instead of comparing compressed sizes across diff --git a/scripts/size.mjs b/scripts/size.mjs index 3dd63306..733fe700 100644 --- a/scripts/size.mjs +++ b/scripts/size.mjs @@ -9,8 +9,8 @@ try { const bundle = readFileSync(path); if (!bundle.length) throw new Error(`${path} is empty. Run npm run build first.`); const measurements = [ - { name: "raw", bytes: bundle.length, budget: 330_000 }, - { name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 107_281 }, + { name: "raw", bytes: bundle.length, budget: 334_096 }, + { name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 108_302 }, ]; const rows = measurements.map(({ name, bytes, budget }) => { const overage = Math.max(0, bytes - budget); diff --git a/scripts/size.test.ts b/scripts/size.test.ts index 781a04f6..185a162c 100644 --- a/scripts/size.test.ts +++ b/scripts/size.test.ts @@ -20,19 +20,19 @@ function check(name: string, contents: string | Buffer) { describe("bundle budget CLI", () => { it("allows the raw budget boundary and rejects one extra byte", () => { - expect(check("boundary.js", "a".repeat(330_000)).status).toBe(0); - const result = check("over.js", "a".repeat(330_001)); + expect(check("boundary.js", "a".repeat(334_096)).status).toBe(0); + const result = check("over.js", "a".repeat(334_097)); expect(result.status).toBe(1); - expect(result.stdout).toContain("budget 330000 bytes; OVER by 1 bytes"); + expect(result.stdout).toContain("budget 334096 bytes; OVER by 1 bytes"); }); it("rejects gzip growth even when raw bytes fit", () => { const result = check("gzip.js", randomBytes(110_000)); expect(result.status).toBe(1); expect(result.stdout).toMatch( - /gzip \(level 9\): \d+ bytes; budget 107281 bytes; OVER by \d+ bytes/, + /gzip \(level 9\): \d+ bytes; budget 108302 bytes; OVER by \d+ bytes/, ); - expect(result.stdout).toContain("budget 330000 bytes; 220000 bytes remaining"); + expect(result.stdout).toContain("budget 334096 bytes; 224096 bytes remaining"); }); it("fails on absent or empty artifacts instead of reporting a small build", () => { diff --git a/src/constants.ts b/src/constants.ts index ba5eeca4..4555804f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -160,11 +160,13 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { deepgramSecretId: "", elevenLabsSecretId: "", openRouterSecretId: "", + openAICompatibleSecretId: "", transcript: { path: "transcripts/{{podcast}}/{{title}}.md", template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}", provider: "elevenlabs", model: "", + serverUrl: "", diarization: { enabled: true, speakerTemplate: "**{{speaker}}:** ", diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 8764c4c8..9ce68516 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -411,7 +411,13 @@ function savedByThisVersion(released: Record): Record { template: "{{transcript}}", provider: "elevenlabs", model: "", + serverUrl: "", diarization: { enabled: false, speakerTemplate: "**{{speaker}}:** " }, }); expect(decoded.settings.feedNote).toEqual({ @@ -623,7 +624,8 @@ describe("PodNotes data schema", () => { libraryId: "", elevenLabsSecretId: "", openRouterSecretId: "", - transcript: { ...rest.transcript, provider: "elevenlabs", model: "" }, + openAICompatibleSecretId: "", + transcript: { ...rest.transcript, provider: "elevenlabs", model: "", serverUrl: "" }, }); }); @@ -650,7 +652,17 @@ describe("PodNotes data schema", () => { ); }; /** The root fields this version adds to a 2.24.0 library when it saves. */ - const ADDED = { elevenLabsSecretId: "", openRouterSecretId: "" }; + const ADDED = { + elevenLabsSecretId: "", + openRouterSecretId: "", + openAICompatibleSecretId: "", + }; + /** The library as saved, with the root fields and the server address this version adds. */ + const savedAs = (file: typeof released2240) => ({ + ...file, + ...ADDED, + transcript: { ...file.transcript, serverUrl: "" }, + }); it.each([ [ @@ -732,7 +744,7 @@ describe("PodNotes data schema", () => { ], ["no key", structuredClone(released2240)], ])( - "a save of a library with %s adds the provider, the model and empty ElevenLabs and OpenRouter key references, and changes nothing else", + "a save of a library with %s adds the provider, the model, an empty server address and empty ElevenLabs, OpenRouter and OpenAI-compatible server key references, and changes nothing else", (_state, data) => { const decoded = decodePodNotesData(structuredClone(data)); const { provider, model } = decoded.settings.transcript; @@ -742,7 +754,7 @@ describe("PodNotes data schema", () => { expect(file).toEqual({ ...data, ...ADDED, - transcript: { ...data.transcript, provider, model }, + transcript: { ...data.transcript, provider, model, serverUrl: "" }, }); expect(JSON.stringify(file.transcript.diarization)).toBe( JSON.stringify(data.transcript.diarization), @@ -765,7 +777,7 @@ describe("PodNotes data schema", () => { provider: "openai", model: "gpt-transcribe", }); - expect(saved(file)).toEqual({ ...file, ...ADDED }); + expect(saved(file)).toEqual(savedAs(file)); }); it("a provider and a model this version does not have are kept as stored, without a repair", () => { @@ -786,7 +798,7 @@ describe("PodNotes data schema", () => { }); expect(decoded.warnings).toEqual([]); expect(decoded.changed).toBe(false); - expect(saved(file)).toEqual({ ...file, ...ADDED }); + expect(saved(file)).toEqual(savedAs(file)); }); it("a value 2.24.0 would have clamped in its provider field is left as found", () => { diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 84a05eab..7b369ef7 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -233,7 +233,7 @@ function decodeTranscript( const diarization = optionalRecord(record.diarization, warnings, "transcript.diarization"); const migrated = migrateTranscriptSettings({ ...record, diarization }, holdsOpenAIKey); - for (const key of ["path", "template", "provider", "model"]) { + for (const key of ["path", "template", "provider", "model", "serverUrl"]) { if (record[key] !== undefined && typeof record[key] !== "string") { warn(warnings, `transcript.${key}`, "expected a string"); } diff --git a/src/services/CredentialRepository.test.ts b/src/services/CredentialRepository.test.ts index 7a9bfd2d..caf2492e 100644 --- a/src/services/CredentialRepository.test.ts +++ b/src/services/CredentialRepository.test.ts @@ -33,17 +33,20 @@ describe("CredentialRepository", () => { deepgram: "dg-key", elevenLabs: "el-key", openRouter: "or-key", + openAICompatible: "server-key", }), ).toEqual({ openAISecretId: "podnotes-openai-api-key", deepgramSecretId: "podnotes-deepgram-api-key", elevenLabsSecretId: "podnotes-elevenlabs-api-key", openRouterSecretId: "podnotes-openrouter-api-key", + openAICompatibleSecretId: "podnotes-openai-compatible-api-key", }); expect(values.get("podnotes-openai-api-key")).toBe("sk-openai"); expect(values.get("podnotes-deepgram-api-key")).toBe("dg-key"); expect(values.get("podnotes-elevenlabs-api-key")).toBe("el-key"); expect(values.get("podnotes-openrouter-api-key")).toBe("or-key"); + expect(values.get("podnotes-openai-compatible-api-key")).toBe("server-key"); expect(api.getSecret).toHaveBeenCalledWith("podnotes-openai-api-key"); }); diff --git a/src/services/TranscriptionService.published.test.ts b/src/services/TranscriptionService.published.test.ts index f149672d..a8f1d85b 100644 --- a/src/services/TranscriptionService.published.test.ts +++ b/src/services/TranscriptionService.published.test.ts @@ -199,6 +199,28 @@ describe("published transcripts", () => { expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); }); + test("the offer names the user's own server, without a charge", async () => { + fetchTextMock.mockResolvedValue("WEBVTT\n"); + const service = new TranscriptionService( + createMockPlugin({ + provider: "openai-compatible", + serverUrl: "http://localhost:8000/v1", + model: "whisper-1", + openAIKey: "", + transcripts: published, + }), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await service.transcribeCurrentEpisode(); + await finished( + service, + 'The published transcript could not be read. Run "Transcribe current episode from audio" to transcribe it with your OpenAI-compatible server instead.', + ); + + expect(providerMock).not.toHaveBeenCalled(); + }); + test("a failed published transcript without a key says only what failed", async () => { fetchTextMock.mockRejectedValue(new NetworkError("unexpected-status", 404)); const plugin = createMockPlugin({ openAIKey: "", transcripts: published }); diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts index 8895220f..dc449fbf 100644 --- a/src/services/TranscriptionService.test.ts +++ b/src/services/TranscriptionService.test.ts @@ -1987,6 +1987,436 @@ describe("TranscriptionService", () => { } }); }); + + describe("an OpenAI-compatible server transcript", () => { + const SERVER = "http://localhost:8000/v1"; + const ENDPOINT = "http://localhost:8000/v1/audio/transcriptions"; + const MODEL = "whisper-large-v3-turbo"; + const audio = new Uint8Array(2048).map((_, index) => (index * 19) % 233); + const useServer = ( + overrides: Partial[0]>> = {}, + ) => { + getEpisodeAudioBufferMock.mockResolvedValue({ buffer: audio.buffer, extension: "mp3" }); + return createMockPlugin({ + provider: "openai-compatible", + serverUrl: SERVER, + model: MODEL, + openAIKey: "", + ...overrides, + }); + }; + /** OpenAI's verbose_json, with word and segment times. */ + const verbose = { + task: "transcribe", + language: "english", + duration: 11, + text: "Hello there. Hi!", + words: [ + { word: "Hello", start: 1.2, end: 1.5 }, + { word: "there.", start: 1.5, end: 1.9 }, + { word: "Hi!", start: 10.16, end: 10.5 }, + ], + segments: [ + { id: 0, start: 1, end: 1.9, text: " Hello there." }, + { id: 1, start: 10, end: 10.5, text: " Hi!" }, + ], + }; + /** What OpenAI's gpt-transcribe answers when asked for verbose_json. */ + const verboseRefused = () => + Response.json( + { + error: { + message: + "response_format 'verbose_json' is not compatible with model 'gpt-transcribe'. Use 'json' or 'text' instead.", + type: "invalid_request_error", + param: "response_format", + code: "unsupported_value", + }, + }, + { status: 400 }, + ); + const link = (time: string) => + `obsidian://podnotes?episodeName=Test%20Episode&url=https%3A%2F%2Fexample.com%2Ffeed.xml&time=${time}`; + const timedNote = + `# Test Episode\n\n[00:00:01](${link("1.2")}) Hello there.\n\n` + + `[00:00:10](${link("10.16")}) Hi!`; + /** Each field of a request's form, with the file as its bytes. */ + const fields = async (request: RequestUrlParam) => + Promise.all( + [...(await sentForm(request))].map(async ([name, value]) => [ + name, + typeof value === "string" ? value : new Uint8Array(await value.arrayBuffer()), + ]), + ); + const responseFormat = async (request: RequestUrlParam) => + (await sentForm(request)).get("response_format"); + + test("sends one multipart request for word and segment times with the key, and times each paragraph", async () => { + providerMock.mockImplementation(async () => Response.json(verbose)); + const plugin = useServer({ openAICompatibleKey: "server-key" }); + + await expect(finalNotice(plugin)).resolves.toBe("Transcription completed and saved."); + + expect(providerMock).toHaveBeenCalledOnce(); + const [request] = providerMock.mock.calls[0]; + expect(request.url).toBe(ENDPOINT); + expect(request.method).toBe("POST"); + expect(request.headers).toEqual({ Authorization: "Bearer server-key" }); + expect(await fields(request)).toEqual([ + ["model", MODEL], + ["file", audio], + ["response_format", "verbose_json"], + ["timestamp_granularities[]", "word"], + ["timestamp_granularities[]", "segment"], + ]); + expect(savedNote(plugin)).toBe(timedNote); + }); + + test("joins a Whisper word list, whose words carry their own leading space, back into its words", async () => { + // Measured live: whisper-large-v3-turbo lists "sci-fi" as " sci" and "-fi". + providerMock.mockImplementation(async () => + Response.json({ + text: " only see sci-fi as cultural criticism. Hi!", + words: [ + { word: " only", start: 1.2, end: 1.4 }, + { word: " see", start: 1.4, end: 1.6 }, + { word: " sci", start: 1.6, end: 1.8 }, + { word: "-fi", start: 1.8, end: 2 }, + { word: " as", start: 2, end: 2.1 }, + { word: " cultural", start: 2.1, end: 2.5 }, + { word: " criticism.", start: 2.5, end: 3 }, + { word: " Hi!", start: 10.16, end: 10.5 }, + ], + }), + ); + const plugin = useServer(); + + await expect(finalNotice(plugin)).resolves.toBe("Transcription completed and saved."); + expect(savedNote(plugin)).toBe( + `# Test Episode\n\n[00:00:01](${link("1.2")}) only see sci-fi as cultural criticism.\n\n` + + `[00:00:10](${link("10.16")}) Hi!`, + ); + }); + + test("with no key selected, runs and sends no Authorization header", async () => { + providerMock.mockImplementation(async () => Response.json(verbose)); + const plugin = useServer(); + + await expect(finalNotice(plugin)).resolves.toBe("Transcription completed and saved."); + + expect(providerMock).toHaveBeenCalledOnce(); + expect(providerMock.mock.calls[0][0].headers).toEqual({}); + }); + + test.each([ + "https://api.groq.com/openai/v1", + "https://api.groq.com/openai/v1//", + " https://api.groq.com/openai/v1/audio/transcriptions/ ", + ])("the address %j sends to the server's transcriptions endpoint", async (serverUrl) => { + providerMock.mockImplementation(async () => Response.json(verbose)); + + await transcribe(useServer({ serverUrl })); + + expect(providerMock.mock.calls.map(([request]) => request.url)).toEqual([ + "https://api.groq.com/openai/v1/audio/transcriptions", + ]); + }); + + test.each([ + "https://example.test/v1?api_key=a%2Fb&x=1", + "https://example.test/v1/?api_key=a%2Fb&x=1", + "https://example.test/v1/audio/transcriptions?api_key=a%2Fb&x=1", + ])("the address %j keeps its query after the transcriptions path", async (serverUrl) => { + providerMock.mockImplementation(async () => Response.json(verbose)); + + await transcribe(useServer({ serverUrl })); + + expect(providerMock.mock.calls.map(([request]) => request.url)).toEqual([ + "https://example.test/v1/audio/transcriptions?api_key=a%2Fb&x=1", + ]); + }); + + test.each([ + "http://localhost:8000/v1", + "http://127.0.0.1:8080/v1", + "http://192.168.1.20:9000/v1", + ])("a server on this machine or a private network at %s is reached", async (serverUrl) => { + providerMock.mockImplementation(async () => Response.json(verbose)); + const plugin = useServer({ serverUrl }); + + await expect(finalNotice(plugin)).resolves.toBe("Transcription completed and saved."); + + expect(providerMock.mock.calls.map(([request]) => request.url)).toEqual([ + `${serverUrl}/audio/transcriptions`, + ]); + }); + + test("a server that refuses verbose_json is asked once more for json, and the note is untimed", async () => { + providerMock.mockImplementation(async (request) => + (await responseFormat(request)) === "verbose_json" + ? verboseRefused() + : Response.json({ text: "Hello there. Hi!" }), + ); + const plugin = useServer(); + + await expect(finalNotice(plugin)).resolves.toBe("Transcription completed and saved."); + + expect(providerMock).toHaveBeenCalledTimes(2); + const [, retry] = providerMock.mock.calls.map(([request]) => request); + expect(await fields(retry)).toEqual([ + ["model", MODEL], + ["file", audio], + ["response_format", "json"], + ]); + expect(savedNote(plugin)).toBe("# Test Episode\n\nHello there. Hi!"); + }); + + test("a server that refuses json too is not asked a third time", async () => { + providerMock.mockImplementation(async () => verboseRefused()); + const plugin = useServer(); + + await expect(finalNotice(plugin)).resolves.toBe( + "Transcription failed: OpenAI-compatible server did not answer in a format PodNotes reads.", + ); + expect(providerMock).toHaveBeenCalledTimes(2); + }); + + test("after a server refuses verbose_json, the episode's next part is sent once, as json", async () => { + const plugin = useServer(); + const stream = MP3_STREAMS.mpeg1At96Kbps; + const frames = Math.ceil(30_000_000 / stream.frameBytes); + getEpisodeAudioBufferMock.mockResolvedValue({ + buffer: mp3Bytes(stream, frames).buffer, + extension: "mp3", + }); + providerMock.mockImplementation(async (request) => + (await responseFormat(request)) === "verbose_json" + ? verboseRefused() + : Response.json({ text: "Hello there." }), + ); + + await expect(finalNotice(plugin)).resolves.toBe("Transcription completed and saved."); + + const requests = providerMock.mock.calls.map(([request]) => request); + const formats = await Promise.all(requests.map(responseFormat)); + const sizes = await Promise.all( + requests.map(async (request) => (await sentFile(request)).size), + ); + expect(formats).toEqual(["verbose_json", "json", "json"]); + expect(sizes[1]).toBe(sizes[0]); + expect(sizes[1] + sizes[2]).toBe(frames * stream.frameBytes); + }); + + test("words a server lists without punctuation take their punctuation from its text", async () => { + providerMock.mockImplementation(async () => + Response.json({ + ...verbose, + words: verbose.words.map((word) => ({ + ...word, + word: word.word.replace(/[.!]/, ""), + })), + }), + ); + const plugin = useServer(); + + await transcribe(plugin); + + expect(savedNote(plugin)).toBe(timedNote); + }); + + test("words keep taking punctuation from the text after a long stretch the words leave out and a word they split", async () => { + const said = "so we kept re running it Then it was running".split(" "); + providerMock.mockImplementation(async () => + Response.json({ + text: "Um, uh, you know, like, I mean, honestly, okay, right, well, look, so we kept re-running it. Then it was running. Hi!", + words: [ + ...said.map((word, index) => ({ + word, + start: 1.2 + index / 5, + end: 1.4 + index / 5, + })), + { word: "Hi", start: 10.16, end: 10.5 }, + ], + }), + ); + const plugin = useServer(); + + await transcribe(plugin); + + expect(savedNote(plugin)).toBe( + `# Test Episode\n\n[00:00:01](${link("1.2")}) so we kept re running it. Then it was running.\n\n` + + `[00:00:10](${link("10.16")}) Hi!`, + ); + }); + + test("a 400 about anything else is not sent again", async () => { + providerMock.mockImplementation(async () => + Response.json( + { + error: { + message: + "Invalid file format. Supported formats: ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']", + type: "invalid_request_error", + param: "file", + code: null, + }, + }, + { status: 400 }, + ), + ); + const plugin = useServer(); + + await expect(finalNotice(plugin)).resolves.toBe( + "Transcription failed: OpenAI-compatible server could not transcribe this audio.", + ); + expect(providerMock).toHaveBeenCalledOnce(); + }); + + test.each([ + ["words", { text: verbose.text, words: verbose.words }, timedNote], + [ + "segments and no words", + { text: verbose.text, segments: verbose.segments }, + `# Test Episode\n\n[00:00:01](${link("1")}) Hello there.\n\n` + + `[00:00:10](${link("10")}) Hi!`, + ], + ["text alone", { text: "Hello there. Hi!" }, "# Test Episode\n\nHello there. Hi!"], + ])("an answer with %s is saved as its note", async (_answer, answer, note) => { + providerMock.mockImplementation(async () => Response.json(answer)); + const plugin = useServer(); + + await transcribe(plugin); + + expect(savedNote(plugin)).toBe(note); + }); + + test("with speaker labels on, saves the note without them and says so, as for any model without them", async () => { + providerMock.mockImplementation(async () => Response.json(verbose)); + const plugin = useServer({ speakers: true }); + + await expect(finalNotice(plugin)).resolves.toBe( + "Saved without speaker labels, because OpenAI-compatible server does not label speakers. These providers do: ElevenLabs, Deepgram, OpenRouter.", + ); + expect(sentText(providerMock.mock.calls[0][0])).not.toContain("diarize"); + expect(savedNote(plugin)).toBe(timedNote); + }); + + test.each([ + [ + "Transcribe current episode", + (service: TranscriptionService) => service.transcribeCurrentEpisode(), + ], + [ + "Transcribe current episode from audio", + (service: TranscriptionService) => service.transcribeCurrentEpisodeFromAudio(), + ], + ])("%s asks for a model when none is entered, and sends nothing", async (_command, run) => { + const plugin = useServer({ model: " " }); + + await run(new TranscriptionService(plugin)); + + expect(noticeMessages).toEqual([ + "Enter a model for your OpenAI-compatible server in the transcript settings.", + ]); + expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); + expect(providerMock).not.toHaveBeenCalled(); + }); + + test.each(["", "localhost:8000/v1", "ftp://files.example.com/v1"])( + "asks for an http(s) address in place of %j, and sends nothing", + async (serverUrl) => { + const plugin = useServer({ serverUrl }); + + new TranscriptionService(plugin).transcribeCurrentEpisodeFromAudio(); + + expect(noticeMessages).toEqual([ + "Enter the address of your OpenAI-compatible server, starting with http:// or https://, in the transcript settings.", + ]); + expect(providerMock).not.toHaveBeenCalled(); + }, + ); + + test("a selected key that is not on this device stops the job instead of sending without it", async () => { + const plugin = useServer({ + openAICompatibleSecretId: "podnotes-openai-compatible-api-key", + }); + + new TranscriptionService(plugin).transcribeCurrentEpisodeFromAudio(); + + expect(noticeMessages).toEqual([ + "The selected OpenAI-compatible server API key is not available on this device. Select or create it in the transcript settings.", + ]); + expect(providerMock).not.toHaveBeenCalled(); + }); + + test.each([ + [ + "a rejected key", + async () => + Response.json({ error: { message: "Invalid API Key" } }, { status: 401 }), + "The server at localhost:8000 rejected the API key. Check it in the transcript settings.", + ], + [ + "no such address or model", + async () => new Response("Not Found", { status: 404 }), + `The server at localhost:8000 has no ${MODEL} model at that address. Check the address and the model in the transcript settings.`, + ], + [ + "no answer", + () => Promise.reject(new TypeError("net::ERR_CONNECTION_REFUSED")), + "PodNotes could not reach the server at localhost:8000. Check the address in the transcript settings.", + ], + ])("%s says what to check, in PodNotes' words", async (_failure, answer, sentence) => { + providerMock.mockImplementation(answer); + + await expect( + finalNotice(useServer({ openAICompatibleKey: "server-key" })), + ).resolves.toBe(`Transcription failed: ${sentence}`); + }); + + test.each([ + ["a public feed's", publicFeed, () => publicFeed.url], + [ + "a private feed's", + { + ...publicFeed, + url: privateFeedPlaceholder("Test Podcast"), + urlSecretId: "podnotes-feed-url-1", + }, + () => "https://private.example.com/feed/SUBSCRIBER-TOKEN.rss", + ], + ])( + "%s episode is uploaded to a local server, and no request holds its address", + async (_feed, feed, resolveUrl) => { + providerMock.mockImplementation(async () => Response.json(verbose)); + const episode: Episode = { + ...mockEpisode, + streamUrl: "https://cdn.example.com/SUBSCRIBER-TOKEN/episode.mp3", + feedUrl: feed.url, + }; + const plugin = useServer({ + feeds: savedFeedRepository(feed, [episode], resolveUrl), + }); + + await transcribe(plugin, episode); + + expect(providerMock).toHaveBeenCalledOnce(); + const [request] = providerMock.mock.calls[0]; + expect(request.url).toBe(ENDPOINT); + expect((await fields(request)).find(([name]) => name === "file")).toEqual([ + "file", + audio, + ]); + for (const secret of [ + "SUBSCRIBER-TOKEN", + "cdn.example.com", + "private.example.com", + ]) { + expect(sentText(request)).not.toContain(secret); + } + }, + ); + }); }); function sumOf(numbers: readonly number[]): number { diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 013fce2c..0e0df260 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -17,7 +17,12 @@ import { renderTranscriptBody, timestampStyle, } from "./publishedTranscript"; -import { refreshModels, transcribe, transcriptionChoice } from "./transcription"; +import { + type ProviderChoice, + refreshModels, + transcribe, + transcriptionChoice, +} from "./transcription"; export const TRANSCRIBE_FROM_AUDIO_COMMAND = "Transcribe current episode from audio"; @@ -128,11 +133,28 @@ export class TranscriptionService { /** Why audio cannot go to the chosen provider from this device, or undefined when it can. */ private refusal(): string | undefined { + const run = this.runnable(); + return typeof run === "string" ? run : undefined; + } + + /** The provider and key audio goes to from this device, or the sentence that says why it cannot. */ + private runnable(): { choice: ProviderChoice; apiKey: string } | string { const { settings, credentials } = this.plugin; - const { provider, label } = transcriptionChoice(settings); - if (provider === undefined) return unknownProvider(label); - if (credentials.has(settings, provider)) return undefined; - return credentials.status(settings, provider) === "missing" + const choice = transcriptionChoice(settings); + if (choice.provider === undefined) return unknownProvider(choice.label); + const { provider, label, server } = choice; + if (server && !server.endpoint) { + return `Enter the address of your ${label}, starting with http:// or https://, in the transcript settings.`; + } + if (server && !choice.model.id) { + return `Enter a model for your ${label} in the transcript settings.`; + } + const apiKey = credentials.get(settings, provider); + const status = credentials.status(settings, provider); + if (apiKey || (server && status === "unconfigured")) { + return { choice, apiKey: apiKey ?? "" }; + } + return status === "missing" ? `The selected ${label} API key is not available on this device. Select or create it in the transcript settings.` : `Select or create ${/^[AEIOU]/.test(label) ? "an" : "a"} ${label} API key in the transcript settings on this device.`; } @@ -255,12 +277,11 @@ export class TranscriptionService { episode: Episode, updateNotice: (message: string) => void, ): Promise { - const { settings, credentials, feeds } = this.plugin; - await this.waitForLifecycle(refreshModels(settings.transcript.provider)); - const choice = transcriptionChoice(settings); - if (choice.provider === undefined) throw new Error(unknownProvider(choice.label)); - const apiKey = credentials.get(settings, choice.provider); - if (!apiKey) throw new Error(`Missing ${choice.label} API key on this device.`); + await this.waitForLifecycle(refreshModels(this.plugin.settings.transcript.provider)); + const run = this.runnable(); + if (typeof run === "string") throw new Error(run); + const { choice, apiKey } = run; + const { feeds } = this.plugin; const { content, warnings } = await this.waitForLifecycle( transcribe({ choice, @@ -289,8 +310,11 @@ export class TranscriptionService { private publishedTranscriptFailure(error: PublishedTranscriptError): string { if (this.refusal()) return error.message; - const { label } = transcriptionChoice(this.plugin.settings); - return `${error.message} Run "${TRANSCRIBE_FROM_AUDIO_COMMAND}" to transcribe it with ${label} instead, which uses your API credit.`; + const { label, server } = transcriptionChoice(this.plugin.settings); + const offer = `${error.message} Run "${TRANSCRIBE_FROM_AUDIO_COMMAND}" to transcribe it with`; + return server + ? `${offer} your ${label} instead.` + : `${offer} ${label} instead, which uses your API credit.`; } private waitForLifecycle(operation: Promise): Promise { diff --git a/src/services/transcription/http.ts b/src/services/transcription/http.ts index 77ee686b..5010b580 100644 --- a/src/services/transcription/http.ts +++ b/src/services/transcription/http.ts @@ -9,6 +9,7 @@ export type FailureKind = | "privacy" | "rejected" | "unfetched" + | "format" | "rate-limited" | "server" | "network" @@ -32,6 +33,8 @@ export interface FailureContext { readonly provider: string; readonly model: string; readonly timeoutMs: number; + /** The host and port of a server at an address the user entered. */ + readonly server?: string; } /** @@ -39,20 +42,26 @@ export interface FailureContext { * own error text can quote the address it was sent, so it is never shown or logged. */ const SENTENCES: Record string> = { - auth: ({ provider }) => - `${provider} rejected the API key. Check it in the transcript settings.`, + auth: ({ provider, server }) => + `${server ? `The server at ${server}` : provider} rejected the API key. Check it in the transcript settings.`, credits: ({ provider }) => `Your ${provider} account is out of credit. Add credit there, then try again.`, - "model-unavailable": ({ provider, model }) => - `${provider} does not offer the ${model} model to this API key.`, + "model-unavailable": ({ provider, model, server }) => + server + ? `The server at ${server} has no ${model} model at that address. Check the address and the model in the transcript settings.` + : `${provider} does not offer the ${model} model to this API key.`, privacy: ({ provider, model }) => `${provider} does not offer the ${model} model under your ${provider} privacy settings. Change them there, or choose another model in the transcript settings.`, rejected: ({ provider }) => `${provider} could not transcribe this audio.`, unfetched: ({ provider }) => `${provider} could not fetch the episode.`, + format: ({ provider }) => `${provider} did not answer in a format PodNotes reads.`, "rate-limited": ({ provider }) => `${provider} is limiting how fast your key may be used. Try again in a few minutes.`, server: ({ provider }, status) => `${provider} failed (HTTP ${status}). Try again later.`, - network: ({ provider }) => `Could not reach ${provider}. Check your connection.`, + network: ({ provider, server }) => + server + ? `PodNotes could not reach the server at ${server}. Check the address in the transcript settings.` + : `Could not reach ${provider}. Check your connection.`, timeout: ({ provider, timeoutMs }) => `${provider} did not answer within ${timeoutMs / 60_000} minutes.`, empty: ({ provider }) => `${provider} found no speech in this episode.`, @@ -94,6 +103,8 @@ export function createSender( context: FailureContext, signal: AbortSignal, ): Send { + // Only an address the user entered may be on their own machine or network. + const privateHosts = context.server !== undefined; const sendOnce = async (request: ProviderRequest): Promise => { const { body, contentType } = await encode(request); let response; @@ -106,6 +117,7 @@ export function createSender( timeoutMs: context.timeoutMs, maxRequestBodyBytes: body.byteLength, acceptedStatuses: EVERY_HTTP_STATUS, + privateHosts, signal, }); } catch (error) { diff --git a/src/services/transcription/index.ts b/src/services/transcription/index.ts index 024499ec..6c2361f4 100644 --- a/src/services/transcription/index.ts +++ b/src/services/transcription/index.ts @@ -3,6 +3,7 @@ export { catalogs, offeredModels, PROVIDER_LABELS, + type ProviderChoice, refreshModels, transcriptionChoice, } from "./registry"; diff --git a/src/services/transcription/providers/openaiCompatible.ts b/src/services/transcription/providers/openaiCompatible.ts new file mode 100644 index 00000000..d0b075a5 --- /dev/null +++ b/src/services/transcription/providers/openaiCompatible.ts @@ -0,0 +1,60 @@ +import { isPlainObject } from "src/persistence/codecUtils"; +import { assertFetchableUrl } from "src/utility/assertFetchableUrl"; +import type { Provider } from "./types"; +import { verboseJsonTimes } from "./verboseJson"; + +const PATH = "/audio/transcriptions"; + +/** + * A server that speaks https://developers.openai.com/api/reference/resources/audio/subresources/transcriptions/methods/create, + * such as whisper.cpp, faster-whisper, speaches, Groq or Together, at the address the user + * enters. It labels no speakers, and an upload goes in parts of 24 MB, under the 25 MB most + * of them take. + */ +export const openaiCompatible: Provider = { + models: [{ id: "", label: "", speakers: false, url: false, maxUploadBytes: 24_000_000 }], + timeoutMs: 10 * 60_000, + + endpoint(address) { + try { + const url = assertFetchableUrl(address.trim(), { privateHosts: true }); + const path = url.pathname.replace(/\/+$/, ""); + url.pathname = path.endsWith(PATH) ? path : `${path}${PATH}`; + return url.href; + } catch { + return undefined; + } + }, + + async transcribe({ apiKey, model, audio, endpoint, textOnly }, send) { + if (!endpoint || "url" in audio) { + throw new Error("An OpenAI-compatible server takes an uploaded file at its endpoint."); + } + const form = new FormData(); + form.append("model", model.id); + form.append("file", audio.file); + form.append("response_format", textOnly ? "json" : "verbose_json"); + if (!textOnly) { + form.append("timestamp_granularities[]", "word"); + form.append("timestamp_granularities[]", "segment"); + } + const answer = await send({ + url: endpoint, + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + body: form, + }); + return ( + verboseJsonTimes(answer) ?? { + text: isPlainObject(answer) && typeof answer.text === "string" ? answer.text : "", + } + ); + }, + + // A model without times, such as OpenAI's gpt-transcribe, refuses verbose_json with a 400 + // that names the option, and answers json, which the job asks for from then on. + failure: (status, body) => + status === 400 && + /response_format|verbose_json|timestamp_granularities/i.test(JSON.stringify(body)) + ? "format" + : undefined, +}; diff --git a/src/services/transcription/providers/openrouter.ts b/src/services/transcription/providers/openrouter.ts index 23608cf1..9559eba9 100644 --- a/src/services/transcription/providers/openrouter.ts +++ b/src/services/transcription/providers/openrouter.ts @@ -2,16 +2,8 @@ import { arrayBufferToBase64 } from "obsidian"; import { isPlainObject } from "src/persistence/codecUtils"; import type { NonEmpty } from "src/services/publishedTranscript/parse"; import { fetchJsonWithTimeout } from "src/utility/networkRequest"; -import type { - Listing, - ModelProfile, - PartInput, - PartTranscript, - Provider, - ProviderRequest, - Segment, - Word, -} from "./types"; +import type { Listing, ModelProfile, PartInput, Provider, ProviderRequest } from "./types"; +import { verboseJsonTimes } from "./verboseJson"; const TRANSCRIPTIONS_URL = "https://openrouter.ai/api/v1/audio/transcriptions"; const CATALOG_URL = "https://openrouter.ai/api/v1/models?output_modalities=transcription"; @@ -146,13 +138,13 @@ export const openrouter: Provider = { async transcribe(input, send) { const model = MODELS.find(({ id }) => id === input.model.id); if (!model) throw new Error(`OpenRouter has no ${input.model.id} request.`); - return parse( - await send({ - url: TRANSCRIPTIONS_URL, - headers: { Authorization: `Bearer ${input.apiKey}` }, - body: await model.body(input), - }), - ); + const answer = await send({ + url: TRANSCRIPTIONS_URL, + headers: { Authorization: `Bearer ${input.apiKey}` }, + body: await model.body(input), + }); + // An answer with neither words nor segments holds no speech. + return verboseJsonTimes(answer) ?? { words: [] }; }, failure(status, body) { @@ -185,68 +177,3 @@ export const openrouter: Provider = { return listing satisfies Listing; }, }; - -/** Words when the answer lists them, else its segments. An answer with neither holds no speech. */ -function parse(answer: unknown): PartTranscript { - if (!isPlainObject(answer)) return { words: [] }; - const { words, segments } = answer; - if (!Array.isArray(words)) { - return { segments: Array.isArray(segments) ? segments.flatMap(segmentOf) : [] }; - } - return { - words: punctuated( - words.flatMap((item: unknown): Word[] => { - // Scribe v2 also lists sounds, such as "[gentle music]", as `audio_event` items. - if (!isPlainObject(item) || (item.type ?? "word") !== "word") return []; - const { word: text, start, end, speaker } = item; - if ( - typeof text !== "string" || - typeof start !== "number" || - typeof end !== "number" - ) { - return []; - } - return [ - { - text, - start, - end, - speaker: - typeof speaker === "number" || typeof speaker === "string" - ? `${speaker}` - : undefined, - }, - ]; - }), - answer.text, - ), - }; -} - -/** - * Qwen3 ASR lists its words without punctuation and keeps the punctuation in `text`, so - * each word takes its token from `text` when the two spell it alike. - */ -function punctuated(words: Word[], text: unknown): Word[] { - if (typeof text !== "string" || words.some((word) => /[.,?!]/.test(word.text))) return words; - const tokens = text.split(/\s+/).filter(Boolean); - const bare = (token: string) => token.toLowerCase().replace(/[^\p{L}\p{N}']/gu, ""); - let next = 0; - return words.map((word) => { - // A few tokens of lookahead get past a word `text` spells differently. - for (let index = next; index < Math.min(tokens.length, next + 3); index++) { - if (bare(tokens[index]) !== bare(word.text)) continue; - next = index + 1; - return { ...word, text: tokens[index] }; - } - return word; - }); -} - -function segmentOf(item: unknown): Segment[] { - if (!isPlainObject(item)) return []; - const { text, start, end } = item; - return typeof text === "string" && typeof start === "number" && typeof end === "number" - ? [{ text, start, end }] - : []; -} diff --git a/src/services/transcription/providers/types.ts b/src/services/transcription/providers/types.ts index 53cc696d..913c5d2f 100644 --- a/src/services/transcription/providers/types.ts +++ b/src/services/transcription/providers/types.ts @@ -50,12 +50,17 @@ export type PartTranscript = export type AudioInput = { readonly url: PublicAudioUrl } | { readonly file: File }; export interface PartInput { + /** Empty when no key is selected for a provider with an `endpoint`, which may need none. */ readonly apiKey: string; readonly model: ModelProfile; /** Label who spoke. Only ever true for a model whose profile says `speakers`. */ readonly speakers: boolean; /** A `url` only for a model whose profile says `url`. */ readonly audio: AudioInput; + /** Where requests go, for a provider with an `endpoint`. */ + readonly endpoint?: string; + /** Ask for text without times, after this job's server refused a request for them. */ + readonly textOnly: boolean; } export interface ProviderRequest { @@ -73,7 +78,10 @@ export type Listing = ReadonlyMap; /** Everything that is one provider's own: its models, its request and how to read its answers. */ export interface Provider { - /** Best first. The first is the one PodNotes recommends. */ + /** + * Best first. The first is the one PodNotes recommends. For a provider with an + * `endpoint`, the one profile says what PodNotes takes the model the user names to do. + */ readonly models: NonEmpty; /** How long PodNotes waits for one answer. */ readonly timeoutMs: number; @@ -82,4 +90,10 @@ export interface Provider { failure?(status: number, body: unknown): FailureKind | undefined; /** Reads which models the provider still serves. Rejects when it cannot tell. */ catalog?(): Promise; + /** + * Set for a server at an address the user enters, which may be on their own machine or + * network, runs the model they name and may need no key. Gives the address requests go + * to from the one entered, or undefined when that is no http(s) address. + */ + endpoint?(address: string): string | undefined; } diff --git a/src/services/transcription/providers/verboseJson.ts b/src/services/transcription/providers/verboseJson.ts new file mode 100644 index 00000000..81056adb --- /dev/null +++ b/src/services/transcription/providers/verboseJson.ts @@ -0,0 +1,108 @@ +import { isPlainObject } from "src/persistence/codecUtils"; +import type { PartTranscript, Segment, Word } from "./types"; + +/** + * The times in an answer in OpenAI's verbose_json shape, which OpenRouter and OpenAI-compatible + * servers answer in: its words when it lists any, else its segments, else undefined. + */ +export function verboseJsonTimes(answer: unknown): PartTranscript | undefined { + if (!isPlainObject(answer)) return undefined; + const words = Array.isArray(answer.words) ? answer.words.flatMap(wordOf) : []; + if (words.length > 0) return { words: punctuated(joined(words), answer.text) }; + const segments = Array.isArray(answer.segments) ? answer.segments.flatMap(segmentOf) : []; + return segments.length > 0 ? { segments } : undefined; +} + +/** + * Whisper lists each word with its leading space, and a piece without one continues the word + * before it, as " sci" and "-fi" in "sci-fi". Lists of bare words are left as they are. + */ +function joined(words: Word[]): Word[] { + if (!words.some((word) => /^\s/.test(word.text))) return words; + const out: Word[] = []; + for (const word of words) { + const last = out[out.length - 1]; + if (last && !/^\s/.test(word.text)) { + out[out.length - 1] = { ...last, text: last.text + word.text, end: word.end }; + } else { + out.push(word); + } + } + return out.map((word) => ({ ...word, text: word.text.trim() })); +} + +function wordOf(item: unknown): Word[] { + // Scribe v2 also lists sounds, such as "[gentle music]", as `audio_event` items. + if (!isPlainObject(item) || (item.type ?? "word") !== "word") return []; + const { word: text, start, end, speaker } = item; + if (typeof text !== "string" || typeof start !== "number" || typeof end !== "number") { + return []; + } + return [ + { + text, + start, + end, + speaker: + typeof speaker === "number" || typeof speaker === "string" + ? `${speaker}` + : undefined, + }, + ]; +} + +/** + * Qwen3 ASR, and the servers that run it or Whisper, can list words without punctuation and + * keep it in `text`, so each word takes its token from `text` when the two spell it alike. + */ +function punctuated(words: Word[], text: unknown): Word[] { + if (typeof text !== "string" || words.some((word) => /[.,?!]/.test(word.text))) return words; + const tokens = text.split(/\s+/).filter(Boolean); + const bare = (token: string) => token.toLowerCase().replace(/[^\p{L}\p{N}']/gu, ""); + const keys = tokens.map(bare); + const single = firstFrom(keys); + const pair = firstFrom(keys.slice(1).map((key, index) => `${keys[index]} ${key}`)); + let next = 0; + return words.map((word, index) => { + const key = bare(word.text); + const near = single(key, next); + // A word `text` spells differently, such as "re-running" listed as "re" and "running", + // matches no token. A token past the few after the last match counts only when the next + // word follows it there too, which finds the words again after any stretch they leave out. + const at = + near !== undefined && near - next < 3 + ? near + : index + 1 < words.length + ? pair(`${key} ${bare(words[index + 1].text)}`, next) + : undefined; + if (at === undefined) return word; + next = at + 1; + return { ...word, text: tokens[at] }; + }); +} + +/** + * Finds the first index at or after `from` that holds a key. Each call's `from` must be at least + * the last one's, which lets every lookup together take time linear in the keys. + */ +function firstFrom(keys: string[]): (key: string, from: number) => number | undefined { + const later = new Map(); + for (let index = keys.length - 1; index >= 0; index--) { + const list = later.get(keys[index]); + if (list) list.push(index); + else later.set(keys[index], [index]); + } + return (key, from) => { + const list = later.get(key); + while (list && list[list.length - 1] < from) list.pop(); + return list?.[list.length - 1]; + }; +} + +function segmentOf(item: unknown): Segment[] { + if (!isPlainObject(item)) return []; + const { text, start, end } = item; + return typeof text === "string" && typeof start === "number" && typeof end === "number" + ? [{ text, start, end }] + : []; +} diff --git a/src/services/transcription/registry.ts b/src/services/transcription/registry.ts index 8ef354f1..c0377758 100644 --- a/src/services/transcription/registry.ts +++ b/src/services/transcription/registry.ts @@ -6,6 +6,7 @@ import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { deepgram } from "./providers/deepgram"; import { elevenlabs } from "./providers/elevenlabs"; import { openai } from "./providers/openai"; +import { openaiCompatible } from "./providers/openaiCompatible"; import { openrouter } from "./providers/openrouter"; import type { Listing, ModelProfile, Provider } from "./providers/types"; @@ -18,6 +19,7 @@ export const PROVIDERS = { deepgram, openrouter, openai, + "openai-compatible": openaiCompatible, } satisfies Record; const PROVIDER_IDS = Object.keys(PROVIDERS) as CredentialKind[]; @@ -37,7 +39,10 @@ export interface ProviderChoice { readonly provider: CredentialKind; /** The provider's name, for buttons and notices. */ readonly label: string; - /** The stored model, or the first offered when none is stored or the stored one cannot run. */ + /** + * The stored model, or the first offered when none is stored or the stored one cannot run. + * On a `server`, the model the user named, its ID empty until they name one. + */ readonly model: OfferedModel; /** The stored model ID, when this version has no such model. */ readonly unknownModel?: string; @@ -45,6 +50,11 @@ export interface ProviderChoice { readonly withdrawnModel?: ModelProfile; /** The settings ask for speaker labels. Whether the model has them is `model.speakers`. */ readonly speakers: boolean; + /** + * For a server at an address the user enters: where requests go, undefined until the + * address is an http(s) one. It may need no key. + */ + readonly server?: { readonly endpoint: string | undefined }; } /** A provider a newer PodNotes stored. This version keeps the setting and cannot run it. */ @@ -53,6 +63,7 @@ export interface UnknownProviderChoice { /** The provider's ID as stored. */ readonly label: string; readonly speakers: boolean; + readonly server?: undefined; } export type TranscriptionChoice = ProviderChoice | UnknownProviderChoice; @@ -68,6 +79,17 @@ export function transcriptionChoice({ const { provider, model: stored, diarization } = transcript; const speakers = diarization.enabled; if (!isOption(PROVIDERS, provider)) return { provider: undefined, label: provider, speakers }; + const source: Provider = PROVIDERS[provider]; + const label = PROVIDER_LABELS[provider]; + if (source.endpoint) { + return { + provider, + label, + model: { ...source.models[0], id: stored.trim(), label }, + speakers, + server: { endpoint: source.endpoint(transcript.serverUrl) }, + }; + } const models = offeredModels(provider); const model = models.find(({ id }) => id === stored); const withdrawn = model @@ -75,7 +97,7 @@ export function transcriptionChoice({ : PROVIDERS[provider].models.find(({ id }) => id === stored); return { provider, - label: PROVIDER_LABELS[provider], + label, model: model ?? models[0], unknownModel: model || withdrawn || !stored ? undefined : stored, withdrawnModel: withdrawn, diff --git a/src/services/transcription/transcribe.ts b/src/services/transcription/transcribe.ts index 7e38dbaf..00b6c87a 100644 --- a/src/services/transcription/transcribe.ts +++ b/src/services/transcription/transcribe.ts @@ -12,6 +12,7 @@ import { splitAudio } from "./split"; export interface TranscriptionJob { readonly choice: ProviderChoice; + /** Empty only for a `server` with no key selected. */ readonly apiKey: string; readonly episode: Episode; /** Read to tell whether the provider may be given the episode's address. */ @@ -33,6 +34,7 @@ const ENDS_THE_JOB: ReadonlySet = new Set([ "model-unavailable", "privacy", "rejected", + "format", ]); /** @@ -43,11 +45,29 @@ export async function transcribe(job: TranscriptionJob): Promise { const { choice, apiKey, episode, signal, onProgress } = job; const { model } = choice; const provider = PROVIDERS[choice.provider]; - const context = { provider: choice.label, model: model.id, timeoutMs: provider.timeoutMs }; + const endpoint = choice.server?.endpoint; + const context = { + provider: choice.label, + model: model.id, + timeoutMs: provider.timeoutMs, + server: endpoint ? new URL(endpoint).host : undefined, + }; const send = createSender(provider.failure, context, signal); const speakers = choice.speakers && model.speakers; - const transcribeOne = (audio: AudioInput) => - provider.transcribe({ apiKey, model, speakers, audio }, send); + let textOnly = false; + const transcribeOne = async (audio: AudioInput) => { + const request = () => + provider.transcribe({ apiKey, model, speakers, audio, endpoint, textOnly }, send); + try { + return await request(); + } catch (error) { + // A server that refuses a request for times is asked for text alone, for this part + // and every later one. + if (!(error instanceof TranscriptionError) || error.kind !== "format") throw error; + textOnly = true; + return request(); + } + }; const progress = `Transcribing with ${choice.label}...`; const parts: TranscribedPart[] = []; diff --git a/src/settingsMigrations.test.ts b/src/settingsMigrations.test.ts index 402c51de..7220b2a3 100644 --- a/src/settingsMigrations.test.ts +++ b/src/settingsMigrations.test.ts @@ -148,12 +148,13 @@ describe("migrateTranscriptSettings", () => { expect(migrateTranscriptSettings(null, false)).toEqual(expected); }); - it("keeps a stored provider, model and speaker settings as they are", () => { + it("keeps a stored provider, model, server address and speaker settings as they are", () => { const stored = { path: "t/{{title}}.md", template: "{{transcript}}", - provider: "deepgram", - model: "nova-3", + provider: "openai-compatible", + model: "whisper-large-v3-turbo", + serverUrl: "http://localhost:8000/v1", diarization: { enabled: true, speakerTemplate: "Speaker {{speaker}}: " }, }; diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index e5d59ece..19770d45 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -103,6 +103,7 @@ type StoredTranscript = { template?: unknown; provider?: unknown; model?: unknown; + serverUrl?: unknown; diarization?: StoredDiarization | null; }; @@ -132,6 +133,7 @@ export function migrateTranscriptSettings( ? stored.provider : releasedProvider(storedDiarization, holdsOpenAIKey), model: typeof stored.model === "string" ? stored.model : "", + serverUrl: typeof stored.serverUrl === "string" ? stored.serverUrl : "", diarization: { // Only a new install starts with speaker labels on. A library that never // stored the choice ran without them. diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 95705fed..f4af441f 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -216,12 +216,14 @@ describe("serializeSettings", () => { deepgramSecretId: "podnotes-deepgram-api-key", elevenLabsSecretId: "podnotes-elevenlabs-api-key", openRouterSecretId: "podnotes-openrouter-api-key", + openAICompatibleSecretId: "podnotes-openai-compatible-api-key", }); const secrets = { openAI: "sk-secret", deepgram: "dg-secret", elevenLabs: "el-secret", openRouter: "or-secret", + openAICompatible: "server-secret", }; const without = serializeSettings(settings, {}, "2.16.0", NOW, new Map()); @@ -229,6 +231,7 @@ describe("serializeSettings", () => { expect(without.settings).not.toHaveProperty("deepgramSecretId"); expect(without.settings).not.toHaveProperty("elevenLabsSecretId"); expect(without.settings).not.toHaveProperty("openRouterSecretId"); + expect(without.settings).not.toHaveProperty("openAICompatibleSecretId"); const withKeys = serializeSettings(settings, { secrets }, "2.16.0", NOW, new Map()); expect(withKeys.version).toBe(2); @@ -611,7 +614,13 @@ describe("parseImport", () => { type: SETTINGS_EXPORT_TYPE, version: 2, settings: { defaultVolume: 0.5, openAISecretId: "not-transferable" }, - secrets: { openAI: " sk ", deepgram: "dg", elevenLabs: "el", openRouter: "or" }, + secrets: { + openAI: " sk ", + deepgram: "dg", + elevenLabs: "el", + openRouter: "or", + openAICompatible: "server", + }, }), ); @@ -623,6 +632,7 @@ describe("parseImport", () => { deepgram: "dg", elevenLabs: "el", openRouter: "or", + openAICompatible: "server", }); } }); @@ -908,6 +918,7 @@ describe("the transcription provider in a settings file", () => { template: released2240.transcript.template, provider: "deepgram", model: "nova-3", + serverUrl: "", diarization: { enabled: true, provider: "deepgram", @@ -934,6 +945,22 @@ describe("the transcription provider in a settings file", () => { ]); }); + it("carries an OpenAI-compatible server's address and model to the library that imports it", () => { + const settings = makeSettings(); + settings.transcript.provider = "openai-compatible"; + settings.transcript.model = "whisper-large-v3-turbo"; + settings.transcript.serverUrl = "http://192.168.1.20:8000/v1"; + const file = JSON.stringify(serializeSettings(settings, {}, "2.25.0", NOW, new Map())); + + const { transcript } = imported(makeSettings(), file); + + expect([transcript.provider, transcript.model, transcript.serverUrl]).toEqual([ + "openai-compatible", + "whisper-large-v3-turbo", + "http://192.168.1.20:8000/v1", + ]); + }); + it("imports a 2.24.0 export that labels speakers with Deepgram as Deepgram, on its recommended model", () => { const current = makeSettings(); current.transcript.provider = "openai"; @@ -999,12 +1026,19 @@ describe("describeSecrets (#168)", () => { expect(describeSecrets({ openAI: "sk" })).toEqual(["OpenAI API key"]); expect(describeSecrets({ deepgram: "dg" })).toEqual(["Deepgram API key"]); expect( - describeSecrets({ openAI: "sk", deepgram: "dg", elevenLabs: "el", openRouter: "or" }), + describeSecrets({ + openAI: "sk", + deepgram: "dg", + elevenLabs: "el", + openRouter: "or", + openAICompatible: "server", + }), ).toEqual([ "OpenAI API key", "Deepgram API key", "ElevenLabs API key", "OpenRouter API key", + "OpenAI-compatible server API key", ]); }); diff --git a/src/types/Credentials.ts b/src/types/Credentials.ts index cd75efd1..ce026501 100644 --- a/src/types/Credentials.ts +++ b/src/types/Credentials.ts @@ -8,6 +8,11 @@ export const CREDENTIALS = { deepgram: { settingsKey: "deepgramSecretId", exportKey: "deepgram", label: "Deepgram" }, elevenlabs: { settingsKey: "elevenLabsSecretId", exportKey: "elevenLabs", label: "ElevenLabs" }, openrouter: { settingsKey: "openRouterSecretId", exportKey: "openRouter", label: "OpenRouter" }, + "openai-compatible": { + settingsKey: "openAICompatibleSecretId", + exportKey: "openAICompatible", + label: "OpenAI-compatible server", + }, } as const; export type CredentialKind = keyof typeof CREDENTIALS; diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 4af66c8e..21d839e8 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -64,8 +64,13 @@ export interface IPodNotesSettings extends CredentialReferenceSettings { template: string; /** The ID of the provider that transcribes audio. One this version does not have is kept as stored. */ provider: string; - /** The ID of one of the provider's models, or "" to follow the model PodNotes recommends. */ + /** + * The ID of one of the provider's models, or "" to follow the model PodNotes recommends. + * For an OpenAI-compatible server, the ID the user typed, which it must have. + */ model: string; + /** The address the user entered for an OpenAI-compatible server, as typed. */ + serverUrl: string; /** * `enabled` asks for speaker labels, which the chosen model may not have. * `speakerTemplate` shapes each label, where `{{speaker}}` is the speaker's name. diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index e6d77efb..7a385122 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -67,7 +67,10 @@ void $rowHasNote; return transcriptNote(app.vault, $settings, episode); }); - const provider = $derived(transcriptionChoice($settings).label); + const choice = $derived(transcriptionChoice($settings)); + const provider = $derived(choice.server ? "your server" : choice.label); + // PodNotes cannot tell whether the user's own server charges. + const paid = $derived(!choice.server); $effect(() => { finding = false; @@ -186,7 +189,7 @@ onclick={() => transcribeFromAudio()} > Transcribe with {provider} - Paid + {#if paid}Paid{/if} {/if} diff --git a/src/ui/PodcastView/TranscriptPanel.test.ts b/src/ui/PodcastView/TranscriptPanel.test.ts index 3ae56d42..63c22d07 100644 --- a/src/ui/PodcastView/TranscriptPanel.test.ts +++ b/src/ui/PodcastView/TranscriptPanel.test.ts @@ -618,6 +618,16 @@ describe("an episode whose feed publishes no transcript", () => { ).toHaveAccessibleName("Transcribe with Deepgram Paid"); }); + test("names the user's own server without calling it paid", async () => { + get(plugin).settings.transcript.provider = "openai-compatible"; + plugin.update((host) => host); + await openTranscript(episodeOf([]), 0); + + expect( + await screen.findByRole("button", { name: /^Transcribe with/ }), + ).toHaveAccessibleName("Transcribe with your server"); + }); + test("offers no paid transcription for a video", async () => { await openTranscript(episodeOf([], { mediaType: "video" }), 0); diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 5de11402..b3e59a88 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -192,10 +192,13 @@ describe("sections", () => { "Transcripts", [ "Transcription provider", + "Server address", "OpenAI API key", "Deepgram API key", "ElevenLabs API key", "OpenRouter API key", + "OpenAI-compatible server API key", + "Model", "Model", "Speaker labels", "Speaker label format", @@ -338,6 +341,7 @@ describe("Transcripts", () => { ["deepgram", "Deepgram"], ["openrouter", "OpenRouter"], ["openai", "OpenAI"], + ["openai-compatible", "OpenAI-compatible server"], ]); expect(provider()).toHaveDisplayValue("OpenAI"); expect(keyButtons()).toEqual(["Link... OpenAI API key"]); @@ -527,6 +531,96 @@ describe("Transcripts", () => { expect(await writes()).toBe(0); }); }); + + describe("OpenAI-compatible server", () => { + /** The names of the rows on screen from the Transcripts heading to the next, in order. */ + const shownRows = (container: HTMLElement) => { + const rows = [...container.querySelectorAll(".setting-item")]; + const start = rows.findIndex((row) => row.textContent === "Transcripts") + 1; + const end = rows.findIndex( + (row, index) => index >= start && row.classList.contains("setting-item-heading"), + ); + return rows + .slice(start, end) + .filter((row) => row.style.display !== "none") + .map((row) => row.querySelector(".setting-item-name")?.textContent); + }; + const textbox = (ui: ReturnType["ui"], name: string) => + ui.queryByRole("textbox", { name }); + + test("choosing it shows its address, optional key and model text rows, and saves what is typed", async () => { + fakeTypingTime(); + try { + const { ui, provider, keyButtons, disk } = openTranscripts(); + expect(textbox(ui, "Server address")).toBeNull(); + expect(textbox(ui, "Model")).toBeNull(); + + choose(provider(), "openai-compatible"); + + expect(textbox(ui, "Server address")).toHaveAttribute( + "placeholder", + "http://localhost:8000/v1", + ); + expect(keyButtons()).toEqual(["Link... OpenAI-compatible server API key"]); + expect(ui.queryByRole("combobox", { name: "Model" })).toBeNull(); + type(textbox(ui, "Server address")!, "http://localhost:8000/v1"); + type(textbox(ui, "Model")!, "whisper-large-v3-turbo"); + vi.advanceTimersByTime(500); + + expect((await disk()).transcript).toMatchObject({ + provider: "openai-compatible", + serverUrl: "http://localhost:8000/v1", + model: "whisper-large-v3-turbo", + }); + + choose(provider(), "openai"); + + expect(textbox(ui, "Server address")).toBeNull(); + expect(textbox(ui, "Model")).toBeNull(); + expect(ui.getByRole("combobox", { name: "Model" })).toBeVisible(); + } finally { + vi.useRealTimers(); + } + }); + + test("its rows come in order: provider, address, key, model, then the shared rows", () => { + const { tab } = openTranscripts(transcript({ provider: "openai-compatible" })); + + expect(shownRows(tab.containerEl)).toEqual([ + "Transcription provider", + "Server address", + "OpenAI-compatible server API key", + "Model", + "Speaker labels", + "Speaker label format", + "Transcript path", + "Transcript template", + ]); + }); + + test("an address without http:// or https:// says so under the field", () => { + const { ui } = openTranscripts(transcript({ provider: "openai-compatible" })); + const address = textbox(ui, "Server address")!; + const error = () => + address.closest(".setting-item")?.querySelector(".setting-item-error")?.textContent; + + enter(address, "localhost:8000/v1"); + expect(error()).toBe("The address must start with http:// or https://."); + + enter(address, "http://localhost:8000/v1"); + expect(error()).toBeUndefined(); + }); + + test("Speaker labels can only be turned off, as for any model without them", () => { + const { ui, speakerLabels } = openTranscripts( + transcript({ provider: "openai-compatible", ...withSpeakerLabels(false) }), + ); + + expect(speakerLabels()).not.toBeChecked(); + expect(speakerLabels()).toBeDisabled(); + expect(ui.getByText("OpenAI-compatible server does not label speakers.")).toBeVisible(); + }); + }); }); describe("text fields", () => { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 39e9aa2b..2f9fd0c5 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -350,11 +350,38 @@ export class PodNotesSettingsTab extends PluginSettingTab { ]), group("Transcripts", [ this.providerRow(), + { + ...this.textRow("Server address", { + ...this.text((settings) => settings.transcript, "serverUrl"), + placeholder: "http://localhost:8000/v1", + preview: (value, setting) => { + const { server } = transcriptionChoice({ + transcript: { + ...this.plugin.settings.transcript, + serverUrl: value, + }, + }); + setting.setErrorMessage( + value.trim() && server && !server.endpoint + ? "The address must start with http:// or https://." + : null, + ); + }, + }), + visible: () => this.onServer(), + }, ...CREDENTIAL_KINDS.map((kind) => ({ ...this.secretRow(kind), visible: () => transcriptionChoice(this.plugin.settings).provider === kind, })), this.modelRow(), + { + ...this.textRow( + "Model", + this.text((settings) => settings.transcript, "model"), + ), + visible: () => this.onServer(), + }, this.speakerLabelsRow(), { ...this.textRow( @@ -676,10 +703,18 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } + /** The provider is a server at an address the user enters, running the model they name. */ + private onServer(): boolean { + return transcriptionChoice(this.plugin.settings).server !== undefined; + } + private modelRow(): SettingDefinitionRender { return { name: "Model", - visible: () => transcriptionChoice(this.plugin.settings).provider !== undefined, + visible: () => { + const { provider, server } = transcriptionChoice(this.plugin.settings); + return provider !== undefined && !server; + }, render: (setting) => { const stops: (() => void)[] = []; setting.addDropdown((dropdown) => { @@ -689,7 +724,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }); const draw = (settings: IPodNotesSettings) => { const choice = transcriptionChoice(settings); - if (choice.provider === undefined) return; + if (choice.provider === undefined || choice.server) return; void refreshModels(choice.provider); const models = offeredModels(choice.provider); const name = ({ label, ends }: { label: string; ends?: string }) => diff --git a/src/utility/assertFetchableUrl.ts b/src/utility/assertFetchableUrl.ts index bff4c8e7..eb3811c1 100644 --- a/src/utility/assertFetchableUrl.ts +++ b/src/utility/assertFetchableUrl.ts @@ -312,15 +312,26 @@ export class UnsafeFetchUrlError extends Error { } } +export interface TargetPolicyOptions { + /** + * Also accepts this device and private networks. Only for an address the user typed for + * a server of their own, never for one a feed, a file or a provider gave. + */ + readonly privateHosts?: boolean; +} + /** * Applies the synchronous target policy and returns the canonical encoded * HTTP(S) target. Credentials remain confined to this returned URL; failures * expose only a stable reason and redacted message. */ -export function assertFetchableUrl(rawUrl: string): URL { +export function assertFetchableUrl( + rawUrl: string, + { privateHosts = false }: TargetPolicyOptions = {}, +): URL { const policy = parseTargetPolicy(rawUrl); if (!policy.ok) throw new UnsafeFetchUrlError(policy.reason); - if (policy.value.hostClassification === "non-public-or-special-literal") { + if (!privateHosts && policy.value.hostClassification === "non-public-or-special-literal") { throw new UnsafeFetchUrlError("non-public-or-special-host"); } @@ -330,9 +341,9 @@ export function assertFetchableUrl(rawUrl: string): URL { } /** Non-throwing companion to {@link assertFetchableUrl}. */ -export function isFetchableUrl(rawUrl: string): boolean { +export function isFetchableUrl(rawUrl: string, options?: TargetPolicyOptions): boolean { try { - assertFetchableUrl(rawUrl); + assertFetchableUrl(rawUrl, options); return true; } catch { return false; diff --git a/src/utility/networkRequest.test.ts b/src/utility/networkRequest.test.ts index 50727cc7..eb7f58a2 100644 --- a/src/utility/networkRequest.test.ts +++ b/src/utility/networkRequest.test.ts @@ -155,6 +155,34 @@ describe("requestWithTimeout target gate and forwarding", () => { expect(request).not.toHaveBeenCalled(); }); + it("reaches this device or a private network only when asked, under every other rule", async () => { + const request = implementation(); + const local = "http://127.0.0.1:8000/v1/audio/transcriptions"; + + expectRedactedError( + await rejectionOf(requestWithTimeout(local, { request })), + "unsafe-target", + ); + expect(request).not.toHaveBeenCalled(); + + await requestWithTimeout(local, { request, privateHosts: true }); + await requestWithTimeout("http://localhost/v1", { request, privateHosts: true }); + expect(request.mock.calls.map(([{ url }]) => url)).toEqual([local, "http://localhost/v1"]); + + for (const target of [ + "ftp://127.0.0.1/v1", + " http://localhost/v1", + "http://localhost/v1#part", + "http:localhost/v1", + ]) { + expectRedactedError( + await rejectionOf(requestWithTimeout(target, { request, privateHosts: true })), + "unsafe-target", + ); + } + expect(request).toHaveBeenCalledTimes(2); + }); + it("supports an injected request implementation without weakening policy", async () => { const request = implementation(Promise.resolve(makeResponse({ bytes: 2 }))); @@ -182,6 +210,7 @@ describe("requestWithTimeout option and body bounds", () => { { acceptedStatuses: [600] }, { signal: {} }, { request: {} }, + { privateHosts: "yes" }, ])("rejects invalid options before transport: %j", async (candidate) => { const error = await rejectionOf( requestWithTimeout( @@ -221,6 +250,7 @@ describe("requestWithTimeout option and body bounds", () => { contentType: "text/plain", headers, body: "body", + privateHosts: false, request, }; const options = Object.create(null) as NetworkRequestOptions; diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts index 9956a457..639baa7e 100644 --- a/src/utility/networkRequest.ts +++ b/src/utility/networkRequest.ts @@ -94,6 +94,8 @@ export interface NetworkRequestOptions { readonly contentType?: string; readonly headers?: Readonly>; readonly body?: string | ArrayBuffer; + /** Lets the target be on this device or a private network. See `TargetPolicyOptions`. */ + readonly privateHosts?: boolean; /** @internal Trusted test seam. Never populate from application or user input. */ readonly request?: NetworkRequestImplementation; } @@ -121,6 +123,7 @@ interface PreparedNetworkRequestOptions { readonly contentType?: string; readonly headers?: Record; readonly body?: string | ArrayBuffer; + readonly privateHosts: boolean; readonly request: NetworkRequestImplementation; } @@ -134,6 +137,7 @@ interface NetworkRequestOptionsSnapshot { readonly contentType: unknown; readonly headers: unknown; readonly body: unknown; + readonly privateHosts: unknown; readonly request: unknown; } @@ -267,6 +271,7 @@ function snapshotOptions(value: unknown): NetworkRequestOptionsSnapshot { "contentType", "headers", "body", + "privateHosts", "request", ]); } catch { @@ -324,6 +329,9 @@ function prepareOptions(value: unknown): PreparedNetworkRequestOptions { if (snapshot.request !== undefined && typeof snapshot.request !== "function") { throw invalidOptions(); } + if (snapshot.privateHosts !== undefined && typeof snapshot.privateHosts !== "boolean") { + throw invalidOptions(); + } const acceptedStatuses = copyAcceptedStatuses(snapshot.acceptedStatuses); const signal = prepareAbortSignal(snapshot.signal); @@ -340,6 +348,7 @@ function prepareOptions(value: unknown): PreparedNetworkRequestOptions { contentType: snapshot.contentType, headers, body, + privateHosts: snapshot.privateHosts === true, request: (snapshot.request as NetworkRequestImplementation | undefined) ?? requestUrl, }; } catch (error) { @@ -386,9 +395,9 @@ function validateRequestBody( return value as ArrayBuffer; } -function assertTarget(rawUrl: string): string { +function assertTarget(rawUrl: string, privateHosts: boolean): string { try { - const validated = assertFetchableUrl(rawUrl); + const validated = assertFetchableUrl(rawUrl, { privateHosts }); return validated.href; } catch { throw new NetworkError("unsafe-target"); @@ -603,7 +612,7 @@ async function requestProjected( body: prepared.body, throw: false, // Keep the target gate as the final validation before listener registration. - url: assertTarget(url), + url: assertTarget(url, prepared.privateHosts), }; const response = await runNativeRequest( prepared.request, diff --git a/tests/e2e/journey-resilience.test.ts b/tests/e2e/journey-resilience.test.ts index cf1b20ac..f9492353 100644 --- a/tests/e2e/journey-resilience.test.ts +++ b/tests/e2e/journey-resilience.test.ts @@ -53,6 +53,7 @@ import { removeItunesStub, removeOpenAIStub, removeOpenRouterStub, + SERVER_SPEECH, startFixtureServer, transcriptionUploads, } from "./journey/fixtures"; @@ -1056,6 +1057,57 @@ describe("resilience journeys", () => { ); }); + test("J-TR-13 an OpenAI-compatible server on this machine gets the episode's audio, with no key, and its word times time the note", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const spoken: FixtureEpisode = { title: "Spoken Episode", seconds: 4, pubDate: pubDate(0) }; + const show: FixtureFeed = { + title: "Journey Own Server", + slug: "own-server", + artwork: true, + episodes: [spoken], + }; + const server = await serve(show); + await seedLibrary(context, server, { + subscriptions: [show], + current: storedEpisode(server, show, spoken), + transcription: { + provider: "openai-compatible", + secretId: "", + model: "whisper-large-v3-turbo", + serverUrl: `${server.transcriptionServerUrl}/`, + }, + }); + const notePath = `Transcripts/${show.title}/${spoken.title}.md`; + + await runCommand(obsidian, `${PLUGIN_ID}:podnotes-transcribe`); + await sandbox.waitForExists(notePath, { timeoutMs: 30_000 }); + + const link = (time: number) => + `obsidian://podnotes?episodeName=Spoken%20Episode&url=${encodeURIComponent(server.feedUrl(show))}&time=${time}`; + expect( + (await readVaultText(obsidian, sandbox.path(notePath))).replace( + /^Date: .+$/m, + "Date: ", + ), + ).toBe( + `# Spoken Episode\n\nPodcast: ${show.title}\nDate: \n\n` + + `[00:00:00](${link(0)}) ${SERVER_SPEECH[0]}\n\n` + + `[00:00:12](${link(12)}) ${SERVER_SPEECH[1]}`, + ); + const audio = server.audio(show, spoken); + expect(server.transcriptions()).toEqual([ + { + authorization: null, + model: "whisper-large-v3-turbo", + responseFormat: "verbose_json", + timestampGranularities: ["word", "segment"], + fileBytes: audio.byteLength, + sha256: createHash("sha256").update(audio).digest("hex"), + }, + ]); + }, 60_000); + test("J-TR-05 feed text cannot give the transcript note a block ID or an inline field", async () => { const context = getContext(); const { obsidian, sandbox } = context; diff --git a/tests/e2e/journey/fixtures.ts b/tests/e2e/journey/fixtures.ts index 4d6fd070..e35af565 100644 --- a/tests/e2e/journey/fixtures.ts +++ b/tests/e2e/journey/fixtures.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import type { ObsidianClient } from "obsidian-e2e"; @@ -51,9 +52,26 @@ export type FixtureServer = Readonly<{ /** Serves `episode` under `title` from now on, with its GUID and media unchanged. */ retitle(feed: FixtureFeed, episode: FixtureEpisode, title: string): void; requests(): readonly string[]; + /** The address of the OpenAI-compatible transcription server this server also is. */ + transcriptionServerUrl: string; + /** Each request the transcription server answered, oldest first. */ + transcriptions(): readonly ServerTranscription[]; stop(): Promise; }>; +export type ServerTranscription = Readonly<{ + authorization: string | null; + model: string | null; + responseFormat: string | null; + timestampGranularities: readonly string[]; + fileBytes: number; + sha256: string | null; +}>; + +const TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions"; +/** What the transcription server hears in any audio: one sentence at 0 s and one 12 s in. */ +export const SERVER_SPEECH = ["Journey transcript opens here.", "A second speaker answers."]; + const SAMPLE_RATE = 8000; export function pubDate(daysBefore: number): string { @@ -62,6 +80,7 @@ export function pubDate(daysBefore: number): string { export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise { const requests: string[] = []; + const transcriptions: ServerTranscription[] = []; const routes = new Map(); const holds = new Map< string, @@ -80,6 +99,10 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise response.writeHead(503).end(); return; } + if (request.method === "POST" && path === TRANSCRIPTIONS_PATH) { + void answerTranscription(request, response, transcriptions); + return; + } const route = routes.get(path); if (!route) { response.writeHead(404).end(); @@ -206,6 +229,8 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise }); }, requests: () => [...requests], + transcriptionServerUrl: `${origin}/v1`, + transcriptions: () => [...transcriptions], stop: () => new Promise((resolve) => { for (const hold of holds.values()) hold.release(); @@ -215,6 +240,49 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise }; } +/** Answers as a Whisper server does, with verbose_json words, or json text when asked for that. */ +async function answerTranscription( + request: http.IncomingMessage, + response: http.ServerResponse, + transcriptions: ServerTranscription[], +): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk as Buffer); + const form = await new Request("http://127.0.0.1/", { + method: "POST", + headers: { "content-type": request.headers["content-type"] ?? "" }, + body: Buffer.concat(chunks), + }).formData(); + const file = form.get("file"); + const bytes = file instanceof Blob ? new Uint8Array(await file.arrayBuffer()) : null; + const field = (name: string) => { + const value = form.get(name); + return typeof value === "string" ? value : null; + }; + transcriptions.push({ + authorization: request.headers.authorization ?? null, + model: field("model"), + responseFormat: field("response_format"), + timestampGranularities: form + .getAll("timestamp_granularities[]") + .filter((value) => typeof value === "string"), + fileBytes: bytes?.byteLength ?? 0, + sha256: bytes && createHash("sha256").update(bytes).digest("hex"), + }); + const words = SERVER_SPEECH.flatMap((sentence, index) => + sentence.split(" ").map((word, at) => ({ + word, + start: index * 12 + at * 0.4, + end: index * 12 + at * 0.4 + 0.3, + })), + ); + const text = SERVER_SPEECH.join(" "); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify(field("response_format") === "verbose_json" ? { text, words } : { text }), + ); +} + export function opmlDocument(entries: ReadonlyArray<{ title: string; url: string }>): string { const outlines = entries .map( diff --git a/tests/e2e/journey/library.ts b/tests/e2e/journey/library.ts index 22fef31d..a2c6aa99 100644 --- a/tests/e2e/journey/library.ts +++ b/tests/e2e/journey/library.ts @@ -54,10 +54,11 @@ export type LibrarySeed = Readonly<{ }>; /** The provider the library transcribes with, and the SecretStorage ID of its key. */ transcription?: Readonly<{ - provider: "openai" | "elevenlabs" | "openrouter"; + provider: "openai" | "elevenlabs" | "openrouter" | "openai-compatible"; secretId: string; /** The recommended model when left out. */ model?: string; + serverUrl?: string; }>; transcriptTemplate?: string; noteTemplate?: string; @@ -340,9 +341,10 @@ export async function seedLibrary( ]), ); if (seed.transcription) { - const { provider, secretId, model = "" } = seed.transcription; + const { provider, secretId, model = "", serverUrl = "" } = seed.transcription; data.transcript.provider = provider; data.transcript.model = model; + data.transcript.serverUrl = serverUrl; data[CREDENTIALS[provider].settingsKey] = secretId; } if (seed.transcriptTemplate) data.transcript.template = seed.transcriptTemplate; diff --git a/tests/mocks/transcriptionService.ts b/tests/mocks/transcriptionService.ts index 63efcdc0..147af64e 100644 --- a/tests/mocks/transcriptionService.ts +++ b/tests/mocks/transcriptionService.ts @@ -75,10 +75,15 @@ export function createMockPlugin( deepgramKey?: string; elevenLabsKey?: string; openRouterKey?: string; + openAICompatibleKey?: string; + /** The SecretStorage ID the settings name for the OpenAI-compatible server's key. */ + openAICompatibleSecretId?: string; /** The stored provider ID. OpenAI when left out. */ provider?: string; /** The stored model ID. The recommended model when left out. */ model?: string; + /** The stored server address. */ + serverUrl?: string; /** The settings ask for speaker labels. */ speakers?: boolean; /** In place of a repository with no saved feed. */ @@ -95,8 +100,11 @@ export function createMockPlugin( deepgramKey = "", elevenLabsKey = "", openRouterKey = "", + openAICompatibleKey = "", + openAICompatibleSecretId = openAICompatibleKey ? "openai-compatible-secret" : "", provider = "openai", model = "", + serverUrl = "", speakers = false, feeds, podcast = mockEpisode, @@ -113,6 +121,14 @@ export function createMockPlugin( deepgram: deepgramKey, elevenlabs: elevenLabsKey, openrouter: openRouterKey, + "openai-compatible": openAICompatibleKey, + }; + const secretIds: Record = { + openai: openAIKey ? "openai-secret" : "", + deepgram: "", + elevenlabs: "", + openrouter: "", + "openai-compatible": openAICompatibleSecretId, }; return { @@ -121,12 +137,14 @@ export function createMockPlugin( deepgramSecretId: "", elevenLabsSecretId: "", openRouterSecretId: "", + openAICompatibleSecretId, timestamp: { template: "- {{linktime}} ", offset: 5 }, transcript: { path: "Transcripts/{{podcast}}/{{title}}.md", template: "# {{title}}\n\n{{transcript}}", provider, model, + serverUrl, diarization: { enabled: speakers, speakerTemplate: "" }, }, download: { @@ -142,9 +160,10 @@ export function createMockPlugin( credentials: { get: vi.fn((_settings, kind: CredentialKind) => keys[kind] || null), has: vi.fn((_settings, kind: CredentialKind) => Boolean(keys[kind])), - status: vi.fn((_settings, kind: CredentialKind) => - keys[kind] ? "available" : "unconfigured", - ), + status: vi.fn((_settings, kind: CredentialKind) => { + if (keys[kind]) return "available"; + return secretIds[kind] ? "missing" : "unconfigured"; + }), }, api: { podcast,