Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,10 @@ ${transcript}`;
};
```

## `getPodcastTimeFormatted(format: string, linkify?: boolean)`
## `getPodcastTimeFormatted(format: string, linkify?: boolean, offsetSeconds?: number)`
This function will return the current playback time formatted according to the given (moment) format.
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.
`offsetSeconds` is optional (default 0), subtracted from the current playback time before formatting/linking, floored at 0, and also applies to the linkified timestamp.

## `getPodcastSegmentFormatted(format: string, startTime: number, endTime: number, linkify?: boolean)`
This function returns a formatted `start-end` playback range.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/transcripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ By default the transcription uses OpenAI's Whisper model, which produces plain t

In the **Transcript settings** section, turn on **Speaker diarization** and choose a provider:

- **OpenAI** (`gpt-4o-transcribe-diarize`): reuses the OpenAI API key you already entered above, so there is nothing else to configure. Because OpenAI caps each request at 25 MB, a long episode is split into chunks that are diarized independently — so on long episodes the speaker labels can change across chunk boundaries (the same person may be labelled `A` in one chunk and `B` in the next). A typical-length episode fits in a single request and is fully consistent.
- **OpenAI** (`gpt-4o-transcribe-diarize`): reuses the OpenAI API key you already entered above, so there is nothing else to configure. Because each request is capped at ~20 MB (a conservative margin under OpenAI's 25 MB request cap), a long episode is split into chunks that are diarized independently — so on long episodes the speaker labels can change across chunk boundaries (the same person may be labelled `A` in one chunk and `B` in the next). A typical-length episode fits in a single request and is fully consistent.
- **Deepgram**: sends the whole episode in one request, so speaker labels stay consistent across the entire episode. This requires a separate **Deepgram API key**, which you can create at [deepgram.com](https://deepgram.com) (new accounts include free credit). Your Deepgram key is stored separately from your OpenAI key and is only used for diarization.

Diarization is off by default, so existing transcripts and the plain-Whisper workflow are unchanged unless you enable it.
Expand Down
102 changes: 102 additions & 0 deletions src/API/API.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
currentEpisode,
currentTime,
downloadedEpisodes,
duration,
playbackRate,
plugin,
volume,
} from "src/store";
import type { Episode } from "src/types/Episode";
import type { LocalEpisode } from "src/types/LocalEpisode";
Expand Down Expand Up @@ -159,6 +161,106 @@ describe("API playback rate controls", () => {
});
});

describe("API skip controls (PB-02)", () => {
beforeEach(() => {
duration.set(0);
plugin.set({
settings: {
defaultPlaybackRate: 1,
skipBackwardLength: 15,
skipForwardLength: 30,
},
} as never);
});

test("skips backward and forward by the configured lengths", () => {
currentTime.set(100);
const api = new API();

api.skipBackward();
expect(api.currentTime).toBe(85);

api.skipForward();
expect(api.currentTime).toBe(115);
});

test("never seeks before 0 when skipping backward past the start", () => {
currentTime.set(5);
const api = new API();

api.skipBackward();
expect(api.currentTime).toBe(0);
});

test("falls back to a 15s default when a skip length is NaN/invalid", () => {
currentTime.set(100);
plugin.set({
settings: {
defaultPlaybackRate: 1,
skipBackwardLength: Number.NaN,
skipForwardLength: undefined,
},
} as never);
const api = new API();

api.skipBackward();
expect(api.currentTime).toBe(85);

api.skipForward();
expect(api.currentTime).toBe(100);
});

test("clamps a forward skip just short of the end so it doesn't auto-advance", () => {
currentTime.set(100);
duration.set(110);
const api = new API();

api.skipForward();
// 100 + 30 = 130, clamped to duration - 0.25.
expect(api.currentTime).toBe(109.75);
});

test("does not rewind on a forward skip when already within the last 0.25s (#213)", () => {
currentTime.set(9.9);
duration.set(10);
const api = new API();

api.skipForward();
// The end-clamp (dur - 0.25 = 9.75) must never move a forward skip backward.
expect(api.currentTime).toBe(9.9);
});

test("leaves the forward target unclamped when duration is unknown", () => {
currentTime.set(100);
duration.set(0);
const api = new API();

api.skipForward();
expect(api.currentTime).toBe(130);
});
});

describe("API volume (AP-07)", () => {
test("clamps volume into [0, 1]", () => {
volume.set(0.5);
const api = new API();

api.volume = 2;
expect(api.volume).toBe(1);

api.volume = -1;
expect(api.volume).toBe(0);
});

test("ignores a non-finite volume (keeps the current value) instead of poisoning it to NaN", () => {
volume.set(0.6);
const api = new API();

api.volume = Number.NaN;
expect(api.volume).toBe(0.6);
});
});

