Skip to content

Commit 7bb004a

Browse files
committed
feat(api): expose generated episode transcripts
1 parent f92a91f commit 7bb004a

7 files changed

Lines changed: 190 additions & 13 deletions

File tree

‎docs/docs/api.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
```js
33
export interface IAPI {
44
readonly podcast: Episode;
5+
readonly transcript: Promise<string | null>;
56
readonly isPlaying: boolean;
67
readonly length: number;
78
currentTime: number;
@@ -19,6 +20,7 @@ export interface IAPI {
1920
endTime: number,
2021
linkify?: boolean,
2122
): string;
23+
getTranscript(episode?: Episode): Promise<string | null>;
2224
start(): void;
2325
stop(): void;
2426
togglePlayback(): void;
@@ -49,6 +51,57 @@ export interface Episode {
4951
}
5052
```
5153

54+
## `transcript`
55+
This is a convenience getter for `getTranscript()`:
56+
57+
```js
58+
const transcript = await app.plugins.plugins.podnotes.api.transcript;
59+
```
60+
61+
It returns the generated transcript note for the current episode, or `null` if no
62+
episode is loaded or no transcript file exists yet. It does not call OpenAI or
63+
create a new transcript. Generate the transcript first with PodNotes' **Transcribe
64+
current episode** command.
65+
66+
If your transcript template includes metadata such as the title and date, that
67+
metadata is included in the returned text. Set the transcript template to
68+
`{{transcript}}` if your macro should receive only the transcript body.
69+
70+
## `getTranscript(episode?: Episode)`
71+
This reads the generated transcript note for the provided episode. If no episode
72+
is provided, it reads the current episode's transcript.
73+
74+
```js
75+
const api = app.plugins.plugins.podnotes.api;
76+
const transcript = await api.getTranscript();
77+
```
78+
79+
### QuickAdd AI prompt example
80+
PodNotes exposes the transcript text; the AI summarization is done by your
81+
QuickAdd macro or whichever AI service/plugin your macro calls.
82+
83+
```js
84+
module.exports = async (params) => {
85+
const podnotes = params.app.plugins.plugins.podnotes?.api;
86+
if (!podnotes) throw new Error("PodNotes is not enabled.");
87+
88+
const transcript = await podnotes.transcript;
89+
if (!transcript) {
90+
throw new Error(
91+
"Generate a transcript for the current PodNotes episode first.",
92+
);
93+
}
94+
95+
const prompt = `Summarize this podcast transcript in five bullet points.
96+
97+
Transcript:
98+
${transcript}`;
99+
100+
// Send `prompt` to the AI action/provider used by your QuickAdd macro.
101+
return prompt;
102+
};
103+
```
104+
52105
## `getPodcastTimeFormatted(format: string, linkify?: boolean)`
53106
This function will return the current playback time formatted according to the given (moment) format.
54107
If `linkify` is true, the time will be linked to the current episode at the given time. This is used by PodNotes to play from the recorded time.

‎docs/docs/transcripts.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ To create a transcript:
2121
3. PodNotes will fetch the audio for the episode you are playing (reusing an already-downloaded copy when one exists), split it into chunks, and send these chunks to OpenAI for transcription. The transcription always uses the currently playing episode's own audio, regardless of your download path settings.
2222
4. Once the transcription is complete, a new file will be created at the specified location with the transcribed content.
2323

