Skip to content

feat(notes): add {{episodelink}} template tag to resume an episode from its note (#35) - #193

Merged
chhoumann merged 2 commits into
masterfrom
chhoumann/35-note-to-episode-nav
Jun 16, 2026
Merged

chhoumann merged 2 commits into
masterfrom
chhoumann/35-note-to-episode-nav

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

Summary

Closes #35.

Adds a {{episodelink}} note-template tag so a podcast note can link straight back to its episode in the PodNotes player and resume from where you left off — or start at the beginning if it has never been played. This is the workflow the issue author asked for: put the tag in the episode-note template once, and every note gets a "back to the episode" link automatically, no manual timestamp-capturing required.

[▶️ Resume in PodNotes]({{episodelink}})

How it works

The crux of #35 is that "last played location" is dynamic — it must be resolved when the link is clicked, not baked in when the note is created. So:

  • {{episodelink}} emits an obsidian://podnotes deep link that carries no time parameter — just episodeName + url (src/utility/buildEpisodeResumeLink.ts).
  • URIHandler now treats an absent/empty time as "resume": it reopens the episode and defers the seek to the player's existing saved-progress restore (EpisodePlayer.svelte → restoreSavedPlaybackTime), which already resumes from playedEpisodes[key].time or 0. No new resume logic — it reuses the well-tested path that powers normal grid-click playback.
  • Links that do carry a time (existing timestamp links / {{linktime}}) hit the unchanged explicit-time branches and behave byte-for-byte as before.

Addressing mirrors API.getPodcastTimeFormatted: a streamed episode links by its feed URL (the player refetches the feed and matches by title); a local/downloaded episode links by its on-disk path. When neither is known the tag yields an empty string, so a template degrades gracefully instead of emitting a broken link.

Why this approach

  • Template link, not a command. The issue author explicitly wanted it in the template ("create it for every new note, not by hand"). A link also embeds the episode identity directly in the note, avoiding a fragile note→episode reverse lookup that a command would need.
  • Defer the seek to the player. Reusing restoreSavedPlaybackTime means "resume where I left off" stays correct even as playback progress changes after the note is written — and there's no duplicated seek logic to drift.
  • Minimal, additive TemplateEngine change (a single new tag) to stay collision-safe with the in-flight [BUG] Handling of max path length when creating notes #22 work.

Backward compatibility

No migration. Existing obsidian://podnotes links always include time (and encodePodnotesURI never emits an empty time), so they never reach the new no-time path. The two explicit-time branches in URIHandler are unchanged.

Testing

  • npm run lint, npm run typecheck, npm run build, npm run format:check: clean.
  • npm run test: 382 passed. New unit tests cover the link builder (remote/local/downloaded/missing-URL/+-encoding), the URIHandler no-time resume across branches (non-loaded, already-loaded, empty-time, invalid-time still rejected, target's saved resume point preserved), and the {{episodelink}} template tag. The deferred seek itself is covered by the existing EpisodePlayer.test.ts restore tests.
  • Real-Obsidian runtime verification (isolated worktree E2E vault): drove the registered protocol handler with a no-time link and confirmed the player opens and currentTime resumes to the saved position (240s for a partially-played episode; 0s for an unplayed one). I did not commit those two E2E cases because driving live playback exposed a pre-existing concurrent-writeFile race on data.json between the harness's per-test reseed and the plugin's saveSettings (it destabilized the shared-instance suite); the behavior they assert is covered deterministically by the unit tests above.

Docs

docs/docs/templates.md documents {{episodelink}} in the note-template section.

Review

Reviewed via one comprehensive pass plus two adversarial reviewers (backward-compat-break and does-resume-actually-work lenses) — all three returned ship. The one nit (a no-time test seeded a saved time without asserting it) was addressed by asserting the target's saved resume point survives currentEpisode.set's side effects.

…om 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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: f7cf896
Status: ✅  Deploy successful!
Preview URL: https://b3056058.podnotes.pages.dev
Branch Preview URL: https://chhoumann-35-note-to-episode.podnotes.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 207a069293

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utility/buildEpisodeResumeLink.ts Outdated
Comment on lines +20 to +23
const target = isLocalFile(episode)
? (episode as LocalEpisode).filePath ??
downloadedEpisodes.getEpisode(episode)?.filePath
: episode.feedUrl;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to downloaded paths without feed URLs

For a non-local file episode that has been downloaded but has no feedUrl (the type allows this, and older/imported episode snapshots can lack it), this else arm returns undefined and {{episodelink}} renders as an empty string even though downloadedEpisodes.getEpisode(episode)?.filePath is a valid vault address the URI handler can resolve through the Local Files projection. Check the downloaded file path before giving up on non-local episodes so downloaded/offline-addressable episodes still get a resume link.

Useful? React with 👍 / 👎.

…nd 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.
@chhoumann

Copy link
Copy Markdown
Owner Author

Review round (ultracode + adversarial) — addressed in f7cf896

Ran a comprehensive ultracode pass plus adversarial reviewers from several lenses (correctness/skeptic, architecture, minimalist, spec-fit, backward-compat) across two rounds. Most verdicts were ship; the reviewers converged on two real, in-scope issues, now fixed:

  1. Finished episodes resumed at EOF. When an episode was already finished, its saved position is its end (time === duration). The no-time link reopened it pinned to the end, and autoplay immediately fired ended → markAsPlayed + queue.playNext(), bouncing the user to a different episode. This is outside the issue's "last location, or the beginning if not played" intent. Fixed: the no-time path now resolves the resume point explicitly (resolveResumeTime) and restarts a finished episode from the beginning — for both non-loaded and already-loaded episodes.

  2. A stale pending seek could override the resume. The no-time path relied on the absence of requestedPlaybackTime; if a previous timestamp link had left a seek pending (metadata still loading), it could win on the next metadata load. Fixed: the no-time path now arms requestedPlaybackTime explicitly with the resolved resume time, so the resume is deterministic and overrides any stale request.

Plus two cleanups: buildEpisodeResumeLink now falls back to a downloaded copy's path when an episode lacks feedUrl (older/imported snapshots get a working link instead of none), and a redundant cast was dropped (isLocalFile already narrows).

Deliberately not changed (out of scope): the player's shared restoreSavedPlaybackTime (grid-click behavior) is untouched, and the pre-existing title-only episode-identity matching in URIHandler (shared with existing timestamp links) is left as-is to avoid a behavioral regression beyond #35.

Tests: 386 passing (added finished-resume for non-loaded + loaded, stale-seek override, and the downloaded-without-feedUrl link). Lint, typecheck, build, format all clean; CI green.

@chhoumann
chhoumann merged commit 8c1ddd6 into master Jun 16, 2026
2 checks passed
@chhoumann
chhoumann deleted the chhoumann/35-note-to-episode-nav branch June 16, 2026 06:17
github-actions Bot pushed a commit that referenced this pull request Jun 22, 2026
# [2.17.0](2.16.0...2.17.0) (2026-06-22)

### Bug Fixes

* behavioral-audit logic and robustness fixes (back-end, 1/2) ([#213](#213)) ([4e2845d](4e2845d))
* **download:** default per-episode download path and migrate empty default ([#183](#183)) ([#186](#186)) ([46a6486](46a6486))
* **download:** prevent Android crash and create missing folders on download ([#178](#178)) ([ecd09d5](ecd09d5)), closes [#113](#113) [#86](#86) [#113](#113) [#86](#86)
* **lifecycle:** defer mobile podcast view startup ([#208](#208)) ([8bf9ce4](8bf9ce4))
* **notes:** cap note path length and harden folder creation ([#22](#22), [#87](#87)) ([#192](#192)) ([858e280](858e280)), closes [#87-class](#87)
* **playback:** persist listened time during playback ([#33](#33)) ([#190](#190)) ([e3433c2](e3433c2)), closes [#191](#191) [#108](#108) [#163](#163) [#183](#183)
* **playback:** play local files and downloads on iOS via resource path ([#100](#100)) ([#184](#184)) ([12c503a](12c503a))
* **player:** clear progress on episode switch to stop end-of-playback glitch ([#94](#94)) ([#194](#194)) ([59dccb3](59dccb3))
* **player:** reveal PodNotes view on Play with PodNotes so local files play ([#84](#84)) ([#198](#198)) ([5953625](5953625))
* **settings:** show labelled Add/Remove buttons in podcast search ([#109](#109)) ([#195](#195)) ([33399f5](33399f5))
* show downloaded episodes in the Local Files playlist ([#176](#176)) ([#177](#177)) ([184188c](184188c))
* **timestamps:** capture into the cursor's table cell without breaking the row ([#165](#165)) ([#203](#203)) ([964e342](964e342))
* **transcription:** always transcribe the currently playing episode ([#182](#182)) ([62b488a](62b488a)), closes [#107](#107)
* **uri:** preserve '+' in episode titles and paths for timestamp links ([#181](#181)) ([8ad7aa5](8ad7aa5)), closes [#164](#164)
* **view:** reliably reveal PodNotes view via command + ribbon icon ([#55](#55)) ([#199](#199)) ([fa1d708](fa1d708))

### Features

* add podcast segment links ([#205](#205)) ([d97e59e](d97e59e))
* **api:** expose generated episode transcripts ([e264465](e264465)), closes [#105](#105)
* behavioral-audit UI and interaction fixes (front-end, 2/2) ([#215](#215)) ([894d93d](894d93d))
* **commands:** add playback rate and media timestamp controls ([#206](#206)) ([a8bb44a](a8bb44a))
* **devx:** isolated per-worktree Obsidian E2E vault wrapper ([#188](#188)) ([a8b7a4a](a8b7a4a))
* **episodes:** add a setting to control the Latest Episodes list length ([#114](#114)) ([#200](#200)) ([7b3e3c6](7b3e3c6))
* **notes:** add {{episodelink}} template tag to resume an episode from its note ([#35](#35)) ([#193](#193)) ([8c1ddd6](8c1ddd6))
* **notes:** add podcast feed-level notes ([#163](#163)) ([#187](#187)) ([db0de47](db0de47)), closes [#161](#161) [#160](#160)
* **notes:** ship a Bases-friendly default episode note template ([#160](#160)) ([#201](#201)) ([209431d](209431d)), closes [#163](#163) [#183](#183)
* **player:** scale episode title font size to its length ([#81](#81)) ([#202](#202)) ([659b6b8](659b6b8))
* **player:** support video episode playback ([#209](#209)) ([f92a91f](f92a91f))
* **queue:** add setting to disable queue auto-population and auto-advance ([#108](#108)) ([#185](#185)) ([cf3d73c](cf3d73c))
* **queue:** allow reordering the playback queue ([#80](#80)) ([#179](#179)) ([d994d61](d994d61)), closes [#173](#173)
* **settings:** import/export settings & templates ([#180](#180)) ([a27d23d](a27d23d)), closes [#162](#162) [#162](#162)
* **templates:** add {{currentDate}}, {{episodeNumber}}, {{duration}} template variables ([#189](#189)) ([ec573eb](ec573eb)), closes [#75](#75) [#34](#34) [#88](#88) [163/#186](#186)
* **templates:** add episode chapters tag ([#207](#207)) ([9c98863](9c98863))
* **transcripts:** opt-in speaker diarization for transcripts ([#168](#168)) ([#204](#204)) ([a96e12f](a96e12f))
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.17.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request] Add a way to go from note to the podcast episode in obsidian

1 participant