describe("API transcript access", () => {
function setTranscriptVault(files: Record<string, string>) {
const createTFile = (path: string): TFile =>
Expand Down
47 changes: 40 additions & 7 deletions src/API/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,25 @@ import {
} from "src/utility/playbackRate";
import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath";

const clampVolume = (value: number): number =>
Math.min(1, Math.max(0, value));
// Default used when a persisted/configured skip length is missing or invalid
// (e.g. a cleared settings field serialized to null/NaN). Mirrors
// DEFAULT_SETTINGS.skip*Length.
const DEFAULT_SKIP_LENGTH = 15;

const normalizeSkipLength = (length: number): number =>
Number.isFinite(length) && length > 0 ? length : DEFAULT_SKIP_LENGTH;

// A non-finite volume (e.g. api.volume = NaN) must never poison the player, which
// two-way binds the store onto the media element. Fall back to the current value
// (itself clamped) so a bad write is a no-op rather than corrupting playback.
const clampVolume = (value: number, fallback = 1): number => {
const safeFallback = Number.isFinite(fallback)
? Math.min(1, Math.max(0, fallback))
: 1;
return Number.isFinite(value)
? Math.min(1, Math.max(0, value))
: safeFallback;
};

export class API implements IAPI {
public get podcast(): Episode {
Expand Down Expand Up @@ -61,7 +78,7 @@ export class API implements IAPI {
}

public set volume(value: number) {
volumeStore.set(clampVolume(value));
volumeStore.set(clampVolume(value, get(volumeStore)));
}

public get playbackRate(): number {
Expand Down Expand Up @@ -187,13 +204,29 @@ export class API implements IAPI {
}

skipBackward(): void {
const skipBackLen = get(plugin).settings.skipBackwardLength;
this.currentTime -= skipBackLen;
const skipBackLen = normalizeSkipLength(
get(plugin).settings.skipBackwardLength,
);
// Never seek before the start. A cleared settings field (NaN/null) falls
// back to the default rather than corrupting currentTime (PB-02).
this.currentTime = Math.max(0, this.currentTime - skipBackLen);
}

skipForward(): void {
const skipForwardLen = get(plugin).settings.skipForwardLength;
this.currentTime += skipForwardLen;
const skipForwardLen = normalizeSkipLength(
get(plugin).settings.skipForwardLength,
);
const target = this.currentTime + skipForwardLen;
const dur = this.length;
// Clamp just short of the end so an over-skip lands at the end instead of
// firing 'ended' and auto-advancing the queue. Never let the clamp move the
// position BACKWARD: when already within the last 0.25s, a forward skip must
// not rewind, so keep at least the current time (PB-02 / Codex review #213).
// With an unknown/zero duration (metadata not loaded) leave it unclamped.
this.currentTime =
dur > 0
? Math.max(this.currentTime, Math.min(target, dur - 0.25))
: target;
}

increasePlaybackRate(): void {
Expand Down
47 changes: 47 additions & 0 deletions src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ const demoEpisode: Episode = {
episodeDate: new Date("2024-01-01"),
};

describe("DownloadPathTemplateEngine extension stripping (#DL-04)", () => {
it("strips only a trailing template extension", () => {
expect(
DownloadPathTemplateEngine("Podcasts/{{title}}.mp3", demoEpisode),
).toBe("Podcasts/Episode 1");
});

it("does not corrupt a folder that contains the extension string earlier in the path", () => {
// getUrlExtension returns the trailing 'mp3', but the old positional
// `.replace('mp3', '')` would strip the FIRST 'mp3' (in the folder name),
// mangling 'mp3folder' -> 'folder' and leaving the real '.mp3' behind.
expect(
DownloadPathTemplateEngine("mp3folder/{{title}}.mp3", demoEpisode),
).toBe("mp3folder/Episode 1");
});

it("leaves a template without a trailing extension untouched", () => {
expect(
DownloadPathTemplateEngine("Podcasts/{{title}}", demoEpisode),
).toBe("Podcasts/Episode 1");
});
});

describe("TimestampTemplateEngine segment tags", () => {
beforeEach(() => {
currentEpisode.set(demoEpisode);
Expand Down Expand Up @@ -116,6 +139,30 @@ describe("TimestampTemplateEngine segment tags", () => {
});
});

describe("empty tag arguments (NT-05/CH-09)", () => {
it("treats {{date:}} as the default date, not an unknown tag", () => {
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
// A bare trailing colon must use the tag default (same as {{date}}),
// not parse as tagId "date:" (invalid) and not pass "" as the format.
expect(NoteTemplateEngine("{{date:}}", demoEpisode)).toBe(
NoteTemplateEngine("{{date}}", demoEpisode),
);
expect(NoteTemplateEngine("{{date:}}", demoEpisode)).toBe("2024-01-01");
});

it("preserves a whitespace-only argument rather than collapsing it (no regression)", () => {
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
// A lone-space argument is passed through to the tag (formatDate(date, " ")),
// keeping the obscure space-prepend usage working — only an empty arg defaults.
expect(NoteTemplateEngine("{{date: }}", demoEpisode)).toBe(" ");
});

it("still honors a real date format argument", () => {
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
expect(NoteTemplateEngine("{{date:YYYY}}", demoEpisode)).toBe("2024");
});
});

describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
it("keeps {{url}} and {{artwork}} pointing at the episode itself", () => {
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
Expand Down
39 changes: 29 additions & 10 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ interface Tags {
type AddTagFn = (tag: Lowercase<string>, value: TagValue) => void;
type ReplacerFn = (template: string) => string;

const TEMPLATE_TAG_REGEX = /\{\{(.*?)(:\s*?.+?)?\}\}/g;
// The optional argument group uses `.*?` (zero-or-more), so a bare trailing
// colon ({{date:}}) is captured AS the argument group (params === ":") rather
// than being absorbed into the tag id — which previously yielded tagId "date:",
// an unknown tag, and a spurious "invalid tag" Notice (NT-05/CH-09).
const TEMPLATE_TAG_REGEX = /\{\{(.*?)(:\s*.*?)?\}\}/g;

export interface NoteTemplateContext {
chapters?: Chapter[];
Expand Down Expand Up @@ -79,15 +83,20 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
}

if (typeof tagValue === "function") {
if (params) {
// Pass everything after the leading colon as a single argument.
// No tag takes more than one argument, and splitting on "," would
// corrupt format strings that legitimately contain commas
// (e.g. {{currentDate:MMMM D, YYYY}}).
return tagValue(params.slice(1));
// Everything after the leading colon is a single argument. A bare
// trailing colon ({{date:}}) means "use the tag default", same as a
// bare {{date}} — not an empty format string (NT-05/CH-09). Only an
// EMPTY argument is collapsed; a whitespace argument ({{description: }})
// is preserved so the obscure space-prepend usage still works.
const arg = params ? params.slice(1) : "";
if (arg === "") {
return tagValue();
}

return tagValue();
// No tag takes more than one argument, and splitting on "," would
// corrupt format strings that legitimately contain commas
// (e.g. {{currentDate:MMMM D, YYYY}}).
return tagValue(arg);
}

return tagValue;
Expand Down Expand Up @@ -323,10 +332,20 @@ export function FilePathTemplateEngine(template: string, episode: Episode) {
}

export function DownloadPathTemplateEngine(template: string, episode: Episode) {
// Removing the template extension, as this is added automatically depending on file type.
// Removing the template extension, as this is added automatically depending on
// file type. Anchor the strip at end-of-string: getUrlExtension returns the
// FIRST '.ext' followed by '?'/'#'/end, which need not be the trailing one, so
// a positional `.replace(ext, "")` could corrupt a folder name that happens to
// contain the same string earlier in the path (#DL-04).
const templateExtension = getUrlExtension(template);
const templateWithoutExtension = templateExtension
? template.replace(templateExtension, "")
? template.replace(
new RegExp(
`\\.${templateExtension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`,
"i",
),
"",
)
: template;

const [replacer, addTag] = useTemplateEngine();
Expand Down
51 changes: 51 additions & 0 deletions src/URIHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,41 @@ describe("podNotesURIHandler", () => {
expect(mockGetEpisodes).not.toHaveBeenCalled();
});

test("resolves a moved/renamed local file by name instead of routing to the feed parser (LF-08)", async () => {
const localEpisode: LocalEpisode = {
title: "Local Note",
streamUrl: "Old/Local Note.mp3",
url: "Old/Local Note.mp3",
description: "",
content: "",
podcastName: "local file",
filePath: "Old/Local Note.mp3",
};
localFiles.set({ ...emptyLocalFiles, episodes: [localEpisode] });
// The file was moved: the recorded path no longer resolves to a file, and
// the url has no http(s) scheme, so it must be treated as a vault path.
setApp(() => null);

await podNotesURIHandler(
{
action: "podnotes",
url: "Old/Local Note.mp3",
episodeName: "Local Note",
time: "15",
},
api as never,
);

expect(get(currentEpisode)).toMatchObject({ title: "Local Note" });
expect(get(viewState)).toBe(ViewState.Player);
expect(get(requestedPlaybackTime)).toEqual({
episodeKey: "local file::Local Note",
time: 15,
});
// A schemeless path must never hit the feed parser.
expect(mockGetEpisodes).not.toHaveBeenCalled();
});

test("shows a notice and changes nothing when the episode cannot be found", async () => {
mockGetEpisodes.mockResolvedValue([]);

Expand Down Expand Up @@ -605,6 +640,22 @@ describe("podNotesURIHandler", () => {
expect(mockGetEpisodes).not.toHaveBeenCalled();
});

test("rejects a negative plain (non-segment) timestamp", async () => {
await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName: testEpisode.title,
time: "-10",
},
api as never,
);

expect(get(currentEpisode)).toBeUndefined();
expect(get(viewState)).toBe(ViewState.PodcastGrid);
expect(mockGetEpisodes).not.toHaveBeenCalled();
});

test("rejects a negative segment start timestamp", async () => {
await podNotesURIHandler(
{
Expand Down
Loading
Loading