diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 6a3c9143..0ebf937c 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -251,14 +251,14 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete the design has one. The states are `first-run`, `add-podcast`, `library`, `episodes`, `error-feed`, `queue`, `playlists`, `playlist`, `latest`, `played`, `local-files`, `offline` and `status-bar`, and the player's - `now-playing`, `mini-player`, `capture`, `chapters`, `up-next`, + `now-playing`, `mini-player`, `capture`, `chapters`, `up-next`, `transcript`, `up-next-empty`, `no-chapters`, `video`, `local-player` and `media-error`. `set player` runs the player's states and `set all` every state; both go on past a failed state and fail at the end. `set` opens each page by its page JSON, so a state works on any version of the view. A player state plays an episode until its media loads and pauses it at a fixed second. - `capture`, `chapters`, `up-next` and `up-next-empty` then press the full - player's `Timestamp`, `Chapters` or `Up next` button, and fail at once + `capture`, `chapters`, `up-next`, `up-next-empty` and `transcript` then press the full + player's `Timestamp`, `Chapters`, `Up next` or `Transcript` button, and fail at once without a button that reads exactly that, as on a full player before v3. `shot` hides Obsidian's status bar, except in `status-bar`, which puts a `9 properties` item in it over the mini player and has no phone shot, because @@ -269,7 +269,7 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete `library`, `retitle`, `requests`, `audio [vault path]` or `stop`. `library` prints the URL of a manifest of eight podcasts, one whose feed answers 404, and one more feed to add. Their episodes' media is silence as long as the feed - says. `#213` has chapters, and `Signal & Noise` has a 10 s video episode that + says. `#213` has chapters and a WebVTT transcript, and `Signal & Noise` has a 10 s video episode that ffmpeg makes on its first request and one whose media answers 404. `retitle` renames the first feed's two episodes to `Renamed Opening Show` and `Renamed Closing Show`, keeping their GUIDs, until the server stops. `requests` prints the paths diff --git a/.claude/skills/verify/fixtures.mjs b/.claude/skills/verify/fixtures.mjs index f5d67978..c7f50477 100755 --- a/.claude/skills/verify/fixtures.mjs +++ b/.claude/skills/verify/fixtures.mjs @@ -9,7 +9,7 @@ // podcasts as PodNotes saves them (Night Owl Science's feed // answers 404) and one more feed to add. Each episode's // media is silence as long as its feed says. #213 has -// chapters; Signal & Noise has a video episode, a 10 s +// chapters and a WebVTT transcript with voices; Signal & Noise has a video episode, a 10 s // test pattern ffmpeg makes on first request, and an // 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 @@ -206,10 +206,86 @@ const transcript = (file) => fs.readFileSync(path.join(transcriptFixtures, file) /** * @typedef {object} EpisodeExtra * @property {[startTime: number, title: string][]} [chapters] Podcasting 2.0 JSON chapters. + * @property {[startTime: number, speaker: string, text: string][]} [transcript] A WebVTT transcript with voices. * @property {boolean} [video] The media is the test pattern. * @property {boolean} [missing] The media answers 404. */ +/** @type {[number, string, string][]} */ +const TRANSCRIPT_213 = [ + [ + 2172, + "Maya Lind", + "Let's get practical. Someone is listening on a walk right now. What do they actually do?", + ], + [ + 2179, + "Ana Ilić", + "Pick a place you know by heart. Your childhood home works for most people.", + ], + [ + 2187, + "Ana Ilić", + "Not a place you visited once. A place you could walk through with your eyes closed.", + ], + [2195, "Maya Lind", "Okay. I've got my grandmother's flat."], + [ + 2199, + "Ana Ilić", + "Perfect. Take the first idea from the book you're reading and put it at the front door.", + ], + [ + 2207, + "Ana Ilić", + "But make it strange. Give it a smell or a sound. Make it too big for the doorway.", + ], + [2216, "Ana Ilić", "The brain is lazy about ordinary things. It holds on to the absurd."], + [2224, "Maya Lind", "So the trick is that you're not memorizing a list."], + [2229, "Ana Ilić", "Exactly. You're walking through a place you already know."], + [ + 2236, + "Ana Ilić", + "Each room holds one idea, and the order of the rooms is the order of the argument.", + ], + [2245, "Maya Lind", "And when you need it back?"], + [2248, "Ana Ilić", "You take the walk again. Hallway, kitchen, the stairs."], + [2256, "Ana Ilić", "People are always surprised how much comes back, and how fast."], + [ + 2264, + "Maya Lind", + "Does it hold up for abstract ideas, though? Arguments, not lists of things?", + ], + [ + 2273, + "Ana Ilić", + "That's where it gets interesting. You have to turn the abstract into a scene.", + ], + [2281, "Ana Ilić", "'Opportunity cost' becomes a man at your front door holding two keys,"], + [2287, "Ana Ilić", "and he will only give you one of them."], + [2297, "Maya Lind", "I'm never going to forget that man now."], + [2301, "Ana Ilić", "That's the point. The stranger the image, the longer it stays."], + [2308, "Ana Ilić", "Then spaced repetition does the rest. You revisit the walk a day later,"], + [2314, "Ana Ilić", "then a week later, then a month later."], + [2321, "Maya Lind", "Which brings us to the apps. Do you use any?"], + [2326, "Ana Ilić", "Honestly? Mostly a pen and the margins of the book."], + [2333, "Ana Ilić", "The app can schedule the walk. It can't take it for you."], +]; + +/** @param {number} seconds */ +function vttClock(seconds) { + const pad = (/** @type {number} */ n) => String(n).padStart(2, "0"); + return `${pad(Math.floor(seconds / 3600))}:${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}.000`; +} + +/** @param {[number, string, string][]} cues */ +function vtt(cues) { + const blocks = cues.map(([start, speaker, text], i) => { + const end = cues[i + 1]?.[0] ?? start + 7; + return `${vttClock(start)} --> ${vttClock(end)}\n${text}`; + }); + return `WEBVTT\n\n${blocks.join("\n\n")}\n`; +} + // The design prototype's sample library, so captures compare with its screens. /** @type {LibraryPodcast[]} */ const LIBRARY = [ @@ -225,6 +301,7 @@ const LIBRARY = [ "2026-10-02", 4350, { + transcript: TRANSCRIPT_213, chapters: [ [0, "Intro"], [220, "Why we forget"], @@ -371,6 +448,7 @@ function libraryMedia({ slug, episodes }, n) { ? { file: `${route}.mp4`, type: "video/mp4", length: 0 } : { file: `${route}.wav`, type: "audio/wav", length: silentWav(seconds).length }), chaptersUrl: extra?.chapters && `${base}${route}.json`, + transcriptUrl: extra?.transcript && `${base}${route}.vtt`, }; } @@ -386,7 +464,7 @@ function libraryFeedXml(podcast) { ${new Date(`${date}T12:00:00Z`).toUTCString()} ${xml(title)}. - ${seconds}${media.chaptersUrl ? `\n\t\t\t` : ""} + ${seconds}${media.chaptersUrl ? `\n\t\t\t` : ""}${media.transcriptUrl ? `\n\t\t\t` : ""} `; }); return ` @@ -502,6 +580,12 @@ function serve() { Buffer.from(JSON.stringify({ version: "1.2.0", chapters })), ]; } + if (extra?.transcript) { + routes[`${route}.vtt`] = [ + "text/vtt; charset=utf-8", + Buffer.from(vtt(extra.transcript)), + ]; + } if (extra?.video) { /** @type {Promise | undefined} */ let made; diff --git a/.claude/skills/verify/screens.mjs b/.claude/skills/verify/screens.mjs index f0685d17..8dc0b514 100644 --- a/.claude/skills/verify/screens.mjs +++ b/.claude/skills/verify/screens.mjs @@ -36,7 +36,7 @@ // States: first-run, add-podcast, library, episodes, error-feed, queue, // playlists, playlist, latest, played, local-files, offline, status-bar, and the // player's: now-playing, mini-player, capture, chapters, up-next, up-next-empty, -// no-chapters, video, local-player, media-error. player runs the player's states +// transcript, no-chapters, video, local-player, media-error. player runs the player's states // and all runs every state; both go on past a failed state and fail at the end. // status-bar is the queue with the mini player and a "9 properties" item in // Obsidian's status bar, which that state's shots keep. It has no phone shot: @@ -44,8 +44,8 @@ // A player state plays the scene's current episode until its media loads, then // pauses it at a fixed second, and opens the full player over the Library; // mini-player stays on the podcast's page. media-error waits for the load to -// fail instead. capture, chapters, up-next and up-next-empty then press the full -// player's Timestamp, Chapters or Up next button with real input, so they fail +// fail instead. capture, chapters, up-next, up-next-empty and transcript then press the full +// player's Timestamp, Chapters, Up next or Transcript button with real input, so they fail // at once on a full player without a button that reads exactly that. capture // keeps the "Timestamp added to" notice in its shots. import { execFileSync } from "node:child_process"; diff --git a/.claude/skills/verify/screens/scenes.mjs b/.claude/skills/verify/screens/scenes.mjs index 5149b955..1568f275 100644 --- a/.claude/skills/verify/screens/scenes.mjs +++ b/.claude/skills/verify/screens/scenes.mjs @@ -112,6 +112,11 @@ export const STATES = { // #213's last chapter. chapters: { ...FULL_PLAYER, press: "Chapters", text: () => "Listener question" }, "up-next": { ...FULL_PLAYER, press: "Up next", text: () => QUEUE[0][1] }, + transcript: { + ...FULL_PLAYER, + press: "Transcript", + text: () => "and he will only give you one of them.", + }, "up-next-empty": { ...FULL_PLAYER, extra: (data) => { diff --git a/AGENTS.md b/AGENTS.md index 6082e69e..f0c36a49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ a tsconfig matches no files, which `lint:tsconfig` catches. `tests/tooling/svelteCheck.test.ts` runs the real `check:a11y` in temporary workspaces and requires it to fail on each of these cases. -After `npm run build`, `npm run size` enforces 330,000 raw bytes and 100,000 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 330,000 raw bytes and 103,000 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/scripts/size-baseline.md b/scripts/size-baseline.md index 2997e665..79a3a30e 100644 --- a/scripts/size-baseline.md +++ b/scripts/size-baseline.md @@ -12,6 +12,18 @@ 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 103,000 bytes. 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 +numbers above and the rejection proof below were measured against the old +budget. With the panel: + +```text +main.js raw: 320728 bytes; budget 330000 bytes; 9272 bytes remaining +main.js gzip (level 9): 101619 bytes; budget 103000 bytes; 1381 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 e9a4a4b4..a0023fec 100644 --- a/scripts/size.mjs +++ b/scripts/size.mjs @@ -10,7 +10,7 @@ try { 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: 100_000 }, + { name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 103_000 }, ]; 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 56252347..9bdb6d9f 100644 --- a/scripts/size.test.ts +++ b/scripts/size.test.ts @@ -30,7 +30,7 @@ describe("bundle budget CLI", () => { const result = check("gzip.js", randomBytes(110_000)); expect(result.status).toBe(1); expect(result.stdout).toMatch( - /gzip \(level 9\): \d+ bytes; budget 100000 bytes; OVER by \d+ bytes/, + /gzip \(level 9\): \d+ bytes; budget 103000 bytes; OVER by \d+ bytes/, ); expect(result.stdout).toContain("budget 330000 bytes; 220000 bytes remaining"); }); diff --git a/src/services/TranscriptionService.published.test.ts b/src/services/TranscriptionService.published.test.ts index a7d878af..abf3ca07 100644 --- a/src/services/TranscriptionService.published.test.ts +++ b/src/services/TranscriptionService.published.test.ts @@ -134,6 +134,41 @@ describe("published transcripts", () => { expect(diarizeWithDeepgramMock).not.toHaveBeenCalled(); }); + test("a transcript quoting its own credential-bearing address and its private feed's keeps neither in the note", async () => { + const feed = "https://private.example.com/rss?auth=FEED_SENTINEL"; + const captions = "https://private.example.com/caption.vtt?token=TX_SENTINEL"; + fetchTextMock.mockResolvedValue( + `WEBVTT\n\n00:00:00.480 --> 00:00:04.410\nFeed ${feed} and captions ${captions}\n`, + ); + const plugin = createMockPlugin({ + openAIKey: "", + podcast: { ...mockEpisode, feedUrl: feed }, + feedUrl: feed, + transcripts: [{ url: "caption.vtt?token=TX_SENTINEL", type: "text/vtt" }], + }); + const service = new TranscriptionService(plugin); + + await service.transcribeCurrentEpisode(); + await finished(service, "Saved the transcript the feed publishes."); + + expect(fetchTextMock).toHaveBeenCalledWith(captions, expect.anything()); + const hidden = "podnotes-private-feed:Test\\%20Podcast"; + expect(plugin.app.vault.create).toHaveBeenCalledExactlyOnceWith( + "Transcripts/Test Podcast/Test Episode.md", + `# Test Episode\n\n00:00:00 **Host ${hidden}:** Feed ${hidden} and captions ${hidden}`, + ); + }); + + test("saving only the feed's transcript never sends the audio to a provider", async () => { + const plugin = createMockPlugin({ transcripts: [] }); + + await new TranscriptionService(plugin).transcribeCurrentEpisode({ paid: false }); + + expect(noticeMessages).toEqual(["No published transcript was found for this episode."]); + expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("links of unknown types keep today's key notice", async () => { const plugin = createMockPlugin({ openAIKey: "", diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index c1fc5516..72a74277 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -59,7 +59,8 @@ export class TranscriptionService { this.plugin = plugin; } - async transcribeCurrentEpisode(): Promise { + /** `paid: false` stops at a Notice when no published transcript is found, instead of sending the audio to the user's provider. */ + async transcribeCurrentEpisode({ paid = true } = {}): Promise { if (this.disposed) return; const episode = this.plugin.api.podcast; if (!episode) { @@ -79,6 +80,8 @@ export class TranscriptionService { if (transcript) { this.enqueue({ episode, source: { kind: "published", transcript } }); + } else if (!paid) { + new Notice("No published transcript was found for this episode."); } else if (this.hasRequiredKey()) { this.enqueue({ episode, source: { kind: "audio" } }); } else { @@ -235,6 +238,7 @@ export class TranscriptionService { const parsed = await this.waitForLifecycle( fetchPublishedTranscript( this.plugin.feeds, + episode, transcript, this.lifetimeAbortController.signal, ), diff --git a/src/services/publishedTranscript/index.test.ts b/src/services/publishedTranscript/index.test.ts index 02b102b4..6df7b0d3 100644 --- a/src/services/publishedTranscript/index.test.ts +++ b/src/services/publishedTranscript/index.test.ts @@ -219,6 +219,7 @@ describe("fetchPublishedTranscript", () => { const transcript = await fetchPublishedTranscript( feeds, + episodeOf(show), { key: "Show", reference: "transcripts/1.vtt", format: "vtt" }, signal, ); @@ -242,6 +243,7 @@ describe("fetchPublishedTranscript", () => { await expect( fetchPublishedTranscript( feeds, + episodeOf(show), { key: "Hidden", reference: "1.vtt", format: "vtt" }, signal, ), @@ -268,6 +270,7 @@ describe("fetchPublishedTranscript", () => { await expect( fetchPublishedTranscript( feeds, + episodeOf(show), { key: "Show", reference: "1.vtt", format: "vtt" }, controller.signal, ), @@ -281,6 +284,7 @@ describe("fetchPublishedTranscript", () => { await expect( fetchPublishedTranscript( feeds, + episodeOf(show), { key: "Show", reference: "1.json", format: "json" }, signal, ), @@ -295,6 +299,7 @@ describe("fetchPublishedTranscript", () => { await expect( fetchPublishedTranscript( feeds, + episodeOf(show), { key: "Gone", reference: "1.vtt", format: "vtt" }, signal, ), @@ -311,7 +316,7 @@ describe("fetchPublishedTranscript", () => { fetchTextMock.mockResolvedValue(transcriptFixture("build-your-saas.vtt")); const source = await findPublishedTranscript(feeds, episodeOf(hidden)); - await fetchPublishedTranscript(feeds, source!, signal); + await fetchPublishedTranscript(feeds, episodeOf(hidden), source!, signal); const stored = [...storage.values.values()].map(String).join("\n"); expect(stored).toContain('"url":"1.vtt"'); diff --git a/src/services/publishedTranscript/index.ts b/src/services/publishedTranscript/index.ts index 72a53a60..ef77d7f1 100644 --- a/src/services/publishedTranscript/index.ts +++ b/src/services/publishedTranscript/index.ts @@ -1,7 +1,13 @@ import type { TranscriptLink } from "src/parser/feedDocumentParser"; import type { FeedKey, FeedRepository } from "src/services/FeedRepository"; +import { withoutPrivateFeedAddresses } from "src/services/privateFeeds"; import type { Episode } from "src/types/Episode"; import { NetworkError, fetchTextWithTimeout } from "src/utility/networkRequest"; +import { + isCredentialBearingUrl, + privateFeedPlaceholder, + redactPrivateFeedAddresses, +} from "src/utility/privateFeedUrl"; import { type ParsedTranscript, parseTranscript, type TranscriptFormat } from "./parse"; export { renderTranscriptBody, timestampStyle } from "./render"; @@ -58,8 +64,13 @@ export class PublishedTranscriptError extends Error { } } +/** + * The episode's published transcript. Its text keeps no private address, neither its private + * feed's nor its own when that carries a credential, so every reader of it gets the same text. + */ export async function fetchPublishedTranscript( feeds: Pick, + episode: Episode, source: PublishedTranscript, signal: AbortSignal, ): Promise { @@ -83,9 +94,15 @@ export async function fetchPublishedTranscript( `The published transcript could not be fetched: ${error instanceof NetworkError ? error.message : "Network request failed."}`, ); } - const transcript = parseTranscript(source.format, text); - if (!transcript) { + const parsed = parseTranscript(source.format, text); + if (!parsed) { throw new PublishedTranscriptError("The published transcript could not be read."); } - return transcript; + const [, transcript] = withoutPrivateFeedAddresses([episode, parsed] as const); + return isCredentialBearingUrl(url) + ? redactPrivateFeedAddresses( + transcript, + new Map([[url, privateFeedPlaceholder(source.key)]]), + ) + : transcript; } diff --git a/src/services/publishedTranscript/render.ts b/src/services/publishedTranscript/render.ts index 27f30d60..89ae3fab 100644 --- a/src/services/publishedTranscript/render.ts +++ b/src/services/publishedTranscript/render.ts @@ -1,5 +1,4 @@ import type { Episode } from "src/types/Episode"; -import { withoutPrivateFeedAddresses } from "src/services/privateFeeds"; import { DEFAULT_SPEAKER_TEMPLATE, formatSpeakerLabel } from "src/services/diarization/segments"; import { formatEpisodeTimestamp } from "src/utility/formatEpisodeTimestamp"; import { collapseWhitespace, type ParsedTranscript, type TranscriptCue } from "./parse"; @@ -25,9 +24,14 @@ export interface TranscriptParagraph { readonly text: string; } +/** A group takes no more cues once it spans `hard` seconds, or `soft` seconds and ends a sentence. */ +export interface GroupLimits { + readonly soft: number; + readonly hard: number; +} + const PARAGRAPH_GAP_SECONDS = 5; -const PARAGRAPH_SOFT_SECONDS = 30; -const PARAGRAPH_HARD_SECONDS = 60; +const PARAGRAPHS: GroupLimits = { soft: 30, hard: 60 }; const ENDS_SENTENCE = /[.!?]["')\]]*$/; /** @@ -41,12 +45,15 @@ export const MAX_PARAGRAPH_LENGTH = 2000; /** A speaker label repeats on each of its paragraphs. Real names stay under 20 characters. */ const MAX_SPEAKER_LENGTH = 100; -export function groupCues(cues: readonly TranscriptCue[]): TranscriptParagraph[] { +export function groupCues( + cues: readonly TranscriptCue[], + limits = PARAGRAPHS, +): TranscriptParagraph[] { const paragraphs: TranscriptParagraph[] = []; for (const cue of cues) { const end = cue.end ?? cue.start; const last = paragraphs[paragraphs.length - 1]; - if (last && continues(last, cue)) { + if (last && continues(last, cue, limits)) { paragraphs[paragraphs.length - 1] = { ...last, end: Math.max(last.end, end), @@ -59,13 +66,17 @@ export function groupCues(cues: readonly TranscriptCue[]): TranscriptParagraph[] return paragraphs; } -function continues(paragraph: TranscriptParagraph, cue: TranscriptCue): boolean { +function continues( + paragraph: TranscriptParagraph, + cue: TranscriptCue, + { soft, hard }: GroupLimits, +): boolean { const span = paragraph.end - paragraph.start; return ( (cue.speaker === undefined || cue.speaker === paragraph.speaker) && cue.start - paragraph.end < PARAGRAPH_GAP_SECONDS && - span < PARAGRAPH_HARD_SECONDS && - !(span >= PARAGRAPH_SOFT_SECONDS && ENDS_SENTENCE.test(paragraph.text)) && + span < hard && + !(span >= soft && ENDS_SENTENCE.test(paragraph.text)) && paragraph.text.length + 1 + cue.text.length <= MAX_PARAGRAPH_LENGTH ); } @@ -98,11 +109,9 @@ function splitText(text: string, max: number): string[] { } export function renderTranscriptBody( - parsed: ParsedTranscript, + transcript: ParsedTranscript, options: { episode: Episode; speakerTemplate: string; timestamp: TimestampStyle }, ): string { - // Escaping puts backslashes inside an address, so redact before it. - const [, transcript] = withoutPrivateFeedAddresses([options.episode, parsed] as const); if (transcript.kind === "untimed") { return transcript.paragraphs .flatMap((text) => splitText(escapeTranscriptText(text), MAX_PARAGRAPH_LENGTH)) diff --git a/src/types/IPodNotes.ts b/src/types/IPodNotes.ts index b3096455..dbf18939 100644 --- a/src/types/IPodNotes.ts +++ b/src/types/IPodNotes.ts @@ -3,6 +3,7 @@ import type { PlayerController } from "src/player/PlayerController"; import type { CaptureService } from "src/services/CaptureService"; import type { FeedRepository } from "src/services/FeedRepository"; import type { FeedUrlRepository } from "src/services/FeedUrlRepository"; +import type { TranscriptionService } from "src/services/TranscriptionService"; import type { Navigation } from "src/ui/PodcastView/Navigation"; import type { Readable } from "svelte/store"; import type { IPodNotesSettings } from "./IPodNotesSettings"; @@ -26,4 +27,5 @@ export interface IPodNotes { showPlayer(): Promise; openSettingsTab(): void; setAutoQueue(enabled: boolean): void; + getTranscriptionService(): TranscriptionService; } diff --git a/src/types/IconType.ts b/src/types/IconType.ts index c459da63..62ab801b 100644 --- a/src/types/IconType.ts +++ b/src/types/IconType.ts @@ -105,6 +105,7 @@ export const ICON_LIST = [ "calendar", "camera-off", "camera", + "captions", "car", "carrot", "cast", diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index 75c54ac2..75c9a50b 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -31,7 +31,7 @@
  • moveTab(event, index)}>{PANEL_LABELS[kind]} {/each} @@ -171,7 +180,7 @@ aria-pressed={shown === kind} disabled={failed} onclick={() => (panel = shown === kind ? undefined : kind)} - >{kind === "chapters" ? "Chapters" : "Up next"}{PANEL_LABELS[kind]} {/each} diff --git a/src/ui/PodcastView/Player.test.ts b/src/ui/PodcastView/Player.test.ts index 62d03714..2c831f35 100644 --- a/src/ui/PodcastView/Player.test.ts +++ b/src/ui/PodcastView/Player.test.ts @@ -382,12 +382,14 @@ describe("the player's panels", () => { expect(player.querySelector(".pn-np-art")).not.toBeNull(); }); - test("an episode without chapters offers no Chapters", async () => { + test("an episode without chapters offers its Transcript and Up next, no Chapters", async () => { await openPlayer(); expect( - within(screen.getByRole("group", { name: "Player panels" })).getAllByRole("button"), - ).toEqual([switchButton("Up next")]); + within(screen.getByRole("group", { name: "Player panels" })) + .getAllByRole("button") + .map((choice) => choice.textContent), + ).toEqual(["Transcript", "Up next"]); }); test("at wide width, the right column shows Chapters, or Up next without chapters", async () => { @@ -411,7 +413,10 @@ describe("the player's panels", () => { await tick(); await tick(); - expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Up next"]); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Transcript", + "Up next", + ]); expect(screen.getByRole("tab", { name: "Up next" })).toHaveAttribute( "aria-selected", "true", @@ -421,7 +426,7 @@ describe("the player's panels", () => { test("at wide width, arrow keys, Home and End move focus between the tabs and show each one's panel", async () => { await openPlayer(withChapters, 820); - await vi.waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); + await vi.waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(3)); const tabs = () => screen.getAllByRole("tab").map((tab) => ({ name: tab.textContent, @@ -436,17 +441,23 @@ describe("the player's panels", () => { }; screen.getByRole("tab", { name: "Chapters" }).focus(); + expect(await key("ArrowRight")).toEqual([ + "Transcript", + screen.getByRole("tab", { name: "Transcript" }).id, + ]); expect(await key("ArrowRight")).toEqual([ "Up next", screen.getByRole("tab", { name: "Up next" }).id, ]); expect(tabs()).toEqual([ { name: "Chapters", selected: "false", tabIndex: -1, focused: false }, + { name: "Transcript", selected: "false", tabIndex: -1, focused: false }, { name: "Up next", selected: "true", tabIndex: 0, focused: true }, ]); expect(within(screen.getByRole("tabpanel")).getByText("Queue is empty")).toBeVisible(); expect((await key("ArrowRight"))[0]).toBe("Chapters"); expect((await key("ArrowLeft"))[0]).toBe("Up next"); + expect((await key("ArrowLeft"))[0]).toBe("Transcript"); expect((await key("Home"))[0]).toBe("Chapters"); expect((await key("Home"))[0]).toBe("Chapters"); expect((await key("End"))[0]).toBe("Up next"); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index a454f66b..a4d1e1de 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -48,6 +48,8 @@ function renderView() { settings: readable(structuredClone(DEFAULT_SETTINGS)), captureTimestamp: async () => null, openSettings: () => {}, + saveTranscriptNote: () => {}, + transcribeFromAudio: () => {}, setAutoQueue: () => {}, hasNote: () => false, rowHasNote: readable(() => false), diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 3b8561d7..07a35fa2 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -8,6 +8,7 @@ autofocus = false, large = false, oninput, + onkeydown, }: { value: string; placeholder: string; @@ -15,6 +16,7 @@ /** The first run's wider, taller field. */ large?: boolean; oninput(value: string): void; + onkeydown?(event: KeyboardEvent): void; } = $props(); let input: HTMLInputElement; @@ -34,6 +36,7 @@ if (!(event as Event as InputEvent).isComposing) oninput(event.currentTarget.value); }} oncompositionend={(event) => oninput(event.currentTarget.value)} + {onkeydown} {@attach (el) => { if (!autofocus) return; // A view mounts before Obsidian reveals its leaf, and a hidden field cannot take focus. diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte new file mode 100644 index 00000000..494e5dbb --- /dev/null +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -0,0 +1,382 @@ + + +
    + + {#if load.kind === "loading"} +
    Loading transcript
    + {:else if load.kind === "error"} + + {:else if load.kind === "none"} +
    + + {isLocalFile(episode) + ? "No transcript yet" + : "No transcript in this feed"} + {#if audio} + + {/if} +
    + {:else if shown.length === 0} +
  • No lines match
  • + {:else} + {#each shown as { line, index }, at (index)} +
  • + {#if line.speaker && line.speaker !== shown[at - 1]?.line.speaker} +
    {line.speaker}
    + {/if} + {#if line.start === undefined} +

    + {@render text(line.text)} +

    + {:else} + {@const start = line.start} + + {/if} +
  • + {/each} + {/if} + +
    + {#if current >= 0 && !following && !finding} + + {/if} +
    + + +{#snippet text(value: string)}{#if needle}{#each pieces(value) as piece, i (i)}{#if i % 2}{piece}{:else}{piece}{/if}{/each}{:else}{value}{/if}{/snippet} + +{#snippet head()} + {#if finding} + { + query = value; + hit = 0; + }} + onkeydown={findKey} + /> + + {:else} + + Feed transcript + Free + + + + {/if} +{/snippet} + + diff --git a/src/ui/PodcastView/TranscriptPanel.test.ts b/src/ui/PodcastView/TranscriptPanel.test.ts new file mode 100644 index 00000000..41280c61 --- /dev/null +++ b/src/ui/PodcastView/TranscriptPanel.test.ts @@ -0,0 +1,494 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, within } from "@testing-library/svelte"; +import { SecretStorage } from "obsidian"; +import { tick } from "svelte"; +import { get } from "svelte/store"; +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + onTestFinished, + test, + vi, +} from "vitest"; +import type { ParsedFeedEpisodes, TranscriptLink } from "src/parser/feedDocumentParser"; +import { FeedRepository } from "src/services/FeedRepository"; +import { FeedUrlRepository } from "src/services/FeedUrlRepository"; +import { plugin } from "src/store"; +import type { Episode } from "src/types/Episode"; +import { NetworkError } from "src/utility/networkRequest"; +import { privateFeedPlaceholder } from "src/utility/privateFeedUrl"; +import type { FakeDocument } from "../../../tests/mocks/fakeMedia"; +import { feedItem, MemoryStorage } from "../../../tests/mocks/feeds"; +import type { Keymap as FakeKeymap } from "../../../tests/mocks/obsidian"; +import { + installResizeObserver, + type FakeResizeObservers, +} from "../../../tests/mocks/resizeObserver"; +import { createTestServices, type TestServices } from "../../../tests/mocks/services"; +import PodcastView from "./PodcastView.svelte"; + +const fetchTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("src/utility/networkRequest", async (importOriginal) => ({ + ...(await importOriginal()), + fetchTextWithTimeout: fetchTextMock, +})); + +const CUES: ReadonlyArray = [ + ["Maya Lind", "Welcome back to Field Notes."], + ["Maya Lind", "Today we build a memory palace together."], + ["Ana Ilic", "Where does the palace start?"], + ["Ana Ilic", "At your own front door."], + ["Maya Lind", "Then every room holds one idea."], + ["Maya Lind", "The stranger the image, the longer it stays."], + ["Ana Ilic", "And spaced repetition does the rest."], + ["Ana Ilic", "Walk the Palace again a day later."], + ["Maya Lind", "Then a week later, then a month later."], + ["Maya Lind", "That is the whole method."], +]; +const LINES = CUES.map(([, text]) => text); +const clock = (seconds: number) => `00:00:${String(seconds).padStart(2, "0")}.000`; +const VTT = `WEBVTT\n\n${CUES.map( + ([speaker, text], index) => + `${clock(index * 6)} --> ${clock(index * 6 + 6)}\n${text}`, +).join("\n\n")}\n`; +const VTT_LINK: TranscriptLink = { url: "1.vtt", type: "text/vtt" }; +const SECRET = "https://private.test/s3cret/feed.xml?auth=s3cret"; + +let services: TestServices; +let doc: FakeDocument; +let dispose: () => void; +let widths: FakeResizeObservers; +let feeds: FeedRepository; +let loadFeed: Mock<(url: string) => Promise>; +let episodes: Array<{ episode: Episode; transcripts: TranscriptLink[] }>; +let made = 0; + +function episodeOf(transcripts: TranscriptLink[], overrides: Partial = {}): Episode { + made++; + const episode: Episode = { + title: `Episode ${made}`, + streamUrl: `https://pod.test/${made}.mp3`, + url: "", + description: "", + content: "", + podcastName: "Field Notes", + feedUrl: "https://pod.test/feed.xml", + duration: 1800, + ...overrides, + }; + episodes.push({ episode, transcripts }); + return episode; +} + +beforeEach(() => { + episodes = []; + fetchTextMock.mockReset(); + fetchTextMock.mockResolvedValue(VTT); + loadFeed = vi.fn(async () => ({ + feed: { title: "Field Notes" }, + episodes: episodes.map(({ episode, transcripts }) => ({ + ...feedItem(episode), + transcripts, + })), + })); + feeds = new FeedRepository({ + load: loadFeed, + resolveUrl: (feed) => feed.url, + storage: new MemoryStorage(), + now: Date.now, + }); + ({ services, doc, dispose } = createTestServices({ feeds })); + services.library.savedFeeds.set({ + "Field Notes": { title: "Field Notes", url: "https://pod.test/feed.xml", artworkUrl: "" }, + }); + widths = installResizeObserver(); +}); + +afterEach(() => { + widths.uninstall(); + dispose(); + vi.restoreAllMocks(); +}); + +async function openTranscript(played: Episode, time = 0, width = 320) { + const result = render(PodcastView, { props: { services } }); + widths.resize(result.container.querySelector(".podcast-view")!, width); + services.player.play(played); + doc.media.duration = 1800; + doc.media.emit("loadedmetadata"); + services.player.pause(); + doc.media.currentTime = time; + doc.media.emit("timeupdate"); + services.navigation.expand(); + await tick(); + await fireEvent.click(switchButton("Transcript")); + return result; +} + +async function at(time: number) { + doc.media.currentTime = time; + doc.media.emit("timeupdate"); + await tick(); +} + +const switchButton = (name: string) => + within(screen.getByRole("group", { name: "Player panels" })).getByRole("button", { name }); +const button = (name: string | RegExp) => screen.getByRole("button", { name }); +const transcript = () => screen.findByRole("list", { name: "Transcript" }); +const lineButton = (text: string) => + within(screen.getByRole("list", { name: "Transcript" })).getByRole("button", { + name: new RegExp(`${text}$`), + }); +const rows = () => + within(screen.getByRole("list", { name: "Transcript" })) + .getAllByRole("listitem") + .map((row) => row.textContent?.replace(/\s+/g, " ").trim()); +const currentLine = () => + within(screen.getByRole("list", { name: "Transcript" })).queryByRole("button", { + current: true, + })?.lastElementChild?.textContent; +const hitLine = () => + screen.getByRole("list", { name: "Transcript" }).querySelector(".is-hit")?.lastElementChild + ?.textContent; +const marks = () => [...document.querySelectorAll("mark")].map((mark) => mark.textContent); +const keymap = () => services.app.keymap as unknown as FakeKeymap; +const press = (key: string, target: Element = document.activeElement ?? document.body) => { + const event = new KeyboardEvent("keydown", { key, bubbles: true }); + Object.defineProperty(event, "target", { value: target }); + keymap().dispatch(event); +}; + +/** + * Lays the list's rows out 40 px apart in a list 120 px tall, as the browser would: its + * scroll position stays within the rows, and a change to it fires `scroll` soon after. + */ +function layOut(list: HTMLElement) { + let top = 0; + Object.defineProperty(list, "clientHeight", { configurable: true, value: 120 }); + Object.defineProperty(list, "scrollTop", { + configurable: true, + get: () => top, + set: (value: number) => { + const next = Math.max(0, Math.min(value, list.children.length * 40 - 120)); + if (next === top) return; + top = next; + queueMicrotask(() => list.dispatchEvent(new Event("scroll"))); + }, + }); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + const y = this === list ? 0 : [...list.children].indexOf(this) * 40 - top; + return { top: y, bottom: y + 40, left: 0, right: 300 } as DOMRect; + }); +} + +describe("a published transcript", () => { + test("lists its lines with their times and speakers, and marks the line playing now", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + + expect(rows()).toEqual([ + "Maya Lind 0:00 Welcome back to Field Notes.", + "0:06 Today we build a memory palace together.", + "Ana Ilic 0:12 Where does the palace start?", + "0:18 At your own front door.", + "Maya Lind 0:24 Then every room holds one idea.", + "0:30 The stranger the image, the longer it stays.", + "Ana Ilic 0:36 And spaced repetition does the rest.", + "0:42 Walk the Palace again a day later.", + "Maya Lind 0:48 Then a week later, then a month later.", + "0:54 That is the whole method.", + ]); + expect(currentLine()).toBe(LINES[0]); + await at(7); + expect(currentLine()).toBe(LINES[1]); + await at(43); + expect(currentLine()).toBe(LINES[7]); + + await fireEvent.click(button("Find in transcript")); + await fireEvent.input(screen.getByRole("searchbox", { name: "Find in transcript" }), { + target: { value: "palace" }, + }); + expect(currentLine()).toBe(LINES[7]); + }); + + test("a tapped line plays from its start", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + expect(get(services.player.state).status).toBe("paused"); + + await fireEvent.click(lineButton(LINES[6])); + + expect(get(services.player.state)).toMatchObject({ currentTime: 36, status: "playing" }); + expect(currentLine()).toBe(LINES[6]); + }); + + test("follows the line playing now until the user scrolls, then Follow brings it back", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + const list = await transcript(); + layOut(list); + + await at(13); + await tick(); + expect(list.scrollTop, "row 2, a third down").toBe(40); + expect(screen.queryByRole("button", { name: "Follow" })).toBeNull(); + + list.scrollTop = 30; + await tick(); + await tick(); + const follow = button("Follow"); + expect(follow.closest("[aria-live=polite]")).not.toBeNull(); + await at(19); + await tick(); + expect(list.scrollTop, "the reader stays where they scrolled").toBe(30); + + await fireEvent.click(follow); + await tick(); + expect(list.scrollTop, "row 3, a third down").toBe(80); + expect(screen.queryByRole("button", { name: "Follow" })).toBeNull(); + expect(get(services.player.state).status).toBe("paused"); + }); + + test("Find shows the lines with a word, Enter and Shift+Enter step through them, and Escape closes Find before the player", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + await fireEvent.click(button("Find in transcript")); + const field = screen.getByRole("searchbox", { name: "Find in transcript" }); + + await fireEvent.input(field, { target: { value: "PALACE" } }); + expect(rows()).toEqual([ + "Maya Lind 0:06 Today we build a memory palace together.", + "Ana Ilic 0:12 Where does the palace start?", + "0:42 Walk the Palace again a day later.", + ]); + expect(marks()).toEqual(["palace", "palace", "Palace"]); + expect(hitLine()).toBe(LINES[1]); + const step = async (shiftKey: boolean) => { + await fireEvent.keyDown(field, { key: "Enter", shiftKey }); + return hitLine(); + }; + expect([await step(false), await step(false), await step(false)]).toEqual([ + LINES[2], + LINES[7], + LINES[1], + ]); + expect([await step(true), await step(true)]).toEqual([LINES[7], LINES[2]]); + + await fireEvent.input(field, { target: { value: "zebra" } }); + expect(rows()).toEqual(["No lines match"]); + + press("Escape", field); + await tick(); + expect(services.navigation.current.expanded, "the player stays open").toBe(true); + expect(screen.queryByRole("searchbox")).toBeNull(); + expect(rows()).toHaveLength(10); + expect(button("Find in transcript")).toHaveFocus(); + + press("Escape"); + await tick(); + expect(services.navigation.current.expanded).toBe(false); + }); + + test("another episode's transcript opens with Find closed and every line shown", async () => { + const second = episodeOf([VTT_LINK]); + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + await fireEvent.click(button("Find in transcript")); + await fireEvent.input(screen.getByRole("searchbox", { name: "Find in transcript" }), { + target: { value: "palace" }, + }); + expect(rows()).toHaveLength(3); + + services.player.play(second); + await tick(); + + await vi.waitFor(() => expect(rows()).toHaveLength(LINES.length)); + expect(screen.queryByRole("searchbox", { name: "Find in transcript" })).toBeNull(); + expect(marks()).toEqual([]); + }); + + test("Save as note hands the note to the transcribe command and writes nothing itself", async () => { + const vault = { create: vi.fn(), modify: vi.fn(), process: vi.fn() }; + Object.assign(services.app, { vault }); + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + + await fireEvent.click(button("Save as note")); + + expect(services.saveTranscriptNote).toHaveBeenCalledExactlyOnceWith(); + expect([vault.create, vault.modify, vault.process].map((spy) => spy.mock.calls)).toEqual([ + [], + [], + [], + ]); + expect(fetchTextMock).toHaveBeenCalledOnce(); + services.online.set(false); + await tick(); + expect(button("Save as note")).toBeDisabled(); + }); + + test("Save as note saves a video's feed transcript too, which needs no audio", async () => { + await openTranscript(episodeOf([VTT_LINK], { mediaType: "video" }), 0); + await transcript(); + + await fireEvent.click(button("Save as note")); + + expect(services.saveTranscriptNote).toHaveBeenCalledExactlyOnceWith(); + }); + + test("an untimed transcript reads as paragraphs, with no time to play from or follow", async () => { + fetchTextMock.mockResolvedValue("First paragraph.\n\nSecond paragraph."); + await openTranscript(episodeOf([{ url: "1.txt", type: "text/plain" }]), 30); + const list = await transcript(); + + list.scrollTop = 30; + await fireEvent.scroll(list); + + expect(rows()).toEqual(["First paragraph.", "Second paragraph."]); + expect(within(list).queryAllByRole("button")).toEqual([]); + expect(list.querySelector("[aria-current]")).toBeNull(); + expect(screen.queryByRole("button", { name: "Follow" })).toBeNull(); + + await fireEvent.click(button("Find in transcript")); + const field = screen.getByRole("searchbox", { name: "Find in transcript" }); + await fireEvent.input(field, { target: { value: "paragraph" } }); + await fireEvent.keyDown(field, { key: "Enter" }); + expect( + screen + .getByRole("list", { name: "Transcript" }) + .querySelector(".is-hit") + ?.textContent?.trim(), + ).toBe("Second paragraph."); + }); + + test("shows once loaded: back from Up next it neither loads again nor fetches again", async () => { + let answer!: (text: string) => void; + fetchTextMock.mockReturnValue(new Promise((resolve) => (answer = resolve))); + await openTranscript(episodeOf([VTT_LINK]), 0); + expect(await screen.findByText("Loading transcript")).toHaveAttribute("role", "status"); + answer(VTT); + await transcript(); + + await fireEvent.click(switchButton("Up next")); + await fireEvent.click(switchButton("Transcript")); + + expect(screen.getByRole("list", { name: "Transcript" })).toBeInTheDocument(); + expect(screen.queryByText("Loading transcript")).toBeNull(); + expect(fetchTextMock).toHaveBeenCalledOnce(); + }); +}); + +describe("a transcript that did not load", () => { + test("offers Retry, which fetches it again", async () => { + fetchTextMock.mockRejectedValueOnce(new NetworkError("unexpected-status", 500)); + await openTranscript(episodeOf([VTT_LINK]), 0); + await screen.findByText("Transcript didn't load"); + + await fireEvent.click(button("Retry")); + + await transcript(); + expect(fetchTextMock).toHaveBeenCalledTimes(2); + }); + + test("never shows or logs a private feed's address from the failure", async () => { + const logged = (["error", "warn", "info", "log", "debug"] as const).map((level) => + vi.spyOn(console, level), + ); + vi.spyOn(feeds, "itemUrl").mockImplementation(() => { + throw new Error(`Could not resolve 1.vtt against ${SECRET}`); + }); + const { container } = await openTranscript(episodeOf([VTT_LINK]), 0); + await screen.findByText("Transcript didn't load"); + + expect(container.innerHTML).not.toContain("s3cret"); + expect(JSON.stringify(logged.map((spy) => spy.mock.calls))).not.toContain("s3cret"); + }); + + test("never shows the private feed address a saved private feed's transcript quotes", async () => { + const feedUrls = new FeedUrlRepository(new SecretStorage()); + const urlSecretId = feedUrls.store(SECRET); + const placeholder = privateFeedPlaceholder("Field Notes"); + const host = get(plugin); + plugin.set({ + ...host, + feedUrls, + settings: { + ...host.settings, + savedFeeds: { + "Field Notes": { + title: "Field Notes", + url: placeholder, + urlSecretId, + artworkUrl: "", + }, + }, + }, + } as never); + onTestFinished(() => plugin.set(host)); + fetchTextMock.mockResolvedValue( + `WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nYour feed is ${SECRET}\n`, + ); + const { container } = await openTranscript( + episodeOf([VTT_LINK], { feedUrl: placeholder }), + 0, + ); + await transcript(); + + expect(rows()).toEqual([`Host 0:00 Your feed is ${placeholder}`]); + expect(container.innerHTML).not.toContain("s3cret"); + }); + + test("when the feed itself did not load, offers Retry, not the paid transcription", async () => { + const served = loadFeed.getMockImplementation()!; + loadFeed.mockRejectedValue(new Error("offline")); + await openTranscript(episodeOf([VTT_LINK]), 0); + + await screen.findByText("Transcript didn't load"); + expect(screen.queryByRole("button", { name: /^Transcribe with/ })).toBeNull(); + loadFeed.mockImplementation(served); + await fireEvent.click(button("Retry")); + await transcript(); + }); +}); + +describe("an episode whose feed publishes no transcript", () => { + test("offers the paid transcription, off while offline", async () => { + await openTranscript(episodeOf([]), 0); + await screen.findByText("No transcript in this feed"); + const paid = button("Transcribe with OpenAI Paid"); + + await fireEvent.click(paid); + expect(services.transcribeFromAudio).toHaveBeenCalledExactlyOnceWith(); + + services.online.set(false); + await tick(); + expect(paid).toBeDisabled(); + }); + + test("names Deepgram when diarization uses it", async () => { + const { settings } = get(plugin); + settings.transcript.diarization = { + ...settings.transcript.diarization, + enabled: true, + provider: "deepgram", + }; + plugin.update((host) => host); + await openTranscript(episodeOf([]), 0); + + expect( + await screen.findByRole("button", { name: /^Transcribe with/ }), + ).toHaveAccessibleName("Transcribe with Deepgram Paid"); + }); + + test("offers no paid transcription for a video", async () => { + await openTranscript(episodeOf([], { mediaType: "video" }), 0); + + await screen.findByText("No transcript in this feed"); + expect(screen.queryByRole("button", { name: /^Transcribe with/ })).toBeNull(); + }); +}); diff --git a/src/ui/PodcastView/context.ts b/src/ui/PodcastView/context.ts index 9cd6e0af..c4e0f347 100644 --- a/src/ui/PodcastView/context.ts +++ b/src/ui/PodcastView/context.ts @@ -51,6 +51,10 @@ export interface ShellServices { captureTimestamp(): Promise; /** Opens Obsidian's settings at the PodNotes tab. */ openSettings(): void; + /** Saves the feed's published transcript to the episode's note, never the paid transcription. */ + saveTranscriptNote(): void; + /** "Transcribe current episode from audio", the paid transcription. */ + transcribeFromAudio(): void; /** Whether playback queues the episode the user switches from, and plays the queue on. */ setAutoQueue(enabled: boolean): void; /** Whether the episode has a note: a vault lookup, so a caller decides when to ask again. */ diff --git a/src/ui/PodcastView/index.test.ts b/src/ui/PodcastView/index.test.ts index 06905e11..63a72c58 100644 --- a/src/ui/PodcastView/index.test.ts +++ b/src/ui/PodcastView/index.test.ts @@ -131,6 +131,24 @@ describe("MainView mobile startup mounting", () => { expect(mountedServices().scope).toBe(view.scope); }); + it("Save as note asks for the feed's transcript without the paid fallback; Transcribe uses the audio", async () => { + const { plugin, view } = createView({ shouldMount: true }); + const transcription = { + transcribeCurrentEpisode: vi.fn(async () => {}), + transcribeCurrentEpisodeFromAudio: vi.fn(), + }; + Object.assign(plugin, { getTranscriptionService: () => transcription }); + await openView(view); + + mountedServices().saveTranscriptNote(); + mountedServices().transcribeFromAudio(); + + expect(transcription.transcribeCurrentEpisode).toHaveBeenCalledExactlyOnceWith({ + paid: false, + }); + expect(transcription.transcribeCurrentEpisodeFromAudio).toHaveBeenCalledExactlyOnceWith(); + }); + it("mountPodcastView wakes a dormant restored view exactly once", () => { const { contentEl, plugin, view } = createView({ shouldMount: false }); diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index 87148fd8..012dd125 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -141,6 +141,10 @@ export class MainView extends ItemView { settings, captureTimestamp: () => plugin.capture.timestamp(), openSettings: () => plugin.openSettingsTab(), + saveTranscriptNote: () => + void plugin.getTranscriptionService().transcribeCurrentEpisode({ paid: false }), + transcribeFromAudio: () => + plugin.getTranscriptionService().transcribeCurrentEpisodeFromAudio(), setAutoQueue: (enabled) => plugin.setAutoQueue(enabled), hasNote: (episode) => getPodcastNote(episode) !== null, rowHasNote, diff --git a/src/ui/PodcastView/transcript.test.ts b/src/ui/PodcastView/transcript.test.ts new file mode 100644 index 00000000..7bb65d27 --- /dev/null +++ b/src/ui/PodcastView/transcript.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ParsedEpisodeDocument, TranscriptLink } from "src/parser/feedDocumentParser"; +import { FeedRepository } from "src/services/FeedRepository"; +import type { Episode } from "src/types/Episode"; +import { NetworkError } from "src/utility/networkRequest"; +import { MemoryStorage, stubLegacyStorage } from "../../../tests/mocks/feeds"; +import { transcriptFixture } from "../../../tests/mocks/transcriptFixtures"; +import { cachedTranscript, lineAt, loadTranscript } from "./transcript"; + +const fetchTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("src/utility/networkRequest", async (importOriginal) => ({ + ...(await importOriginal()), + fetchTextWithTimeout: fetchTextMock, +})); + +const signal = new AbortController().signal; + +function episode(number: number, transcripts: TranscriptLink[] = []) { + const item: ParsedEpisodeDocument = { + title: `Episode ${number}`, + streamUrl: `https://media.test/${number}.mp3`, + description: "", + content: "", + episodeDate: new Date(Date.UTC(2026, 0, number)), + mediaType: "audio", + transcripts, + }; + const played: Episode = { + title: item.title, + streamUrl: item.streamUrl, + url: "", + description: "", + content: "", + podcastName: "Show", + feedUrl: "https://show.test/feed.xml", + }; + return { item, played }; +} + +function repository(items: ParsedEpisodeDocument[], url = "https://show.test/feed.xml") { + const load = vi.fn(async () => ({ feed: { title: "Show" }, episodes: items })); + const clock = { now: Date.UTC(2026, 9, 12) }; + const feeds = new FeedRepository({ + load, + resolveUrl: (feed) => feed.url, + storage: new MemoryStorage(), + now: () => clock.now, + }); + feeds.setSubscriptions({ Show: { title: "Show", url, artworkUrl: "" } }); + return { feeds, load, clock }; +} + +const SEVEN_HOURS = 7 * 3_600_000; + +const vtt = (...cues: string[]) => + `WEBVTT\n\n${cues.map((cue, index) => `00:00:0${index}.000 --> 00:00:0${index + 1}.000\n${cue}`).join("\n\n")}\n`; + +beforeEach(() => { + stubLegacyStorage(); + fetchTextMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("lineAt", () => { + test("is the last line to start at or before the time, and -1 before the first", () => { + const lines = [{ start: 2 }, { start: 6 }, { start: 12 }]; + + expect([1, 2, 11.9, 12, 500].map((time) => lineAt(lines, time))).toEqual([-1, 0, 1, 2, 2]); + }); +}); + +describe("loadTranscript", () => { + test("merges a word-level transcript into sentence lines a tap can land in", async () => { + const { item, played } = episode(1, [{ url: "1.json", type: "application/json" }]); + const { feeds } = repository([item]); + fetchTextMock.mockResolvedValue(transcriptFixture("buzzcast.json")); + + const transcript = await loadTranscript(feeds, played, signal); + + expect(transcript?.kind).toBe("timed"); + expect(transcript?.lines.slice(0, 3)).toEqual([ + { + start: 0.16, + end: 15.04, + speaker: "Alban", + text: "Moments ago, I just hit a professional milestone, a new accomplishment. All right, I'm gonna be using air quotes for this, but the first code that I ever wrote, and I'm now using air quotes for I wrote is now live in Buzzsprout as of like four minutes ago.", + }, + { + start: 15.2, + end: 17.28, + speaker: "Kevin", + text: "Oh, your PR got accepted and merged?", + }, + { start: 17.44, end: 18.32, speaker: "Alban", text: "It's merged." }, + ]); + expect(fetchTextMock).toHaveBeenCalledWith("https://show.test/1.json", expect.anything()); + }); + + test("an untimed transcript is its paragraphs", async () => { + const { item, played } = episode(2, [{ url: "2.txt", type: "text/plain" }]); + const { feeds } = repository([item]); + fetchTextMock.mockResolvedValue("First paragraph.\n\nSecond paragraph."); + + await expect(loadTranscript(feeds, played, signal)).resolves.toEqual({ + kind: "untimed", + lines: [{ text: "First paragraph." }, { text: "Second paragraph." }], + }); + }); + + test("a feed without a transcript is none, and stays known as none", async () => { + const { item, played } = episode(3); + const { feeds, load } = repository([item]); + + expect(cachedTranscript(played)).toBeUndefined(); + await expect(loadTranscript(feeds, played, signal)).resolves.toBeNull(); + + expect(cachedTranscript(played)).toBeNull(); + expect(load).toHaveBeenCalledOnce(); + expect(fetchTextMock).not.toHaveBeenCalled(); + }); + + test("keeps one episode: loading another replaces it", async () => { + const first = episode(4, [{ url: "4.vtt", type: "text/vtt" }]); + const second = episode(5); + const { feeds } = repository([first.item, second.item]); + fetchTextMock.mockResolvedValue(vtt("Only line.")); + + const loaded = await loadTranscript(feeds, first.played, signal); + expect(cachedTranscript(first.played)).toBe(loaded); + await loadTranscript(feeds, second.played, signal); + + expect(cachedTranscript(first.played)).toBeUndefined(); + expect(cachedTranscript(second.played)).toBeNull(); + }); + + test("a transcript that failed to load is not kept, so the next load fetches it again", async () => { + const { item, played } = episode(6, [{ url: "6.vtt", type: "text/vtt" }]); + const { feeds } = repository([item]); + fetchTextMock.mockRejectedValueOnce(new NetworkError("unexpected-status", 500)); + fetchTextMock.mockResolvedValueOnce(vtt("Back again.")); + + await expect(loadTranscript(feeds, played, signal)).rejects.toThrow( + "The published transcript could not be fetched", + ); + expect(cachedTranscript(played)).toBeUndefined(); + + await expect(loadTranscript(feeds, played, signal)).resolves.toEqual({ + kind: "timed", + lines: [{ start: 0, end: 1, speaker: undefined, text: "Back again." }], + }); + expect(fetchTextMock).toHaveBeenCalledTimes(2); + }); + + test("an episode a cached feed lacks, when the feed fails to refresh, fails rather than reporting no transcript", async () => { + const { item, played } = episode(8, [{ url: "8.vtt", type: "text/vtt" }]); + const { feeds, load, clock } = repository([episode(9).item]); + await feeds.load(["Show"], "cached"); + clock.now += SEVEN_HOURS; + load.mockRejectedValueOnce(new Error("offline")); + + await expect(loadTranscript(feeds, played, signal)).rejects.toThrow(); + expect(cachedTranscript(played)).toBeUndefined(); + + load.mockResolvedValueOnce({ feed: { title: "Show" }, episodes: [item] }); + fetchTextMock.mockResolvedValue(vtt("Found once the feed loads.")); + await expect(loadTranscript(feeds, played, signal)).resolves.toMatchObject({ + kind: "timed", + }); + }); + + test("an episode a cached feed lists without a transcript has none, though the feed failed to refresh", async () => { + const { item, played } = episode(10); + const { feeds, load, clock } = repository([item]); + await feeds.load(["Show"], "cached"); + clock.now += SEVEN_HOURS; + load.mockRejectedValueOnce(new Error("offline")); + + await expect(loadTranscript(feeds, played, signal)).resolves.toBeNull(); + }); + + test.each([ + [ + "timed", + { url: "11.vtt", type: "text/vtt" }, + (feed: string) => vtt(`Your feed: ${feed}`), + ], + ["untimed", { url: "11.txt", type: "text/plain" }, (feed: string) => `Your feed: ${feed}`], + ])( + "%s transcript text quoting the episode's private feed shows no address", + async (_, link, body) => { + const secret = "https://private.test/rss?auth=REVIEW%2FSENTINEL"; + const { item, played } = episode(11, [link]); + const { feeds } = repository([item], secret); + fetchTextMock.mockResolvedValue(body(secret)); + + const shown = await loadTranscript(feeds, { ...played, feedUrl: secret }, signal); + + expect(JSON.stringify(shown)).toContain("Your feed: podnotes-private-feed:Show"); + expect(JSON.stringify(shown)).not.toContain("SENTINEL"); + }, + ); + + test("transcript text quoting the transcript's own credential-bearing address shows no credential", async () => { + const captions = "https://show.test/12.vtt?token=TX_SENTINEL"; + const { item, played } = episode(12, [ + { url: "12.vtt?token=TX_SENTINEL", type: "text/vtt" }, + ]); + const { feeds } = repository([item]); + fetchTextMock.mockResolvedValue(vtt(`Read along at ${captions}`)); + + const shown = await loadTranscript(feeds, played, signal); + + expect(fetchTextMock).toHaveBeenCalledWith(captions, expect.anything()); + expect(shown).toEqual({ + kind: "timed", + lines: [ + { + start: 0, + end: 1, + speaker: "Host podnotes-private-feed:Show", + text: "Read along at podnotes-private-feed:Show", + }, + ], + }); + }); + + test("a feed that did not load fails rather than reporting no transcript", async () => { + const { item, played } = episode(7, [{ url: "7.vtt", type: "text/vtt" }]); + const { feeds, load } = repository([item]); + load.mockRejectedValueOnce(new Error("offline")); + + await expect(loadTranscript(feeds, played, signal)).rejects.toThrow(); + expect(cachedTranscript(played)).toBeUndefined(); + + fetchTextMock.mockResolvedValue(vtt("Found once the feed loads.")); + await expect(loadTranscript(feeds, played, signal)).resolves.toMatchObject({ + kind: "timed", + }); + }); +}); diff --git a/src/ui/PodcastView/transcript.ts b/src/ui/PodcastView/transcript.ts new file mode 100644 index 00000000..9bb13145 --- /dev/null +++ b/src/ui/PodcastView/transcript.ts @@ -0,0 +1,57 @@ +import { get } from "svelte/store"; +import type { FeedRepository } from "src/services/FeedRepository"; +import { + fetchPublishedTranscript, + findPublishedTranscript, +} from "src/services/publishedTranscript"; +import { + type GroupLimits, + groupCues, + type TranscriptParagraph, +} from "src/services/publishedTranscript/render"; +import type { Episode } from "src/types/Episode"; +import { getEpisodeKey } from "src/utility/episodeKey"; + +const SENTENCES: GroupLimits = { soft: 5, hard: 30 }; + +export type Transcript = + | { readonly kind: "timed"; readonly lines: readonly TranscriptParagraph[] } + | { readonly kind: "untimed"; readonly lines: readonly { readonly text: string }[] }; + +/** The last line that starts at or before `time`, or -1 before the first. */ +export function lineAt(lines: readonly { start: number }[], time: number): number { + let at = -1; + for (const [index, line] of lines.entries()) if (line.start <= time) at = index; + return at; +} + +let cached: { key: string; transcript: Transcript | null } | undefined; + +/** Undefined when not cached; null when the episode's feed publishes no transcript. */ +export function cachedTranscript(episode: Episode): Transcript | null | undefined { + return cached?.key === getEpisodeKey(episode) ? cached.transcript : undefined; +} + +/** + * The episode's published transcript as lines, or null when its feed publishes none. Caches + * the result, and throws when the transcript or the feed could not be read. + */ +export async function loadTranscript( + feeds: FeedRepository, + episode: Episode, + signal: AbortSignal, +): Promise { + const key = episode.podcastName; + const source = await findPublishedTranscript(feeds, episode); + const feedFailedBeforeTheEpisode = + !source && !feeds.episodeMetadata(key, episode) && get(feeds).get(key)?.error; + if (feedFailedBeforeTheEpisode) throw new Error("The feed did not load."); + const parsed = source && (await fetchPublishedTranscript(feeds, episode, source, signal)); + const transcript: Transcript | null = !parsed + ? null + : parsed.kind === "timed" + ? { kind: "timed", lines: groupCues(parsed.cues, SENTENCES) } + : { kind: "untimed", lines: parsed.paragraphs.map((text) => ({ text })) }; + cached = { key: getEpisodeKey(episode), transcript }; + return transcript; +} diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts new file mode 100644 index 00000000..488a37e3 --- /dev/null +++ b/tests/e2e/journey-transcript.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { FIXTURE_HOST, SUPERCAST_FIXTURE_HOST } from "../../scripts/e2e-instance.mjs"; +import { createPodNotesE2EHarness } from "./harness"; +import { + dismissNotices, + runCommand, + setMobileEmulation, + setOffline, + showAddPodcast, + showLibrary, + showPodNotes, +} from "./journey/actions"; +import { cdp, click, pressKey, typeInto, typeText } from "./journey/input"; +import { PLAYER_VIEW, ui, type UiTarget } from "./journey/targets"; +import { eventually, isDisabled, isShown, notices, waitUntilShown } from "./journey/wait"; +import { + type FixtureEpisode, + type FixtureFeed, + type FixtureServer, + pubDate, + startFixtureServer, +} from "./journey/fixtures"; +import { + readVaultText, + seedLibrary, + storedEpisode, + waitForLibrary, + waitForPlayback, +} from "./journey/library"; +import { jsLiteral, PLUGIN_ID } from "./runtime"; + +const getContext = createPodNotesE2EHarness("journey-transcript"); + +const CUES: ReadonlyArray = [ + ["Maya Lind", "Welcome back to Field Notes."], + ["Maya Lind", "Today we build a memory palace together."], + ["Ana Ilic", "Where does the palace start?"], + ["Ana Ilic", "At your own front door."], + ["Maya Lind", "Then every room holds one idea."], + ["Maya Lind", "The stranger the image, the longer it stays."], + ["Ana Ilic", "And spaced repetition does the rest."], + ["Ana Ilic", "Walk the Palace again a day later."], + ["Maya Lind", "Then a week later, then a month later."], + ["Maya Lind", "That is the whole method."], +]; +const LINES = CUES.map(([, text]) => text); +const clock = (seconds: number) => `00:00:${String(seconds).padStart(2, "0")}.000`; +const VTT = `WEBVTT\n\n${CUES.map( + ([speaker, text], index) => + `${clock(index * 6)} --> ${clock(index * 6 + 6)}\n${text}`, +).join("\n\n")}\n`; + +const CAPTIONED: FixtureEpisode = { + title: "Memory Palaces", + seconds: 60, + pubDate: pubDate(0), + transcripts: [{ path: "1.vtt", type: "text/vtt", body: VTT }], +}; +const PLAIN: FixtureEpisode = { title: "No Captions", seconds: 60, pubDate: pubDate(1) }; +const FEED: FixtureFeed = { + title: "Journey Transcript", + slug: "transcript", + artwork: true, + episodes: [CAPTIONED, PLAIN], +}; +const NOTE_PATH = `Transcripts/${FEED.title}/${CAPTIONED.title}.md`; + +const TRANSCRIPT_LIST = `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player ol[aria-labelledby]`)})].find((ol) => document.getElementById(ol.getAttribute("aria-labelledby"))?.textContent === "Transcript")`; +const panelButton = (label: string): UiTarget => ({ + name: `transcript panel's ${label}`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-panel button`)})].filter((el) => el.getAttribute("aria-label") === ${jsLiteral(label)} || el.textContent.trim().startsWith(${jsLiteral(label)}))`, +}); +const line = (text: string): UiTarget => ({ + name: `transcript line ${text}`, + elements: `[...(${TRANSCRIPT_LIST})?.querySelectorAll("button") ?? []].filter((el) => el.lastElementChild?.textContent.trim() === ${jsLiteral(text)})`, +}); +const followChip: UiTarget = { + name: "Follow chip", + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-tx-follow`)})]`, +}; +const findField: UiTarget = { + name: "Find in transcript field", + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player input[aria-label="Find in transcript"]`)})]`, +}; + +type ShownTranscript = Readonly<{ + lines: string[]; + current: string | null; + hit: string | null; + marks: string[]; +}>; + +async function shownTranscript(): Promise { + return await getContext().obsidian.dev.evalJson(`(() => { + const list = ${TRANSCRIPT_LIST}; + const text = (row) => row?.lastElementChild?.textContent.trim() ?? null; + const rows = list ? [...list.querySelectorAll("li > :last-child")] : []; + return { + lines: rows.map(text), + current: text(list?.querySelector("[aria-current='true']")), + hit: text(list?.querySelector(".is-hit")), + marks: list ? [...list.querySelectorAll("mark")].map((mark) => mark.textContent) : [], + }; + })()`); +} + +async function serve(): Promise { + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + return server; +} + +async function openTranscript(episode: FixtureEpisode): Promise { + const context = getContext(); + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, episode), + }); + await showPodNotes(context.obsidian); + if (!(await isShown(context.obsidian, ui.fullPlayer))) + await click(context.obsidian, ui.openPlayer); + await waitUntilShown(context.obsidian, ui.fullPlayer); + await click(context.obsidian, ui.playerPanel("Transcript")); + return server; +} + +async function takeNote(): Promise { + const { obsidian, sandbox } = getContext(); + await sandbox.waitForExists(NOTE_PATH, { timeoutMs: 30_000 }); + await obsidian.waitFor( + async () => + (await notices(obsidian)).some((notice) => + notice.endsWith("Saved the transcript the feed publishes."), + ), + { timeoutMs: 10_000, intervalMs: 100, message: "the transcription finished" }, + ); + const text = await readVaultText(obsidian, sandbox.path(NOTE_PATH)); + await obsidian.dev.evalJsonAsync(`(async () => { + await app.vault.delete(app.vault.getAbstractFileByPath(${jsLiteral(sandbox.path(NOTE_PATH))})); + return true; + })()`); + await dismissNotices(obsidian); + return text.replace(/^Date: .+$/m, "Date: "); +} + +describe("transcript journeys", () => { + test("the feed's transcript follows playback, seeks where tapped, finds a word, and saves the command's note", async () => { + const { obsidian } = getContext(); + await openTranscript(CAPTIONED); + const all = { lines: LINES, current: LINES[0], hit: null, marks: [] }; + expect(await eventually(obsidian, shownTranscript, all, 15_000)).toEqual(all); + + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (playback) => playback.playing, "playing from the start"); + const second = { ...all, current: LINES[1] }; + expect( + await eventually(obsidian, shownTranscript, second, 15_000), + "the current line follows playback", + ).toEqual(second); + + await click(obsidian, line(LINES[6])); + const landed = await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds >= 36 && playback.seconds < 40, + "playing from the tapped line", + ); + await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds > landed.seconds + 0.5, + "playback continues past the tapped line", + ); + expect((await shownTranscript()).current).toBe(LINES[6]); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (playback) => !playback.playing, "paused"); + + await click(obsidian, panelButton("Find in transcript")); + await waitUntilShown(obsidian, findField); + await typeText(obsidian, "PALACE"); + const found = { + lines: [LINES[1], LINES[2], LINES[7]], + current: null, + hit: LINES[1], + marks: ["palace", "palace", "Palace"], + }; + expect(await eventually(obsidian, shownTranscript, found), "Find").toEqual(found); + await pressKey(obsidian, "Enter"); + expect((await eventually(obsidian, shownTranscript, { ...found, hit: LINES[2] })).hit).toBe( + LINES[2], + ); + await pressKey(obsidian, "Escape"); + expect(await eventually(obsidian, () => isShown(obsidian, findField), false)).toBe(false); + expect(await isShown(obsidian, ui.fullPlayer), "Escape closes Find, not the player").toBe( + true, + ); + expect((await shownTranscript()).lines).toEqual(LINES); + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + const fromPanel = await takeNote(); + await runCommand(obsidian, `${PLUGIN_ID}:podnotes-transcribe`); + const fromCommand = await takeNote(); + expect(fromPanel).toMatch( + /^# Memory Palaces\n\nPodcast: Journey Transcript\nDate: \n/, + ); + expect(fromPanel).toContain("Today we build a memory palace together."); + expect(fromPanel).toBe(fromCommand); + }, 120_000); + + test("an episode whose feed publishes no transcript offers the paid transcription", async () => { + const { obsidian } = getContext(); + await openTranscript(PLAIN); + const transcribe = panelButton("Transcribe with OpenAI"); + await waitUntilShown(obsidian, transcribe); + + expect( + await obsidian.dev.evalJson( + `(${transcribe.elements})[0].closest(".pn-panel").textContent.replace(/\\s+/g, " ").trim()`, + ), + ).toBe("No transcript in this feed Transcribe with OpenAI Paid"); + expect(await isDisabled(obsidian, transcribe)).toBe(false); + }, 60_000); + + test("offline, the paid transcription is disabled", async () => { + const { obsidian } = getContext(); + await openTranscript(PLAIN); + const transcribe = panelButton("Transcribe with OpenAI"); + await waitUntilShown(obsidian, transcribe); + + await setOffline(obsidian, true); + onTestFinished(() => setOffline(obsidian, false)); + + expect(await eventually(obsidian, () => isDisabled(obsidian, transcribe), true)).toBe(true); + await setOffline(obsidian, false); + expect(await eventually(obsidian, () => isDisabled(obsidian, transcribe), false)).toBe( + false, + ); + }, 60_000); + test("a private feed's transcript that quotes its own token and the feed's address shows and saves neither", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const captions = "caption.vtt?token=E2E_TX_SENTINEL"; + const quoting: FixtureEpisode = { + title: "Quoting Episode", + seconds: 60, + pubDate: pubDate(0), + guid: "quoting", + transcripts: [ + { + path: captions, + type: "text/vtt", + absolute: true, + body: (port) => + `WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nYour feed is http://${SUPERCAST_FIXTURE_HOST}:${port}/feeds/E2E_FEED_SENTINEL/feed.xml and these captions are http://${FIXTURE_HOST}:${port}/quoting-private/${captions}\n`, + }, + ], + }; + const show: FixtureFeed = { + title: "Quoting Private", + slug: "quoting-private", + host: SUPERCAST_FIXTURE_HOST, + path: "/feeds/E2E_FEED_SENTINEL/feed.xml", + artwork: false, + episodes: [quoting], + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server); + await showPodNotes(obsidian); + await showAddPodcast(obsidian); + await typeInto(obsidian, ui.podcastSearch, server.feedUrl(show)); + await click(obsidian, ui.addPodcast(show.title)); + await waitForLibrary(plugin, (library) => library.subscriptions.length === 1, "subscribed"); + const secretId = await obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.settings.savedFeeds[${jsLiteral(show.title)}].urlSecretId`, + ); + onTestFinished(() => + obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(secretId)}, ""), true`), + ); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await click(obsidian, ui.episode(quoting.title)); + await waitForPlayback(obsidian, (playback) => playback.playing, "playing"); + await click(obsidian, ui.playbackToggle); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + + const hidden = "podnotes-private-feed:Quoting%20Private"; + const line = `Your feed is ${hidden} and these captions are ${hidden}`; + expect( + await eventually(obsidian, async () => (await shownTranscript()).lines, [line]), + "the panel's line", + ).toEqual([line]); + expect(server.requests()).toContain(`/quoting-private/${captions}`); + const view = await obsidian.dev.evalJson( + `document.querySelector(${jsLiteral(PLAYER_VIEW)}).innerHTML`, + ); + expect(view).not.toMatch(/E2E_TX_SENTINEL|E2E_FEED_SENTINEL/); + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + const notePath = `Transcripts/${show.title}/${quoting.title}.md`; + await sandbox.waitForExists(notePath, { timeoutMs: 30_000 }); + const note = await obsidian.waitFor( + async () => { + const text = await readVaultText(obsidian, sandbox.path(notePath)); + return text.includes("Your feed is") && text; + }, + { timeoutMs: 10_000, intervalMs: 100, message: "the saved note's text" }, + ); + expect(note).not.toMatch(/E2E_TX_SENTINEL|E2E_FEED_SENTINEL/); + expect(note).toContain( + "Your feed is podnotes-private-feed:Quoting\\%20Private and these captions are podnotes-private-feed:Quoting\\%20Private", + ); + }, 120_000); + + test("on a phone, Save, the Find field, Follow and Up next's Edit are 44 px tall", async () => { + const context = getContext(); + const { obsidian } = context; + const phone = { width: 390, height: 844 }; + await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { + ...phone, + deviceScaleFactor: 1, + mobile: true, + screenWidth: phone.width, + screenHeight: phone.height, + }); + onTestFinished(async () => { + await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); + }); + await setMobileEmulation(obsidian, true); + expect( + await obsidian.dev.evalJson('document.body.classList.contains("is-phone")'), + "a phone", + ).toBe(true); + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + queue: [storedEpisode(server, FEED, PLAIN)], + }); + await showPodNotes(obsidian); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually( + obsidian, + async () => (await shownTranscript()).lines.length, + LINES.length, + ); + const height = (target: UiTarget) => + obsidian.dev.evalJson( + `Math.round((${target.elements})[0]?.getBoundingClientRect().height ?? 0)`, + ); + + const heights: Record = { save: await height(panelButton("Save as note")) }; + await obsidian.dev.evalJson( + `((list) => ((list.scrollTop = list.scrollHeight), true))(${TRANSCRIPT_LIST})`, + ); + await waitUntilShown(obsidian, followChip); + heights.follow = await height(followChip); + await click(obsidian, panelButton("Find in transcript")); + await waitUntilShown(obsidian, findField); + heights.find = await height(findField); + await pressKey(obsidian, "Escape"); + await click(obsidian, ui.playerPanel("Up next")); + await waitUntilShown(obsidian, panelButton("Edit")); + heights.edit = await height(panelButton("Edit")); + + expect(heights).toEqual({ save: 44, follow: 44, find: 44, edit: 44 }); + }, 120_000); +}); diff --git a/tests/e2e/journey/fixtures.ts b/tests/e2e/journey/fixtures.ts index 3c6175b2..1161d7e3 100644 --- a/tests/e2e/journey/fixtures.ts +++ b/tests/e2e/journey/fixtures.ts @@ -8,7 +8,8 @@ export type FixtureTranscript = Readonly<{ path: string; type: string; absolute?: boolean; - body?: string; + /** Or made from the server's port, for a body that quotes the server's own addresses. */ + body?: string | ((port: number) => string); }>; export type FixtureEpisode = Readonly<{ @@ -139,11 +140,11 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise const body = wav(episode.seconds, 300 + 40 * index + 7 * feed.slug.length); routes.set(mediaPath(feed, episode), { type: "audio/wav", body }); } - for (const transcript of episode.transcripts ?? []) { - if (transcript.body === undefined) continue; - routes.set(`/${feed.slug}/${transcript.path}`, { - type: transcript.type, - body: Buffer.from(transcript.body), + for (const { path, type, body } of episode.transcripts ?? []) { + if (body === undefined) continue; + routes.set(`/${feed.slug}/${path}`, { + type, + body: Buffer.from(typeof body === "function" ? body(port) : body), }); } }); diff --git a/tests/mocks/services.ts b/tests/mocks/services.ts index b72a302d..a5cde938 100644 --- a/tests/mocks/services.ts +++ b/tests/mocks/services.ts @@ -76,6 +76,8 @@ export type TestServices = ShellServices & { readonly library: TestLibrary; readonly captureTimestamp: ReturnType Promise>>; readonly openSettings: ReturnType void>>; + readonly saveTranscriptNote: ReturnType void>>; + readonly transcribeFromAudio: ReturnType void>>; readonly setAutoQueue: ReturnType void>>; readonly hasNote: ReturnType boolean>>; readonly rowHasNote: Writable<(episode: Episode) => boolean>; @@ -93,7 +95,15 @@ export type TestServices = ShellServices & { */ export function createTestServices( overrides: Partial< - Omit + Omit< + TestServices, + | "captureTimestamp" + | "openSettings" + | "saveTranscriptNote" + | "transcribeFromAudio" + | "setAutoQueue" + | "hasNote" + > > = {}, ): { services: TestServices; @@ -138,6 +148,8 @@ export function createTestServices( settings: derived(plugin, ($plugin) => $plugin.settings), captureTimestamp: vi.fn(async () => null), openSettings: vi.fn(), + saveTranscriptNote: vi.fn(), + transcribeFromAudio: vi.fn(), setAutoQueue: vi.fn((enabled: boolean) => { settings.autoQueue = enabled; plugin.update((host) => host);