Local Player: atomic progress that travels with the drive
How writeFileAtomic + serial queue + .bak + .corrupt-<ts> eliminated position loss even when killing the server mid-save.
Losing where I stopped in a video was the bug that annoyed me most. So I treated progress as critical data.
Context
In part 1 I told you about the Local Player’s overall architecture. Here I’ll tell you about the subsystem that cost me the most sleep: saving the lesson position in server/progress/store.js.
I decided progress would live next to the library, in <lib>/.courseplayer/progress.json. That way it travels with the HDD or USB stick — I unplug it, take it to another machine, and pick up where I left off. Each entry is {position, duration, completed, updatedAt} under a libId\0rel key.
The problem I wanted to solve
It was always the same story: I’d shut down the server mid-save, the power would go out, or progress.json would get corrupted — and I’d lose everything. Or worse: I’d clear the transcode cache and the progress went with it.
My rule became: a normal save never deletes a key, never removes a completed, never loses a valid position. The frontend had to cooperate too, with throttling and a few guards against regression.
The most important decision
Three simple layers on the backend:
- atomic writes (
writeFileAtomic: writes to a tmp file, fsyncs, renames, fsyncs the directory); - serial queue (
updateProgress) — one save at a time, never two together; - double backup (
.bakand.bak.1; if the main file corrupts, it’s preserved as.corrupt-<ts>and restored from backup).
save → tmp exclusivo → fsync arquivo → rename → fsync dir
corrompido → preserva .corrupt-<ts> → tenta .bak → tenta .bak.1
I considered using SQLite with WAL, but it would pull in a native dependency and complicate things on FAT/exFAT. Atomic JSON with backup covers my real volume, and anyone can open and audit it.
flowchart TD
Player["timeupdate / pause / ended"] --> Fila["updateProgress (serial)"]
Fila --> Atomica["writeFileAtomic"]
Atomica --> Disco["progress.json"]
Disco --> Backup[".bak / .bak.1"]
How I used AI in development
It was great for generating the test matrix (progress-invariance, progress-persistence, progress-forensic, library-progress) — position regression, preserved completed, per-library isolation.
But it tried to talk me into parallel writes with aggressive debouncing on the backend to “optimize”. I had to lock the rule in AGENTS.md: serial queue, no exceptions.
And I did validation the hard way: I wrote progress, killed the server mid-save, restarted, and checked it came back. I corrupted the JSON on purpose to watch .corrupt-<ts> be born and the restore kick in.
What I discovered implementing it
- Clearing is surgical: by
<course>/prefix or global. AndPOST /api/transcode/clearclears transcode and cancels jobs, but never touchesprogress.json— there’s a test just for that. - Rescan never deletes progress.
- On the frontend (
public/js/player.js):timeupdatewith 5s throttle,pause/ended/beforeunloadwithsendBeacon; above 95% marks as completed; rewatching doesn’t unmark; position 0 doesn’t erase; nothing is written without metadata; resume only if3 < position < duration - 2. - Removing a library is config-only (with an active job it returns 409); the default one can’t be removed (403).
What worked
LP_DATA_DIRto run tests with a real server in a temporary directory, without touching my data.LP_PROGRESS_FORENSIC=1when I need verbose logging.- Per-library keys ended progress leaking between the internal HDD and the USB stick.
What didn’t work
- The first direct
fs.writeFile: a kill mid-write produced truncated JSON and total loss. The backup only became useful after atomicity. - I tried persisting the sidebar’s open folders alongside and gave up — the tree re-renders a lot and fought the saved state.
What I’d do differently today
I would have written the guards (“normal save never…”) as tests before the code, not after. Many AI bugs would have fallen at the first node --test.
Result
Passing today: kill mid-save preserves, corrupted becomes .corrupt-<ts> and restores, transcode clear doesn’t affect progress, rescan preserves everything.
Missing: compacting progress.json for huge libraries — today it rewrites the whole file on every save.
What I learned
1. Atomicity is cheap
Tmp + fsync + rename solves 90% with no new dependency.
2. “Don’t regress” is a specification
It has to be a test, not a comment.
3. Format follows portability
JSON in .courseplayer travels with the disk; a local database would chain my data to one machine.
Next steps
Conclusion
Reliable progress doesn’t need a new stack. It needs total ordering of writes, fsync in the right place, and backup tested with real kills.
References
server/progress/store.js,server/core/fs-atomic.js,server/progress/routes.jstest/progress-invariance.test.js,test/progress-persistence.test.js,test/progress-forensic.test.jsdocs/VALIDATION.mdsection 4