Local Player: vibecoding with invariants and zero build step
How a local/offline player in Express + vanilla JS organizes thousands of lessons with no bundler: AGENTS.md as contract, physical structure as source of truth, and scan with on-disk cache.
I wanted a simple way to watch my courses on disk, with no cloud and no hassle — and I ended up building an entire player with AI help.
Context
I had a very concrete problem: lots of courses scattered across HDDs, SSDs, USB sticks, and SD cards, each in a folder organized its own way, and no player that organized all of it properly. VLC plays, but it doesn’t remember where I stopped. Online platforms don’t work for me because I want everything offline, with no telemetry, sending nothing to the internet.
That’s how the Local Player was born. It’s a local server in Node.js + Express with a pure-JS SPA, with no build step at all. server.js is only 145 lines and just wires things together — the rest lives in submodules under server/. Today the project is about 22k lines in the backend, almost 20k in the frontend, 21 test files, and docs in English and Portuguese.
And yes, I built it as vibecoding: I described the behavior, the AI generated it, I reviewed it. What saved the project from becoming a throwaway prototype was a 283-line file called AGENTS.md, which became the contract of what can never break.
The problem I wanted to solve
Watching lessons on disk sounds easy until you face a real disk. In my case it was:
- thousands of lessons on a slow FAT USB stick;
.mp4mixed with.mkv,.avi,.movthat the browser sometimes plays, sometimes doesn’t;- progress that vanished if the server died mid-save;
- fear of a malicious path escaping the library;
- wanting AI subtitles and a tutor, but without depending on them to press play.
I decided upfront: Express as the only dependency, no bundler so nothing breaks on an external drive, and the folder on disk rules — the code only reads, never guesses.
The most important decision
Keeping server.js thin, only registering routes:
core.registerLibraryRoutes(app);
core.registerProgressRoutes(app);
core.registerMediaRoutes(app);
core.registerSystemRoutes(app);
core.registerAiRoutes(app);
core.registerTutorRoutes(app);
core.registerSubtitleRoutes(app);
I discarded two ideas: throwing everything into one giant file (the AI generates it fast, but I’d never be able to review it) and using React/Vite (it would demand a toolchain right on the user’s USB stick).
The price was writing more vanilla JS by hand. The payoff: I change server.js or something in public/, reload the browser, and that’s it. No build, no sourcemap leaking paths.
flowchart LR
Disco --> Scan[scan + tree-cache]
Scan --> API["/api/tree por biblioteca"]
API --> SPA["SPA hash routing"]
SPA --> Media["/media com Range"]
Media --> Player["<video> + sidebar"]
How I used AI in development
Where it genuinely helped me:
- generating the repetitive route modules from one I had already reviewed;
- mirroring the EN/pt-BR documentation;
- spitting out path-traversal edge cases for the test suite.
Where it got in the way:
- it tried to guess whether a folder was a topic from its “pretty name” instead of following the explicit rule;
- it wanted to convert video by extension instead of checking the codec with ffprobe;
- it tried to
awaitsubtitles beforeplay().
What I learned: without AGENTS.md in context, it loosened the rules. With it open, rework plummeted. And everything touching paths, progress, or queues I tested by hand with npm start on port 4173.
What I discovered implementing it
- A folder only becomes a topic if it has a
.topicfile or its name ends with(TP). Otherwise it’s a course/module. The(TP)disappears only from the on-screen title. No guessing. - The pretty titles are computed on the server (
server/core/titles.js): it stripsLesson 03 -,~1, tags, and underscores, and keeps acronyms like SQL and Node.js. - The scan ignores the app folder, files with a leading
., and.ini/.db/.lnk. It finds covers by name (cover,poster,banner) or inherits them from a child — if there’s nothing, it generates a gradient with the initials. - Ordering uses numeric pt-BR
localeCompare. It looks like a detail, but navigation breaks if you get it wrong. npm install --no-bin-linksis deliberate, so it works on FAT/exFAT.
What worked
ROOTalways derived from__dirname, never hardcoded. The default library is wherever the app is.- Tree with per-library cache, rescan via the refresh button or API.
- Every path coming from the client goes through
resolveSafeRelPath(). Noexpress.static(ROOT), only the SPA inpublic/. Video goes out with Range 206. - If another instance is already on the port, it warns clearly and exits with
exit(1)— because two instances would corruptprogress.json.
What didn’t work
- The first scan with no cache left a large library on a USB stick unusable on every boot.
- I tried serving HTML/SVG/JS materials inline — a risk of running code on the app’s origin. Now everything goes out as
attachment+nosniff. - I tried persisting the sidebar’s expanded folders and gave up: the tree re-renders directly and fought the saved state. It’s memory-only again.
What I’d do differently today
I would have extracted scan.js and titles.js much earlier — they were the ones the AI rewrote most. And I would have frozen the key format (libId\0rel, sha1(...)) on day one.
Result
What already pays off: scan, hash navigation, compatible video playing directly, accent-insensitive search, favorites, 14 configurable shortcuts, node --check and node --test passing.
What’s missing: the final AppImage and the Whisper models (which will never go into git because they’re huge).
In the next part I’ll tell you how I solved progress so I’d never lose my position again.
What I learned
1. Contract before prompt
An AGENTS.md with “removing a library is config-only” is worth more than a clever prompt.
2. Disk rules, code obeys
An explicit topic rule killed an entire class of bugs the AI loved to create.
3. Zero build is a feature
On someone else’s machine and an external drive, node server.js + F5 is freedom.
Next steps
Conclusion
Vibecoding scales when I state what can’t break and let the tests say no fast. The rest is cheap iteration.
References
server.js,server/core/scan.js,server/core/titles.js,server/libraries/registry.jsdocs/DOCUMENTATION.md,docs/VALIDATION.md,AGENTS.md- Part 2 of the series