Repository navigation
fix: follow vault moves for local files and play linked vault files outside Local Files - #427
Merged
Merged
Conversation
…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.
Deploying podnotes with
|
| Latest commit: |
a3942b5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9aef724d.podnotes.pages.dev |
| Branch Preview URL: | https://fix-local-file-moves.podnotes.pages.dev |
… 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
How it works
src/getVaultRenameHandler.tsis the onevault.on("rename")handler, registered insrc/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.onunloadcloses 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 afterunload()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 newfilePath,urlandstreamUrl. A feed download gets only the newfilePath. 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 fromtowhen the session plays the file atfrom. It reuses the existingonEmptiedrecovery, so the episode keeps its status, position and speed. It is not an intent: it selects, queues and saves nothing, and leavesplay()untouched.PlaybackSourceand 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.tschanges only inside the exact-path branch.Three-way merge (#388)
No merge code changes. A rename edits list entries and leaves
playedEpisodesalone, 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.tspins 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 buildpasses.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":filePathstays"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.
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/verifyand the verifier's drivers.Fixtures/Awith "Move file to...", then let Lead end.Fixtures/Clip One.wav. The player advances to Clip One with statuserrorand shows "Could not load this episode's media". masterFixtures/A/Clip One.wav, and Clip One plays. branchFixtures/Long Talk.wavto "Long Talk Renamed", seek to 1500 s, capture a timestamp.MEDIA_ERR_NETWORK"PIPELINE_ERROR_READ: FFmpegDemuxer: data source error". The capture links the old, missing path. masterepisodeName=Long%20Talk&url=Fixtures%2FLong%20Talk%20Renamed.wav, and progress stays underlocal file::Long Talk. branchConcurrent progress. Base progress was 10.03 s. A remote data.json saved 42 s while this device renamed
Sync/Original.wavtoSync/Renamed.wav. The merged data.json keepslocal file::Originalat 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.mdwith "Note for "head Original" already exists", and no second note is made. branchSnapshot without a path. A current episode, favorite and playlist entry without
filePathplayedPathless/Before.wav. After the rename toPathless/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). branchUnload in the same task. A second rename listener called
app.plugins.unloadPlugin("podnotes")during the rename ofUnload/Before.wavtoUnload/After.wav. On e22f96b, data.json keptcurrentEpisodeatUnload/After.wavbut downloads, Local Files, queue, favorites and the playlist atUnload/Before.wav, and after a reload the Local Files entry showed "Could not load this episode's media". On 7787655 every saved store holdsUnload/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.wavtoLate/After.wav. On 28beef3, PodNotes' copied callback still ran after unload and savedcurrentEpisodeatLate/After.wavnext to every list atLate/Before.wav. On 7787655 the old instance saves nothing after unload, and every saved list holdsLate/Before.wav. Data and screen match a control run where the file is renamed while PodNotes is disabled.The isolated instance's
dev:errorsshowed no errors after these runs.Release impact
No data.json shape, schema, settings, API or URI change. A rename rewrites only
filePath,urlandstreamUrlof 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: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."Note
Fix vault-file URI handling to play files outside Local Files and follow moved registrations
The
podNotesURIHandlerin 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.Macroscope summarized a3942b5.