24+
Generated transcript notes are also available to workflow plugins through the
25+
[PodNotes API](./api.md#transcript), so tools such as QuickAdd or Templater can
26+
read the text and send it to the AI provider configured in your own macro.
27+
2428
## Transcript Template
2529

26-
The transcript template works similarly to the [note template](./templates.md#note-template), but with the added `{{template}}` placeholder.
30+
The transcript template works similarly to the [note template](./templates.md#note-template), but with the added `{{transcript}}` placeholder.
2731

2832
## Speaker Diarization
2933

‎src/API/API.test.ts‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { TFile } from "obsidian";
12
import { get } from "svelte/store";
2-
import { beforeEach, describe, expect, test } from "vitest";
3+
import { beforeEach, describe, expect, test, vi } from "vitest";
34

45
import { API } from "./API";
56
import {
@@ -157,3 +158,86 @@ describe("API playback rate controls", () => {
157158
expect(api.playbackRate).toBe(1.8);
158159
});
159160
});
161+
162+
describe("API transcript access", () => {
163+
function setTranscriptVault(files: Record<string, string>) {
164+
const createTFile = (path: string): TFile =>
165+
Object.assign(new TFile(), { path });
166+
const getAbstractFileByPath = vi.fn((path: string) =>
167+
Object.prototype.hasOwnProperty.call(files, path)
168+
? createTFile(path)
169+
: null,
170+
);
171+
const read = vi.fn(async (file: TFile) => files[file.path] ?? "");
172+
173+
plugin.set({
174+
settings: {
175+
defaultPlaybackRate: 1.8,
176+
transcript: {
177+
path: "Transcripts/{{podcast}}/{{title}}.md",
178+
},
179+
},
180+
app: {
181+
vault: {
182+
getAbstractFileByPath,
183+
read,
184+
},
185+
},
186+
} as never);
187+
188+
return { getAbstractFileByPath, read };
189+
}
190+
191+
test("returns null when no episode is loaded", async () => {
192+
setTranscriptVault({});
193+
const api = new API();
194+
195+
await expect(api.getTranscript()).resolves.toBeNull();
196+
});
197+
198+
test("returns null when the current episode has no generated transcript file", async () => {
199+
currentEpisode.set(feedEpisode);
200+
const { getAbstractFileByPath, read } = setTranscriptVault({});
201+
const api = new API();
202+
203+
await expect(api.getTranscript()).resolves.toBeNull();
204+
205+
expect(getAbstractFileByPath).toHaveBeenCalledWith(
206+
"Transcripts/Feed Podcast/Feed Episode.md",
207+
);
208+
expect(read).not.toHaveBeenCalled();
209+
});
210+
211+
test("reads the generated transcript note for the current episode", async () => {
212+
currentEpisode.set(feedEpisode);
213+
const transcript = "# Feed Episode\n\nTranscript body for AI macros.";
214+
const { read } = setTranscriptVault({
215+
"Transcripts/Feed Podcast/Feed Episode.md": transcript,
216+
});
217+
const api = new API();
218+
219+
await expect(api.getTranscript()).resolves.toBe(transcript);
220+
await expect(api.transcript).resolves.toBe(transcript);
221+
222+
expect(read).toHaveBeenCalledWith(
223+
expect.objectContaining({
224+
path: "Transcripts/Feed Podcast/Feed Episode.md",
225+
}),
226+
);
227+
});
228+
229+
test("reads the generated transcript note for an explicit episode", async () => {
230+
currentEpisode.set(feedEpisode);
231+
const transcript = "Local episode transcript";
232+
const { getAbstractFileByPath } = setTranscriptVault({
233+
"Transcripts/local file/Local Episode.md": transcript,
234+
});
235+
const api = new API();
236+
237+
await expect(api.getTranscript(localEpisode)).resolves.toBe(transcript);
238+
239+
expect(getAbstractFileByPath).toHaveBeenCalledWith(
240+
"Transcripts/local file/Local Episode.md",
241+
);
242+
});
243+
});

‎src/API/API.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Episode } from "src/types/Episode";
22
import { formatSeconds } from "src/utility/formatSeconds";
33
import type { IAPI } from "./IAPI";
4+
import { TFile } from "obsidian";
45
import {
56
currentEpisode,
67
currentTime,
@@ -24,6 +25,7 @@ import {
2425
normalizePlaybackRate,
2526
PLAYBACK_RATE_STEP,
2627
} from "src/utility/playbackRate";
28+
import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath";
2729

2830
const clampVolume = (value: number): number =>
2931
Math.min(1, Math.max(0, value));
@@ -33,6 +35,10 @@ export class API implements IAPI {
3335
return get(currentEpisode);
3436
}
3537

38+
public get transcript(): Promise<string | null> {
39+
return this.getTranscript();
40+
}
41+
3642
public get length(): number {
3743
return get(duration);
3844
}
@@ -141,6 +147,26 @@ export class API implements IAPI {
141147
return `[${segment}](${url.href})`;
142148
}
143149

150+
async getTranscript(episode = this.podcast): Promise<string | null> {
151+
if (!episode) {
152+
return null;
153+
}
154+
155+
const pluginInstance = get(plugin);
156+
const transcriptPath = getEpisodeTranscriptPath(
157+
episode,
158+
pluginInstance.settings.transcript.path,
159+
);
160+
const transcriptFile =
161+
pluginInstance.app.vault.getAbstractFileByPath(transcriptPath);
162+
163+
if (!(transcriptFile instanceof TFile)) {
164+
return null;
165+
}
166+
167+
return await pluginInstance.app.vault.read(transcriptFile);
168+
}
169+
144170
private getEpisodeLinkTarget(): string | undefined {
145171
const epIsLocal = isLocalFile(this.podcast);
146172
return !epIsLocal

‎src/API/IAPI.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Episode } from 'src/types/Episode';
22

33
export interface IAPI {
44
readonly podcast: Episode;
5+
readonly transcript: Promise<string | null>;
56
readonly isPlaying: boolean;
67
readonly length: number;
78
currentTime: number;
@@ -20,6 +21,8 @@ export interface IAPI {
2021
endTime: number,
2122
linkify?: boolean,
2223
): string;
24+
25+
getTranscript(episode?: Episode): Promise<string | null>;
2326

2427
start(): void;
2528
stop(): void;

‎src/services/TranscriptionService.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,10 @@ import { Notice, TFile } from "obsidian";
22
import type { OpenAI } from "openai";
33
import type PodNotes from "../main";
44
import { getEpisodeAudioBuffer } from "../downloadEpisode";
5-
import {
6-
FilePathTemplateEngine,
7-
TranscriptTemplateEngine,
8-
} from "../TemplateEngine";
9-
import {
10-
enforceMaxPathLength,
11-
lastSegmentExtension,
12-
} from "../utility/enforceMaxPathLength";
5+
import { TranscriptTemplateEngine } from "../TemplateEngine";
136
import { ensureFolderExists } from "../utility/ensureFolderExists";
147
import type { Episode } from "src/types/Episode";
8+
import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath";
159
import {
1610
type DiarizationAudio,
1711
type DiarizationProviderId,
@@ -570,11 +564,10 @@ export class TranscriptionService {
570564
* the user's chosen suffix.
571565
*/
572566
private getTranscriptPath(episode: Episode): string {
573-
const rendered = FilePathTemplateEngine(
574-
this.plugin.settings.transcript.path,
567+
return getEpisodeTranscriptPath(
575568
episode,
569+
this.plugin.settings.transcript.path,
576570
);
577-
return enforceMaxPathLength(rendered, lastSegmentExtension(rendered));
578571
}
579572

580573
private async saveTranscription(
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { FilePathTemplateEngine } from "src/TemplateEngine";
2+
import type { Episode } from "src/types/Episode";
3+
import {
4+
enforceMaxPathLength,
5+
lastSegmentExtension,
6+
} from "src/utility/enforceMaxPathLength";
7+
8+
export function getEpisodeTranscriptPath(
9+
episode: Episode,
10+
transcriptPathTemplate: string,
11+
): string {
12+
const rendered = FilePathTemplateEngine(transcriptPathTemplate, episode);
13+
return enforceMaxPathLength(rendered, lastSegmentExtension(rendered));
14+
}

0 commit comments

Comments
 (0)