Skip to content

Commit 8c1ddd6

Browse files
authored
feat(notes): add {{episodelink}} template tag to resume an episode from its note (#35) (#193)
* feat(notes): add {{episodelink}} template tag to resume an episode from its note (#35) Add a no-timestamp obsidian://podnotes deep link so an episode note can link back to the player and resume from the last played location (or the start if unplayed). The resume point is resolved at click time, not baked into the note. - New {{episodelink}} note-template tag (src/utility/buildEpisodeResumeLink.ts) - URIHandler: a link without time reopens the episode and defers the seek to the player's existing saved-progress restore; explicit-time links are unchanged - Docs + unit tests for the link builder, URI handler, and template tag * fix(notes): make {{episodelink}} resume robust to finished episodes and stale seeks (#35) Address adversarial review of #193: - Finished episodes (saved at time===duration) no longer resume at EOF and auto-advance the queue; the no-time path resolves the resume point explicitly and restarts a finished episode from the beginning (resolveResumeTime), both for non-loaded and already-loaded episodes. - The no-time path now arms requestedPlaybackTime explicitly instead of relying on its absence, so a stale pending seek from an earlier link to the same episode can no longer override the resume on metadata load. - buildEpisodeResumeLink falls back to a downloaded copy's path when an episode lacks feedUrl, and drops a redundant cast (isLocalFile already narrows). - Tests for finished resume (non-loaded + loaded), stale-seek override, and the downloaded-without-feedUrl link; docs note finished -> start behavior.
1 parent 59dccb3 commit 8c1ddd6

7 files changed

Lines changed: 370 additions & 18 deletions

File tree

docs/docs/templates.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ This template will be used to create the note text. You can use the following sy
4040
- `{{duration:minutes}}` → total whole minutes (e.g. `62`); `{{duration:seconds}}` → total seconds (e.g. `3723`).
4141
- Any other argument is treated as a clock format using the tokens `H`/`HH`, `h`/`hh`, `m`/`mm`, `s`/`ss`, `A`/`a` — e.g. `{{duration:HH:mm:ss}}` → `01:02:03`. (Unlike `{{date}}`, `[literal]` bracket escaping is not supported here.)
4242
- `{{artwork}}`: The URL of the podcast artwork. If no artwork is found, an empty string will be used.
43+
- `{{episodelink}}`: A clickable `obsidian://podnotes` link that reopens this episode in the PodNotes player and **resumes from where you left off** (or starts at the beginning if you have never played it, or have already finished it). The resume position is resolved when you click the link — not baked in when the note is created — so the link always jumps to your latest position. Put it in your template to get a "back to the episode" link on every note, e.g. `[▶️ Resume in PodNotes]({{episodelink}})`. The value is the bare URL, so wrap it in your own Markdown link text. It is empty when the episode has no feed URL or local file path to address it by. See [issue #35](https://github.com/chhoumann/PodNotes/issues/35).
4344

4445
### Linking an episode to its podcast (feed) note
4546
In an episode note, `{{url}}` and `{{artwork}}` always describe the **episode**. To reference the parent podcast (feed), use these additional tags:

src/TemplateEngine.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "./TemplateEngine";
1111
import type { Episode } from "./types/Episode";
1212
import type { PodcastFeed } from "./types/PodcastFeed";
13-
import { plugin } from "./store";
13+
import { downloadedEpisodes, plugin } from "./store";
1414

1515
// The illegal-character sanitizer is private; exercise it through
1616
// DownloadPathTemplateEngine, which applies it to {{title}} and {{podcast}}.
@@ -116,6 +116,21 @@ describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
116116
);
117117
});
118118

