Movie Choice
A dice roll for your Plex library. Point it at your own Tautulli server, narrow the pool however you like, and let it pick so you can stop scrolling and start watching.
Frontend only — no server of its own, no database, no accounts. Host one copy and anyone can use it with their own Tautulli. Installs as a PWA.
Configuration
There isn't any, in the deployment sense. Every setting is entered in the app and stored in that visitor's own browser: server address, API key, which libraries to draw from, spin length, sound.
This is deliberate. There is no .env, no injected config.js, and nothing baked
into the bundle, because anything the host could configure would be served to every
visitor — which is exactly how an API key leaks. The built artifact contains no
credentials at all, and a visitor's key never leaves their browser except in a
request to their own server.
Practical consequences:
- The person hosting this never sees anyone's key, and cannot.
- Each visitor needs their own Tautulli.
- Clearing site data signs you out. There is no recovery, because there is nothing stored anywhere else.
- "Disconnect and erase this browser's data" in Settings removes the credentials and the cached library and metadata.
Deployment
docker compose up -d --build
Then open http://your-host:8480. That is the whole deployment — no environment
variables, no secrets, no .env file to keep out of version control.
The image is a multi-stage build: Node compiles the bundle, nginx-unprivileged
serves it as a non-root user on port 8080 with a read-only root filesystem. Nothing
is generated at container start, so the web root never needs to be writable.
sw.js and index.html are served uncacheable so updates take effect;
fingerprinted assets are cached for a year.
HTTPS is the one thing to plan around
If you serve this over HTTPS — which you need for PWA install on a public domain —
then browsers will block calls to a plain-http:// Tautulli. That is mixed
content, and it applies even to LAN addresses like http://192.168.1.10:8181.
So a publicly hosted HTTPS copy only works for people whose Tautulli is also reachable over HTTPS: behind a reverse proxy with a certificate, or through something like Tailscale or a Cloudflare Tunnel. The setup form detects the mismatch and says so before you try to connect, rather than failing with a vague network error.
If everyone using it is on your LAN, serving the app over plain HTTP avoids the problem entirely — at the cost of PWA installability in most browsers.
Tautulli has to allow the browser in
The app calls Tautulli directly, so Tautulli must send CORS headers for the app's
origin: Settings → Web Interface → Allow Origin. The default * already works.
One more trap: the address is resolved by the browser, not by any server. A
Docker service name like http://tautulli:8181 will not work for a phone on the
LAN.
How it works
Tautulli splits film data across two calls, and the app is built around that seam rather than hiding it:
get_library_media_inforeturns the whole library in a couple of paged calls, but carries no genre, runtime or synopsis. It backs the pool and the instant filters: title, year, watch state, quality, library.get_metadatacarries genre, certificate, runtime, score, cast and synopsis — but it is one HTTP call per film. It backs the deeper filters.
So rolled films are always enriched on demand (at most ten calls, and the reels spin while it happens), and the deeper filters run against a metadata index you build once from the filter panel. The index lives in IndexedDB, so it survives reloads and works offline. Filter facets are computed from whatever is indexed, which is why genres appear gradually rather than all at once.
The roll
The winners are drawn the instant you press the button — the spin is theatre, not the lottery. It exists so the result lands rather than appears.
Reels and audio share one deceleration curve in src/lib/timing.ts,
so what you see and what you hear stay locked together: frames start ~45ms apart
and stretch to ~260ms as the reel slows, then each card settles with its own thunk.
The tick track is synthesised with Web Audio rather than played from a file,
because the rate is not known ahead of time and there is no asset to fail to load
offline.
Spin length and sound are both in Settings, per browser. Muting suspends the
AudioContext outright. Under prefers-reduced-motion the spin and the audio are
both skipped.
Picks use crypto.getRandomValues with rejection sampling and a partial
Fisher–Yates shuffle — no modulo bias, and no film can come up twice in one roll.
See src/lib/random.ts.
Development
npm install
npm run dev
| Command | What it does |
|---|---|
npm run dev |
Vite dev server |
npm run build |
Typecheck, then production build |
npm run typecheck |
Types only |
npm run lint |
oxlint |
npm run preview |
Serve dist/ locally |
npm run icons |
Regenerate icons and favicon from the vector source |