Skip to content

fix: follow vault moves for local files and play linked vault files outside Local Files - #427

Merged
chhoumann merged 16 commits into
masterfrom
fix/local-file-moves
Oct 13, 2026
Merged

chhoumann merged 16 commits into
masterfrom
fix/local-file-moves

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Local files now follow the vault. When you move or rename a file in Obsidian, PodNotes updates every place it keeps that file: Local Files, the queue, favorites, playlists, the current episode, and the player. A queued file that moved plays from its new path when the queue reaches it. A playing file keeps playing after a move, past the part already buffered, and the next timestamp you capture links its new path. A downloaded episode moves the same way.

A move or rename changes only where the file is. The episode keeps its title, so its saved position, its episode note and its identity on other devices stay as they were.

A timestamp link to a file that exists at its path in the vault now plays that file at the link's time, whether or not the file is in Local Files. If the file is registered at that path, PodNotes plays it as registered, under its title and with the audio or video choice made for an .mp4 or .webm.

Fixes #412
Fixes #410

Behavior decisions

  • A rename keeps the episode. A renamed file plays under the title it was registered with. Its progress key, note path and sync identity do not change, so progress saved on another device at the same time still merges, and Create episode note opens the existing note. Play with PodNotes on a renamed file keeps the title it is registered under at that path.
  • Two files with one name stay apart. Renaming a file to the name of another file in Local Files keeps two separate entries, each with its own title and path, and shows no notice. The existing "A local file named "X" was already tracked at "". It has been replaced by ""." notice remains only for Play with PodNotes on a file that has no registration at its path, when another file is already tracked under its name.
  • Every local snapshot follows. This includes a snapshot without a file path, which plays the file registered under its title.
  • Deletes change nothing. A deleted file's entries stay. Playing one still shows "Could not load this episode's media", and Remove from Local Files removes it. Sync services deliver a move as a delete and a create, so acting on deletes would lose entries.
  • Unrelated renames are cheap and write nothing. A rename that no stored episode refers to returns after one scan, and requests no save.
  • The fix: make podcast name lookups exact and safe #413 rule stays. A link whose path is missing still plays a file by name only when exactly one vault file has that name.
  • Renames while PodNotes is off are not seen. Obsidian reports renames only while the plugin is loaded.

How it works

  • src/getVaultRenameHandler.ts is the one vault.on("rename") handler, registered in src/main.ts. Obsidian reports a moved folder once per file inside it, so the handler collects the renames of one task and follows them in one pass in a microtask. onunload closes the handler first: it follows the renames received so far, while the player and the store bindings still save, then ignores any later callback. Obsidian still calls a listener it collected for a dispatch after an earlier listener unloaded PodNotes, and that late callback now does nothing, so the library stays at the old path, as for a rename made while PodNotes is off.
  • src/persistence/writer.ts: the final save after unload() writes memory as it was at unload, advanced only by what the writer itself merges in. A callback that runs after unload can no longer reach data.json. A local snapshot gets the new filePath, url and streamUrl. A feed download gets only the new filePath. A store is set only when it holds a moved file.
  • PlayerController.relocate(from, to, moved) updates the shown episode, and reloads the media element from to when the session plays the file at from. It reuses the existing onEmptied recovery, so the episode keeps its status, position and speed. It is not an intent: it selects, queues and saves nothing, and leaves play() untouched. PlaybackSource and the session record the vault file they play, so a feed episode playing its downloaded copy also follows a move of that copy.
  • src/getContextMenuHandler.ts: Play with PodNotes keeps the title of a registration at the file's exact path.
  • src/URIHandler.ts changes only inside the exact-path branch.

