← Back to archive

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;
  • .mp4 mixed with .mkv, .avi, .mov that 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 await subtitles before play().

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 .topic file 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 strips Lesson 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-links is deliberate, so it works on FAT/exFAT.

What worked

  • ROOT always 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(). No express.static(ROOT), only the SPA in public/. 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 corrupt progress.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.js
  • docs/DOCUMENTATION.md, docs/VALIDATION.md, AGENTS.md
  • Part 2 of the series
Diagrama ampliado
100%
↑↓ to navigate · Enter to open 8 items in the index