fix(download): prevent Android crash and create missing folders on download#178
Conversation
…wnload Downloading an episode buffered the whole file via requestUrl, wrapped it in a Blob, then round-tripped back to an ArrayBuffer before writing — holding ~3x the episode size in the JS heap. On Android's constrained WebView this OOM- crashed Obsidian mid-download with no file created (#113). Thread a single ArrayBuffer from requestUrl straight into vault.createBinary, dropping the Blob and the Blob->ArrayBuffer round-trip, and read audio-signature bytes from a zero-copy Uint8Array view instead of Blob.slice + FileReader. This removes every full-file copy PodNotes controls (peak ~3x -> ~1x). The remaining peak is inside requestUrl's native base64 decode, which no plugin can reduce, so this is a mitigation rather than a guaranteed cure for the very largest files. Also create missing parent folders before createBinary (mirroring createPodcastNote), so templated paths like podcast/{{podcast}}/{{title}} no longer fail to write (#86), and harden the file-name sanitizer to preserve dots (e.g. "Episode 1.5"), strip control characters, and collapse whitespace globally. Closes #113 Closes #86
Deploying podnotes with
|
| Latest commit: |
fcba581
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://68c6bb87.podnotes.pages.dev |
| Branch Preview URL: | https://chhoumann-113-android-downlo.podnotes.pages.dev |
There was a problem hiding this comment.
🚩 Pre-existing issue: DownloadPathTemplateEngine extension removal leaves trailing dot
The extension removal logic at src/TemplateEngine.ts:166-169 calls getUrlExtension(template) which returns the extension WITHOUT the leading dot (e.g., "mp3"), then does template.replace(templateExtension, ""). For a template like "{{title}}.mp3", this removes only "mp3" leaving "{{title}}." with a trailing dot. When the file extension is later appended by createEpisodeFile, the result is "Episode Title..mp3" (double dot). This is pre-existing behavior, not introduced by this PR, but with dots now preserved in titles it becomes slightly more visible since titles like "Episode 1.5" produce "Episode 1.5..mp3" when the template redundantly specifies an extension.
(Refers to lines 166-169)
Was this helpful? React with 👍 or 👎 to provide feedback.
# [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))
|
🎉 This PR is included in version 2.17.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
This reads like it was AI generated, but nevermind; more importantly, the download still crashes obsidian on mobile. |
It is. I use AI to help me develop, and that includes writing PR bodies.
Thanks for letting me know. I've been testing on my own mobile, which is a newer iPhone, and haven't experienced crashes after this PR. Following your comment here, I thought that it might be due to the app running out of memory. I've fixed that in #218 and will release it momentarily. Would you please help me test the next release, when it comes out? That'd be a great help. |
|
Sure, I'm using a Samsung S23 Ultra. It has 12 GB memory. I've tried a few times downloading the latest episode of "A Way With Words", from 22 June: https://waywordradio.org/flash-in-the-pan/ It's just 49 MB The download message window appears and stays visible for about 20 secs, then Obsidian crashes. |
|
Thank you! Would you go ahead and test this? The fix is out in version 2.17.1, which was just released. The main idea is that downloads now stream to the disk in small chunks, so we keep memory usage low. I reproduced this exact episode on the new build, and it downloads for me. While the fix is platform-agnostic, there is still the device platform difference. |
|
There's a download this time and I see a progress window, which survived about halfway, then it crashed Obsidian. I can see a mp3 (25 MB) created in the download folder. Download itself appeared to be going rather slow as well, which I wouldn't expect for such a small file size. |
|
Thanks for testing! Okay, so the streaming download does start and gets some of the way. I'd like to know a few things:
I'm currently a bit in the dark as to what's actually causing the crash. And how slow is it? Thanks! |
|
My Obsidian version is 1.12.7 (333), which is the latest public release as of now. Android System Webview 149.0.7827.159 I tried a small download (Merriam Webster Word of the Day podcast) and that episode did download (hurray 🎉!). It's a 2.08 MB download, so very small. After that I tried a bigger one, the same episode I mentioned previously and timed 55 secs to reach halfway around 20 MB and then again Obsidian crashed. It seems to be at the same halfway point each time. I have no sync running, no Google drive or anything, but I do have a large vault and lots of plugins (60-70). I tried with a smaller vault, it still crashed, BUT then I deactivated all my plugins except podNotes and to my surprise, it downloaded !!! So it seems there's a conflict with a plugin... I'll try to find which one by reactivating one by one. I'll report back when I find something. |
|
I think I may have found the culprit: waypoint plugin. After deactivating it the download completes. Could you test with this plugin and see if you could replicate the crash? |
|
Nice find - and thanks for the thorough debugging! Yeah, that reproduces for me. Looks like Waypoint is watching every time a file is modified. Even for smaller episodes, e.g. 47mb, we'd have 1 file creation and 11 file modify events. Since Waypoint reads the file each time, it'd load the growing file into memory. This is what led to the continued crashes. I've fixed it in #220 by streaming to a temporary, hidden file that Obsidian doesn't broadcast the file events for. This means no watcher plugins, Waypoint or others, will be able to react to it while we're creating it. I've released this now, would you test again? |
|
That fixed it for me! I reactivated the waypoint plugin and the download still succeeds. It's also fast I'm happy to report. I'm thrilled that you were able to solve this. Your explanation makes a lot of sense and I understand now what was happening. Thank you very much! ❤️ |
Summary
Fixes the long-standing Android crash when downloading a podcast (#113), where Obsidian crashes mid-download and no file is created. Also fixes the related "no file created" with a templated download folder (#86).
Root cause
The download path buffered the entire episode via
requestUrl(kept deliberately overfetchto avoid the CORS failures from #31 —requestUrlhas no streaming), then made two more full-size copies before writing:response.arrayBufferrequestUrlresultnew Blob([response.arrayBuffer])downloadFileawait blob.arrayBuffer()createEpisodeFile→createBinaryOn Android these ~3× coexist in a memory-constrained Capacitor WebView, so a typical 50–200 MB episode OOM-crashes the app mid-download — matching #113 and #86 (the latter reproduced even with download path
"A", ruling out illegal chars / missing folders as the crash trigger).Changes
Memory (the crash).
downloadFilenow returns a single{ data, contentType, byteLength }and that oneArrayBufferis threaded straight intovault.createBinary— theBlobwrap andBlob→ArrayBufferround-trip are gone. Audio-signature detection reads a zero-copyUint8Arrayview instead ofBlob.slice+FileReader. PodNotes-side peak heap drops from ~3× to ~1× the episode size. Applied to both download functions; each keeps its distinct extension strategy (UI = byte-signature → URL → content-type; API = URL → HEAD content-type →mp3) and the dual-cased content-type lookup.Missing folders (Obsidian crashes ~20 secs after Podcast should be downloaded (with no file created) #86). New
src/utility/ensureFolderExists.tscreates parent folders (per-segment guarded, sincecreateFolderthrows on an existing folder) beforecreateBinary, so paths likepodcast/{{podcast}}/{{title}}can write at all.File-name sanitizer.
replaceIllegalFileNameCharactersInStringnow preserves dots (Episode 1.5no longer becomesEpisode 15), replaces control chars with spaces globally, collapses whitespace, and trims leading/trailing dots — fixing the old non-global-flag bugs.Honest caveats
requestUrl's native base64 decode (atob→ binary string →Uint8Array), which no plugin can reduce. The fix removes every copy PodNotes controls; the very largest episodes on the most memory-starved devices may still OOM.Episode 15.mp3) will be re-downloaded once under the corrected name (Episode 1.5.mp3), leaving the old file orphaned..mp3(previously.mp3— both broken; vanishingly rare). Possible follow-up.Verification
lint,typecheck,format:check(Biome),check:a11y(svelte-check, 604 files / 0),build, and the full test suite (172 passing, 16 new) all green.createBinarythrowsENOENTon a missing folder, writes the exact bytes from anArrayBufferonce the folder exists, andcreateFolderthrows on an existing folder — validating both fixes' premises in the live app. The Android OOM itself is not reproducible on desktop (ample heap); desktop is a proxy for the path/write logic only.Notes
Closes #113
Closes #86