1- import { requestUrl } from "obsidian" ;
1+ import { type DataAdapter , requestUrl } from "obsidian" ;
22import { get } from "svelte/store" ;
33import { plugin } from "../store" ;
44import { encodeUrlForRequest } from "../utility/encodeUrlForRequest" ;
5+ import { enforceMaxPathLength } from "../utility/enforceMaxPathLength" ;
56
67// ---- Streaming download (issue #113) ---------------------------------------
78// Mobile WebViews have a tight per-process memory budget. Buffering a whole
89// episode (hundreds of MB) via requestUrl().arrayBuffer — plus the native->JS
910// bridge copy — used to OOM-kill Obsidian on iOS. Instead we pull the file in
1011// bounded HTTP Range chunks and append each straight to disk, so peak heap
1112// stays at roughly one chunk regardless of episode size.
13+ //
14+ // ---- Temp-then-move (Waypoint et al. crash) --------------------------------
15+ // Appending chunks directly to the final vault path makes the growing, half-
16+ // written media file visible to the vault's file watcher: a single download
17+ // fires ~12 create/modify events. Watcher plugins (Waypoint, Dataview, Obsidian
18+ // Git, MOC/index generators) react to each one and re-scan the partial file,
19+ // and that synchronous re-scan storm — racing the chunked writer — OOM-crashes
20+ // the app on mobile. So we stream every chunk to a dot-prefixed sibling temp
21+ // (which Obsidian keeps out of the file index entirely, so it fires zero events
22+ // while it grows), then move the finished, size-verified file into place as a
23+ // single rename. Watchers then see exactly one create of an already-complete
24+ // file — the same shape the pre-#113 atomic createBinary path produced.
1225
1326export const DOWNLOAD_CHUNK_SIZE = 4 * 1024 * 1024 ; // 4 MiB per range request
1427
15- export interface BinaryAppendAdapter {
16- writeBinary ( path : string , data : ArrayBuffer ) : Promise < void > ;
28+ // Obsidian's DataAdapter — writeBinary/rename/remove/list, all used below — is
29+ // fully typed and public. Only `appendBinary` exists at runtime on the desktop and
30+ // mobile Capacitor adapters without appearing in the public typings, so we extend
31+ // the real interface with that single bolt-on and keep one cast (appendableAdapter).
32+ export interface BinaryAppendAdapter extends DataAdapter {
1733 appendBinary ?( path : string , data : ArrayBuffer ) : Promise < void > ;
1834}
1935
@@ -97,18 +113,20 @@ export async function probeAndFetchFirstChunk(
97113}
98114
99115// Write the already-fetched first chunk, then pull the remaining bytes in
100- // bounded Range requests, appending each straight to disk. Peak memory is one
101- // chunk, not the whole file. Returns the total number of bytes written.
116+ // bounded Range requests, appending each straight to `destPath`. Peak memory is
117+ // one chunk, not the whole file. Returns the total number of bytes written.
118+ // `destPath` is the temp path (see partialPathFor); the caller moves the result
119+ // into the final vault path once it is complete and size-verified.
102120export async function writeStreamedFile (
103121 url : string ,
104- filePath : string ,
122+ destPath : string ,
105123 probe : RangeProbe ,
106124 onProgress ?: ( written : number , total : number | null ) => void ,
107125 chunkSize : number = DOWNLOAD_CHUNK_SIZE ,
108126) : Promise < number > {
109127 const adapter = appendableAdapter ( ) ;
110128
111- await adapter . writeBinary ( filePath , probe . firstChunk ) ;
129+ await adapter . writeBinary ( destPath , probe . firstChunk ) ;
112130 let written = probe . firstChunk . byteLength ;
113131 onProgress ?.( written , probe . totalSize ) ;
114132
@@ -144,7 +162,7 @@ export async function writeStreamedFile(
144162 const chunk = response . arrayBuffer ;
145163 if ( chunk . byteLength === 0 ) break ;
146164
147- await adapter . appendBinary ( filePath , chunk ) ;
165+ await adapter . appendBinary ( destPath , chunk ) ;
148166 written += chunk . byteLength ;
149167 onProgress ?.( written , probe . totalSize ) ;
150168
@@ -154,3 +172,71 @@ export async function writeStreamedFile(
154172
155173 return written ;
156174}
175+
176+ const PARTIAL_SUFFIX = ".podnotes-partial" ;
177+
178+ // A monotonic counter makes every temp name unique within a session even when two
179+ // downloads resolve to the same final path (distinct episodes can collide on one
180+ // path — #107/#183) and start in the same millisecond, so concurrent downloads
181+ // never share a temp file.
182+ let partialCounter = 0 ;
183+
184+ // The sibling temp path a download streams to before being moved into place. It is
185+ // dot-prefixed so Obsidian keeps it out of the file index (no watcher events while
186+ // it grows), lives in the same folder as the final file so the move is a cheap
187+ // in-place rename, and carries a unique token so concurrent downloads can't clash.
188+ // The token comes first so it survives the length cap (#22), which trims the tail:
189+ // the final name's own segment is already at the byte limit, and prepending a dot +
190+ // appending the suffix would otherwise push the temp past ENAMETOOLONG. The embedded
191+ // final name is only there to make the temp recognisable while debugging.
192+ export function partialPathFor ( filePath : string ) : string {
193+ const slash = filePath . lastIndexOf ( "/" ) ;
194+ const dir = slash === - 1 ? "" : filePath . slice ( 0 , slash + 1 ) ;
195+ const name = slash === - 1 ? filePath : filePath . slice ( slash + 1 ) ;
196+ const token = `${ Date . now ( ) . toString ( 36 ) } -${ ( partialCounter ++ ) . toString ( 36 ) } ` ;
197+ return enforceMaxPathLength ( `${ dir } .${ token } .${ name } ${ PARTIAL_SUFFIX } ` , PARTIAL_SUFFIX ) ;
198+ }
199+
200+ export function isPartialPath ( path : string ) : boolean {
201+ const name = path . slice ( path . lastIndexOf ( "/" ) + 1 ) ;
202+ return name . startsWith ( "." ) && name . endsWith ( PARTIAL_SUFFIX ) ;
203+ }
204+
205+ // Move the completed temp file to its final vault path with a single rename, so
206+ // watchers see one create of an already-complete file. rename is a standard
207+ // DataAdapter op on every platform and, because the temp is a sibling of the final
208+ // file, an in-place metadata move that buffers zero bytes — preserving #113's
209+ // memory win. (We never finalize by reading the temp back into memory and
210+ // re-writing it: that whole-file buffer is exactly the #113 OOM this path avoids.)
211+ export async function moveIntoPlace (
212+ tmpPath : string ,
213+ filePath : string ,
214+ ) : Promise < void > {
215+ await appendableAdapter ( ) . rename ( tmpPath , filePath ) ;
216+ }
217+
218+ // Remove temp partials orphaned in `folder` by a previous download that was hard-
219+ // killed (OOM, force-quit) before it could move its file into place or clean up —
220+ // otherwise hidden partials accumulate and can replicate via sync. `isActive`
221+ // guards this and any concurrent download's live temp from being swept (temp names
222+ // are unique per attempt, so a live temp would otherwise look like an orphan).
223+ // Best-effort: a listing failure must never block a download.
224+ export async function sweepStalePartials (
225+ folder : string ,
226+ isActive : ( path : string ) => boolean ,
227+ ) : Promise < void > {
228+ const adapter = appendableAdapter ( ) ;
229+ try {
230+ const listing = await adapter . list ( folder ) ;
231+ for ( const entry of listing . files ) {
232+ if ( isPartialPath ( entry ) && ! isActive ( entry ) ) {
233+ await adapter . remove ( entry ) ;
234+ }
235+ }
236+ } catch ( error ) {
237+ console . error (
238+ `Failed to sweep stale download temp files in "${ folder } ":` ,
239+ error ,
240+ ) ;
241+ }
242+ }
0 commit comments