119+
it("emits {{episodelink}} as a no-timestamp obsidian://podnotes link to the episode (#35)", () => {
120+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
121+
downloadedEpisodes.set({});
122+
123+
const rendered = NoteTemplateEngine("{{episodelink}}", demoEpisode);
124+
const parsed = new URL(rendered);
125+
126+
expect(parsed.protocol).toBe("obsidian:");
127+
expect(parsed.host).toBe("podnotes");
128+
expect(parsed.searchParams.get("episodeName")).toBe("Episode 1");
129+
expect(parsed.searchParams.get("url")).toBe("https://example.com/feed.xml");
130+
// No baked-in time: the resume point is resolved when the link is clicked.
131+
expect(parsed.searchParams.has("time")).toBe(false);
132+
});
133+
119134
it("emits {{podcastlink}} as a path-qualified wikilink to the feed note", () => {
120135
plugin.set({
121136
settings: {

src/TemplateEngine.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { formatDate } from "./utility/formatDate";
99
import { formatDuration } from "./utility/formatDuration";
1010
import { formatEpisodeNumber } from "./utility/formatEpisodeNumber";
1111
import { parseEpisodeNumberFromTitle } from "./utility/parseEpisodeNumber";
12+
import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink";
1213

1314
// Each tag is either a literal string or a function taking at most one argument
1415
// (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The
@@ -157,6 +158,12 @@ export function NoteTemplateEngine(template: string, episode: Episode) {
157158
// A ready-made wikilink to the parent feed's note, pointing at the same file
158159
// createFeedNote writes (derived from the feed-note path setting).
159160
addTag("podcastlink", getFeedNoteWikilink(episode.podcastName));
161+
// A clickable obsidian://podnotes deep link that reopens this episode in the
162+
// player and resumes from the last played location (or the start if it has
163+
// never been played). The resume point is resolved at click time, not baked
164+
// in here, so a single templated link always jumps to where you left off.
165+
// Empty when the episode has no feed/file URL to address it by. See issue #35.
166+
addTag("episodelink", buildEpisodeResumeLink(episode));
160167

161168
return replacer(template);
162169
}

src/URIHandler.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import podNotesURIHandler from "./URIHandler";
1212
import {
1313
currentEpisode,
1414
currentTime,
15+
duration,
1516
isPaused,
1617
localFiles,
1718
playedEpisodes,
@@ -64,6 +65,7 @@ const emptyLocalFiles: Playlist = {
6465
function resetStores() {
6566
currentEpisode.update(() => undefined as unknown as Episode);
6667
currentTime.set(0);
68+
duration.set(0);
6769
isPaused.set(true);
6870
playedEpisodes.set({});
6971
requestedPlaybackTime.set(null);
@@ -331,6 +333,144 @@ describe("podNotesURIHandler", () => {
331333
});
332334
});
333335

336+
test("reopens a non-loaded episode without a timestamp and resumes from the saved location (#35)", async () => {
337+
playedEpisodes.setEpisodeTime(testEpisode, 321, 3600, false);
338+
339+
await podNotesURIHandler(
340+
{
341+
action: "podnotes",
342+
url: testFeedUrl,
343+
episodeName: testEpisode.title,
344+
// No `time`: the {{episodelink}} template tag omits it.
345+
},
346+
api as never,
347+
);
348+
349+
expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl);
350+
expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title });
351+
expect(get(viewState)).toBe(ViewState.Player);
352+
// The resume point is resolved from saved progress and armed explicitly, so
353+
// the player seeks to 321s — and any stale pending request can't override it.
354+
expect(get(requestedPlaybackTime)).toEqual({
355+
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
356+
time: 321,
357+
});
358+
});
359+
360+
test("a no-timestamp link overrides a stale pending seek for the same episode (#35)", async () => {
361+
playedEpisodes.setEpisodeTime(testEpisode, 321, 3600, false);
362+
// A previous timestamp link left a pending seek that has not been applied
363+
// (metadata still loading). The resume link must win, not the stale 999.
364+
requestedPlaybackTime.set({
365+
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
366+
time: 999,
367+
});
368+
369+
await podNotesURIHandler(
370+
{ action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title },
371+
api as never,
372+
);
373+
374+
expect(get(requestedPlaybackTime)).toEqual({
375+
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
376+
time: 321,
377+
});
378+
});
379+
380+
test("restarts a finished episode from the beginning for a no-timestamp link (#35)", async () => {
381+
// A finished episode is stored at its end; resuming there would auto-advance.
382+
playedEpisodes.markAsPlayed(testEpisode);
383+
playedEpisodes.setEpisodeTime(testEpisode, 3600, 3600, true);
384+
385+
await podNotesURIHandler(
386+
{ action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title },
387+
api as never,
388+
);
389+
390+
expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title });
391+
expect(get(requestedPlaybackTime)).toEqual({
392+
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
393+
time: 0,
394+
});
395+
});
396+
397+
test("restarts an already-loaded finished episode from the beginning for a no-timestamp link (#35)", async () => {
398+
currentEpisode.set(testEpisode);
399+
viewState.set(ViewState.PodcastGrid);
400+
duration.set(180);
401+
currentTime.set(180); // sitting at the very end
402+
isPaused.set(true);
403+
404+
await podNotesURIHandler(
405+
{ action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title },
406+
api as never,
407+
);
408+
409+
expect(get(viewState)).toBe(ViewState.Player);
410+
expect(get(currentTime)).toBe(0);
411+
expect(get(isPaused)).toBe(false);
412+
expect(mockGetEpisodes).not.toHaveBeenCalled();
413+
});
414+
415+
test("surfaces the player and resumes the already-loaded episode without seeking when no timestamp is given (#35)", async () => {
416+
currentEpisode.set(testEpisode);
417+
viewState.set(ViewState.PodcastGrid);
418+
currentTime.set(1234);
419+
isPaused.set(true);
420+
421+
await podNotesURIHandler(
422+
{
423+
action: "podnotes",
424+
url: testFeedUrl,
425+
episodeName: testEpisode.title,
426+
},
427+
api as never,
428+
);
429+
430+
expect(get(viewState)).toBe(ViewState.Player);
431+
// The live position is already the last played location — left untouched.
432+
expect(get(currentTime)).toBe(1234);
433+
expect(get(isPaused)).toBe(false);
434+
expect(get(requestedPlaybackTime)).toBeNull();
435+
expect(mockGetEpisodes).not.toHaveBeenCalled();
436+
});
437+
438+
test("treats an empty timestamp the same as an omitted one (resume, not error) (#35)", async () => {
439+
await podNotesURIHandler(
440+
{
441+
action: "podnotes",
442+
url: testFeedUrl,
443+
episodeName: testEpisode.title,
444+
time: "",
445+
},
446+
api as never,
447+
);
448+
449+
expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title });
450+
expect(get(viewState)).toBe(ViewState.Player);
451+
// No saved progress for this episode → resume from the start.
452+
expect(get(requestedPlaybackTime)).toEqual({
453+
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
454+
time: 0,
455+
});
456+
});
457+
458+
test("still rejects a present-but-non-numeric timestamp", async () => {
459+
await podNotesURIHandler(
460+
{
461+
action: "podnotes",
462+
url: testFeedUrl,
463+
episodeName: testEpisode.title,
464+
time: "not-a-number",
465+
},
466+
api as never,
467+
);
468+
469+
expect(get(currentEpisode)).toBeUndefined();
470+
expect(get(viewState)).toBe(ViewState.PodcastGrid);
471+
expect(mockGetEpisodes).not.toHaveBeenCalled();
472+
});
473+
334474
test("picks the '+'-variant local file (raw-first) even when its space-twin precedes it", async () => {
335475
const makeLocal = (title: string): LocalEpisode => ({
336476
title,

src/URIHandler.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import type { IAPI } from "./API/IAPI";
55
import FeedParser from "./parser/feedParser";
66
import {
77
currentEpisode,
8+
currentTime,
9+
duration,
810
isPaused,
911
localFiles,
12+
playedEpisodes,
1013
requestedPlaybackTime,
1114
viewState,
1215
} from "./store";
@@ -44,21 +47,43 @@ function findEpisodeByCandidates(
4447
return undefined;
4548
}
4649

50+
/**
51+
* The time a no-timestamp link ({{episodelink}}, issue #35) should resume an
52+
* episode at: the last played location, or the start. A FINISHED episode is
53+
* stored at its end (time === duration); resuming there would immediately fire
54+
* the player's `ended` handler and auto-advance the queue, so we restart it from
55+
* the beginning instead — matching the issue's "the beginning if not played".
56+
*/
57+
function resolveResumeTime(episode: Episode): number {
58+
const played = playedEpisodes.get(episode);
59+
if (!played) return 0;
60+
61+
const isFinished =
62+
played.finished || (played.duration > 0 && played.time >= played.duration);
63+
return isFinished ? 0 : played.time;
64+
}
65+
4766
export default async function podNotesURIHandler(
4867
{ url, episodeName, time }: ObsidianProtocolData,
4968
api: IAPI
5069
) {
51-
if (!url || !episodeName || time === undefined) {
52-
new Notice(
53-
"URL, episode name, and timestamp are required to play an episode"
54-
);
70+
if (!url || !episodeName) {
71+
new Notice("URL and episode name are required to play an episode");
5572
return;
5673
}
5774

58-
const requestedTime = parseFloat(time);
59-
if (!Number.isFinite(requestedTime)) {
60-
new Notice("Timestamp must be a valid number");
61-
return;
75+
// A link may omit the timestamp ({{episodelink}}, issue #35). When it does we
76+
// reopen the episode and resume from the last played location (resolved from
77+
// saved progress below), or the start if it was never played or already
78+
// finished — rather than seeking to a baked-in time.
79+
const hasExplicitTime = time !== undefined && time !== "";
80+
let requestedTime = 0;
81+
if (hasExplicitTime) {
82+
requestedTime = parseFloat(time);
83+
if (!Number.isFinite(requestedTime)) {
84+
new Notice("Timestamp must be a valid number");
85+
return;
86+
}
6287
}
6388

6489
const nameCandidates = candidateValues(episodeName);
@@ -73,16 +98,32 @@ export default async function podNotesURIHandler(
7398
const playerIsVisible = get(viewState) === ViewState.Player;
7499

75100
if (episodeIsPlaying) {
76-
requestedPlaybackTime.set({
77-
episodeKey: getEpisodeKey(currentEp),
78-
time: requestedTime,
79-
});
101+
if (hasExplicitTime) {
102+
requestedPlaybackTime.set({
103+
episodeKey: getEpisodeKey(currentEp),
104+
time: requestedTime,
105+
});
106+
viewState.set(ViewState.Player);
107+
api.currentTime = requestedTime;
108+
isPaused.set(false);
109+
if (playerIsVisible) {
110+
requestedPlaybackTime.set(null);
111+
}
112+
113+
return;
114+
}
115+
116+
// No timestamp: the live position already is the last played location, so
117+
// surface the player and resume playback without seeking — unless the
118+
// episode already finished (live position at its end), in which case
119+
// replaying would instantly fire `ended` and auto-advance, so restart it.
80120
viewState.set(ViewState.Player);
81-
api.currentTime = requestedTime;
82-
isPaused.set(false);
83-
if (playerIsVisible) {
84-
requestedPlaybackTime.set(null);
121+
const liveTime = get(currentTime);
122+
const liveDuration = get(duration);
123+
if (liveDuration > 0 && liveTime >= liveDuration) {
124+
api.currentTime = 0;
85125
}
126+
isPaused.set(false);
86127

87128
return;
88129
}
@@ -121,9 +162,14 @@ export default async function podNotesURIHandler(
121162
return;
122163
}
123164

165+
// Always arm requestedPlaybackTime so the resume point is explicit. For a
166+
// no-timestamp link (issue #35) we resolve it from saved progress here rather
167+
// than relying on the player's restore-on-absence: that makes the seek
168+
// deterministic and overrides any stale pending request from an earlier link
169+
// to this same episode (which would otherwise win on metadata load).
124170
requestedPlaybackTime.set({
125171
episodeKey: getEpisodeKey(episode),
126-
time: requestedTime,
172+
time: hasExplicitTime ? requestedTime : resolveResumeTime(episode),
127173
});
128174
currentEpisode.set(episode);
129175
viewState.set(ViewState.Player);

0 commit comments

Comments
 (0)