Three-way merge (#388)

No merge code changes. A rename edits list entries and leaves playedEpisodes alone, so progress or a finished marker saved on another device in the same sync window merges as before, in either device orientation. Two devices applying the same rename keep one entry at the new path. merge.test.ts pins all of these.

Verification

Node v22.23.2. On head 7787655 (this branch merged with origin/master edb9447):

  • npm run lint: 0 errors, 0 warnings. npm run format:check: all files formatted. npm run typecheck: passes.
  • npm run build passes. npm run test: svelte-check clean, 100 files, 1768 tests pass. npm run test:tooling: 10 tests pass. npm run docs:build: all 112 legacy fragments resolve.
  • npm run test:e2e: 12 files, 80 tests pass.
  • npm run size: raw 287,557 bytes, gzip level 9 91,607 bytes. Against origin/master edb9447 (285,622 and 90,884), that is +1,935 raw and +723 gzip. Both budgets pass.

The first commit (ef75517) adds the two regressions, and both fail on master:

  • src/main.fileRenames.test.ts "plays from where the file is now once the queue reaches it": filePath stays "Fixtures/Clip One.wav" instead of "Fixtures/A/Clip One.wav".
  • src/URIHandler.test.ts "plays the file at the link's time when it is not in Local Files": expected undefined to match object { title: 'Orphan', … }.

Against a build of ef75517, the queue e2e test and the unregistered-link e2e test both fail. The queue advanced to the moved file but did not play, and the link loaded no episode. The third e2e test, for a playing file that moves, fails on a mutation that restarts the reload from 0 s: expected 0.023615 to be greater than or equal to 1.023475.

Performance

Measured with the verifier's driver, in Obsidian 1.14.4 on this host, at a load average of about 3. A library of 500 tracked local files sits in downloads, the queue, favorites and one playlist, and the folder holding them is renamed. Each run alternated five trials of master 48566ab and five of the branch build.

Run Handler Folder rename median, master Folder rename median, branch Unrelated rename dispatch, branch
1 one pass per rename event 27.7 ms 197.6 ms 0.1-0.5 ms
2 one pass per task (1de1400) 36.5 ms 48.2 ms 0.1-0.2 ms

The folder figure is the time await app.fileManager.renameFile(folder, ...) takes on the main thread, which includes PodNotes' pass. Master does no tracking work, so the difference within a run is PodNotes' cost: about 12 ms now, down from about 170 ms.

Every branch trial ended with all 500 entries at the new path and wrote data.json once after a flush.

Obsidian runtime

Obsidian 1.14.4 on headless Linux, with this worktree's isolated vault podnotes-fix-local-file-moves. Steps ran through the file explorer menu, the command palette, or a clicked link in Reading view, driven by .claude/skills/verify and the verifier's drivers.

  1. Queue a file, move it, let the queue advance. Play Clip One with PodNotes, then Lead, which queues Clip One. Move Clip One to Fixtures/A with "Move file to...", then let Lead end.
    • Master: the queue and Local Files keep Fixtures/Clip One.wav. The player advances to Clip One with status error and shows "Could not load this episode's media". master
    • Branch: the queue and Local Files hold Fixtures/A/Clip One.wav, and Clip One plays. branch
  2. Remove a file from Local Files, keep it in the vault, open its link. Play Orphan, capture a timestamp, select Remove from Local Files, play Lead, then click the link.
    • Master: a notice says "Episode not found". master
    • Branch: Orphan plays from the link's time, and the direct URI path does the same. Local Files stays empty. branch
  3. Rename a 30-minute file while it plays. Rename Fixtures/Long Talk.wav to "Long Talk Renamed", seek to 1500 s, capture a timestamp.
    • Master: after the seek the element stalls, then fails with MEDIA_ERR_NETWORK "PIPELINE_ERROR_READ: FFmpegDemuxer: data source error". The capture links the old, missing path. master
    • Branch: "Long Talk" plays on from 1500 s to 1525 s. The capture writes episodeName=Long%20Talk&url=Fixtures%2FLong%20Talk%20Renamed.wav, and progress stays under local file::Long Talk. branch
  4. The verifier's repros:
    • Concurrent progress. Base progress was 10.03 s. A remote data.json saved 42 s while this device renamed Sync/Original.wav to Sync/Renamed.wav. The merged data.json keeps local file::Original at 42 s, and Local Files and the current episode hold the new path.

    • Episode note. Create an episode note, rename the audio file, and run Create episode note again. It opens PodNotes/local file/head Original.md with "Note for "head Original" already exists", and no second note is made. branch

    • Snapshot without a path. A current episode, favorite and playlist entry without filePath played Pathless/Before.wav. After the rename to Pathless/After.wav, all three hold the new path under the title "Before". Capture Timestamp inserts - [00:00:00](obsidian://podnotes?episodeName=Before&url=Pathless%2FAfter.wav&time=0.76). branch

    • Unload in the same task. A second rename listener called app.plugins.unloadPlugin("podnotes") during the rename of Unload/Before.wav to Unload/After.wav. On e22f96b, data.json kept currentEpisode at Unload/After.wav but downloads, Local Files, queue, favorites and the playlist at Unload/Before.wav, and after a reload the Local Files entry showed "Could not load this episode's media". On 7787655 every saved store holds Unload/After.wav, and the reloaded Local Files entry plays.

    • An earlier listener unloads PodNotes. A listener registered before PodNotes unloads and reloads it during the rename of Late/Before.wav to Late/After.wav. On 28beef3, PodNotes' copied callback still ran after unload and saved currentEpisode at Late/After.wav next to every list at Late/Before.wav. On 7787655 the old instance saves nothing after unload, and every saved list holds Late/Before.wav. Data and screen match a control run where the file is renamed while PodNotes is disabled.

The isolated instance's dev:errors showed no errors after these runs.

Release impact

No data.json shape, schema, settings, API or URI change. A rename rewrites only filePath, url and streamUrl of the stored episodes that refer to the file. Titles, episode keys and progress records do not change.

Docs at release

  • docs/src/content/docs/local_files.md:
    • In "Remove a download or a local file", replace "Links to your own file stop working until you select Play with PodNotes on the file again." with "Links to your own file keep working while the file stays in your vault."
    • In "What works with local files", replace "A timestamp link to a local file plays only on a device where the file is in your vault and in Local Files." with "A timestamp link to a local file plays on any device where the file is in your vault."
    • Add: "When you move or rename a file in Obsidian, PodNotes follows it in Local Files, the queue, playlists and the player. The episode keeps its name in PodNotes, its position and its note. PodNotes does not see a move made while it is off or outside Obsidian."
  • docs/src/content/docs/faq.md, "What happens to notes and links when I remove a download?": replace "Links to your own media files need the file and its entry in Local Files. After Remove from Local Files, select Play with PodNotes on the file again to restore its links." with "Links to your own media files need only the file in your vault."
  • docs/src/content/docs/timestamps.md: replace "A link to a local file needs the file in Local Files." with "A link to a local file needs the file in your vault."
  • Change notes: moved and renamed local files keep playing and stay in the queue and playlists, with their progress and notes (Queued local files keep stale paths after a vault move #412). Timestamp links play vault files that are not in Local Files (Timestamp links require a Local Files entry even when the exact vault file exists #410).

Note

Fix vault-file URI handling to play files outside Local Files and follow moved registrations

The podNotesURIHandler in URIHandler.ts now plays an existing vault file addressed by its exact path even when it is not in Local Files. If no episode is registered at that path, it creates a local episode, picking the media type from the path when unambiguous, or from a same-named registration, defaulting to audio. Registrations at the same path keep their stored title and other episode data.

  • Adds a test suite in URIHandler.test.ts covering unregistered audio files, registered video containers, and files whose registered title differs from their filename. Tests check playback time, media element type, resource URL, player view, and that no episode lookup runs for direct path links.
  • Behavioral Change: direct vault-file links no longer require a Local Files registration; previously unregistered files now default to audio playback.

Macroscope summarized a3942b5.

…utside Local Files

A vault rename or move now updates every stored snapshot of the file:
downloads and Local Files, the queue, favorites, playlists, the current
episode and its progress, and the loaded media, which reloads from the
new path at the same position. A renamed local file takes its new name.
A timestamp link to an existing vault file plays it whether or not it is
in Local Files, as registered at that path when it is.

Fixes #412
Fixes #410
…ry list

The player now records the vault file a session plays, so moving a
downloaded copy that a feed episode plays reloads it from the new path.
A local file renamed to another tracked file's name takes over that
name's entries in the queue, favorites and playlists as in Local Files,
and its progress leaves no legacy title-only record behind.
…lated progress

relocate takes the new path from the rename instead of reading it back
from the downloads store. Moving progress deletes only the keys a
progress lookup reads, so another podcast's record survives. The
playing-file e2e test now checks the position on the first observation
after the reload.
…ress

moveProgress deletes the keys getPlayedEpisode reads through one
getPlayedEpisodeKeys helper, so the two cannot drift. The moved-download
relocate test no longer pre-writes the store, so it fails if relocate
reads the new path back from it.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: a3942b5
Status: ✅  Deploy successful!
Preview URL: https://9aef724d.podnotes.pages.dev
Branch Preview URL: https://fix-local-file-moves.podnotes.pages.dev

View logs

… one pass

A rename or move now changes only where the file is. The episode keeps
its title, so its progress, its note and its identity on other devices
stay as they were, and a concurrent progress change from another device
merges as before. Local snapshots without a path, which play the file
registered under their title, follow the move too. Play with PodNotes
keeps the title a file was registered under at its path. Renames of one
task, such as the files of a moved folder, are followed in one pass, and
a rename that touches nothing PodNotes stores returns at once.
A rename is followed in a microtask so a moved folder is one pass. An
unload in the same task, such as a reload another plugin triggers, ran
before that microtask, after the stores stopped saving, so data.json kept
the old paths. onunload now follows the pending renames first, and the
queued callback finds nothing left to do.
Covers both listener orders in one rename dispatch, a second rename in a
later task, a path one moved file vacated and another took, and a writer
change made after unload.
…nload

Obsidian still calls the rename listeners it collected for a dispatch
after an earlier listener unloaded PodNotes. That late callback changed
the current episode after the stores stopped saving, and the final save
wrote it next to library entries at the old path. Unload now follows the
renames received so far and then closes the handler, and the writer's
final save writes memory as it was at unload, so no callback that runs
later can reach data.json.
A save already in flight at unload merges a change from disk and
publishes it to memory. The snapshot taken at unload missed it, so the
final save read the change as a local deletion and dropped it.
@chhoumann
chhoumann merged commit 0a8d298 into master Oct 13, 2026
14 checks passed
@chhoumann
chhoumann deleted the fix/local-file-moves branch October 13, 2026 02:59
chhoumann added a commit that referenced this pull request Oct 13, 2026
…e shell

The merged test from #427 asserted the deleted viewState store; the shell opens the full player through the link's reveal.
chhoumann added a commit that referenced this pull request Oct 13, 2026
* feat(ui): add the shell's page model and plugin-scope navigation

* feat(ui): observe the view's width from its own window

* feat(ui): host the legacy grid and episode list in runes pages

* feat(ui): replace the tab bar with the v3 view shell

* feat(ui): restore the last page and open the full player from Show player

* refactor(ui): open the full player through navigation and delete the tab state

* refactor(ui): move EpisodePlayer to runes and shell services

* fix(ui): lay the shell out edge to edge in the Obsidian view

* test(e2e): drive the journeys through the view shell and add shell journeys

* docs(e2e): say the capture verbs reveal the PodNotes view

* fix(ui): keep Show player's full player open through a deferred view's restore

Obsidian 1.14 holds the PodNotes leaf in a collapsed sidebar deferred.
Revealing it loads the view, whose setState restored the saved page and
collapsed the player Show player had just expanded. Restoring the saved
page now leaves the player as it is; only the user's navigation collapses.

Show player also expands only while an episode is loaded, so an expanded
flag never waits hidden and pops the player open after a later restore.

* fix(ui): keep the player's Escape on the stack of the view's window

Obsidian keeps one scope stack per window and pushes onto the active
window's. The player's scope stayed on the window it was pushed in, so a
player moved to a popout lost Escape, and Escape typed in a main-window
editor collapsed it, read from the popout's stale activeElement.

The scope now follows the view's window: it is pushed on mount and on each
focus of that window while it is active, and moves when Obsidian migrates
the view. Escape collapses only when the key comes from inside the view or
its document's body. A keyboard collapse returns focus to the mini player.

The test keymap keeps a stack per window, as Obsidian's does.

* fix(ui): make the page and the rail inert under the full player

The full player covered the page, the rail and the first-run block only
visually: Tab and Shift+Tab still reached their controls. They are inert
while the player is open, and stay mounted with their scroll.

* fix(ui): give the view settings that follow toggles and imports

The shell handed components a plain settings object, so the player's
Queue no longer followed the queue automation toggle (#108), and a
settings import, which replaces the object, left the view on the old one.
ShellServices.settings is now a store MainView derives from the plugin
store, which the toggle and the import both emit.

* fix(ui): name the shell's landmarks without tooltips

Obsidian shows the nearest aria-label as a tooltip wherever the pointer
rests, so the rail showed "PodNotes" over empty space and the full player
showed "Player" over itself. The rail, the Library rows, the mini player
and the full player now take their names from hidden text through
aria-labelledby. The names stay the same.

* fix(verify): refuse to click an element something covers

ui.mjs clicked the center of a visible match even when another element,
such as the full player, covered it, so the click landed on the cover.
click, type, fill and wait now wait until nothing covers the match's
center, and fail with "covered by <tag.class>" when it stays covered.

* fix(ui): say why the mini player's Timestamp added nothing

When no editor took the timestamp and the episode note has no path or
template, the mini player now says "Set an episode note path and template
to capture timestamps". A failed write keeps its own Notice. The mini
player also drops the time left once none is left, instead of "1 min
left", and fills its play and pause glyphs as the prototype does.

* fix(ui): let the shell inset its pages and center the first run

Inside the shell's scroller the legacy podcast grid and the Playlists
grid lose their own padding, so they line up with the page's heading.
The first run's column is capped at 360 px and centered, top-aligned so
the search field stays put as results load under it. Podcasts uses the
layout-grid icon, the prototype's 2x2 grid.

The podcast search fades in only. Its outro attached to the page's key
block and lingered over the next page; an {#if true} wrapper hid that
and goes away.

* test(ui): check that every kind of library keeps first run away

* refactor(ui): derive the shell's head from one pure headFor

The Back label, the head title, the page title and the compact Library
actions each re-encoded part of one rule over layout and page. headFor in
pages.ts now owns that rule and is table-tested. The DOM is unchanged.

* fix(ui): push the player's Escape scope once per window

Pushing it again on every focus of the view's window lifted it over a
modal opened since, such as Settings, so Escape no longer closed the
modal after the window lost and regained focus. The scope now goes onto
the view window's stack once, when that window is active, and moves
only when the view moves to another window.

* fix(ui): keep the mini player clear of the phone navbar and drawer fade

On a phone, Obsidian's floating navbar covered the mini player in the
main area, so Timestamp and play/pause could not be tapped, and a mobile
drawer's fade washed out its bottom. The shell's main-area view content
now pads by the navbar's height and offset on phones only, since a
tablet has no navbar, and the drawer fade is hidden for the PodNotes
leaf while the mini player shows.

* test(e2e): check that Back restores the Library's scroll on a real scroller

The browse journey now subscribes to enough podcasts that the Library
scrolls. It wheels the Library to the bottom, opens the last podcast,
goes Back, and reads where the page's scroller came to rest.

* style(ui): say why the first run's column is top-aligned

* test(e2e): keep Obsidian's keyring warning off the view

Without a keyring, Obsidian loads stored secrets with a Notice that stays
until dismissed, over the top of the right sidebar. Once a test stored a
secret, each later reload brought it back over the view, so a click on
the full player's Collapse button found it covered, depending on the
order the files ran in. The harness now dismisses the warning for the
run.

* refactor(ui): make every Navigation step say whether it collapses the player

* fix(ui): keep the full player open when a removed page falls back

When Show player revealed a deferred view whose saved podcast or playlist
had been removed, the shell's fallback to Latest episodes went through
goRoot, which collapses, so the user got only the mini player. Removing
the open podcast under the full player collapsed it the same way.

The fallback is not the user navigating, so it now goes through
Navigation.replace, the renamed restore, which keeps the full player.

* fix: say why a timestamp has nowhere to go from the headset too

appendTimestampToEpisodeNote decides that the episode note is not set
up, so it now shows the "Set an episode note path and template" Notice
itself, next to its failure Notice. The headset button reports it as the
mini player's Timestamp did, and the mini player no longer re-derives
the rule from settings.

* style(test): join the shell test's store import

* refactor(ui): write the Add podcast page's search once

* test(ui): name the deferred-restore rows by what they check

Drop the resize fixture the test never drives, and say in MainView.getState that whether the full player is open is not saved.

* refactor(ui): give the view its library by context

ShellServices gains a library of the stores the view reads. MainView
fills it from the plugin's stores, and the shell, its pages and the
player read it from context. Tests seed an isolated library, and a check
fails on a view component that imports these stores from src/store.

* refactor(ui): make PodcastGrid the one grid the library pages fill

PodcastGrid takes its cards as children and owns the grid's CSS. The
Library page fills it with podcasts and the Playlists page with
playlists, so the copied grid rules, the unused playlist path and the
shell's padding and scroll overrides for the grid go. Both pages render
pixel for pixel as before at 320 and 820 px.

* fix(ui): tell assistive technology the mini player's progress

The bar the mini player draws, the compact line or the wide track, is
a progressbar named Episode progress, with its time as value text such
as 12:34 of 45:00. A screen reader can read progress without opening
the full player. The times now come from formatDuration, which formats
them as the mini player's own clock did.

* refactor(ui): size the mini player's buttons with the shell's token

The mini player sets --pn-btn instead of overriding each button's size
for desktop and phone, and the rail and the head share one spacer rule.
Both render pixel for pixel as before.

* refactor: drop exports the old view left unused

The Selection type and the played-episode placeholder's export served
only the view the shell replaced.

* test(ui): guard every shell file and store module against library imports

Scan the shell's TypeScript too, match imports from src/store/feeds and the other store modules, and pin the layout each progress check renders.

* test(uri): check that a vault file link reveals the player through the shell

The merged test from #427 asserted the deleted viewState store; the shell opens the full player through the link's reveal.

* test(ui): catch store re-exports and whole-module imports in the library guard

A helper with export { queue } from "src/store" let a shell component read the global queue past the guard (V-434b). The guard now reports export ... from, export * and import * as, and pins each form, the verifier's bridge included.

* docs: point the shell's comments and verify recipe at its pages

The Local Files mirror comment named the deleted podcast grid, and the transcripts recipe said to click Podcast grid.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Queued local files keep stale paths after a vault move Timestamp links require a Local Files entry even when the exact vault file exists

1 participant