2026-10-26 16:36:49 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-10-26 16:36:49 -07:00
2026-08-23 17:53:14 -07:00
2026-10-26 21:42:10 +00:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00
2026-08-23 17:53:14 -07:00

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_info returns 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_metadata carries 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
S
Description
No description provided
Readme
594 KiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 93.7%
CSS 5%
Dockerfile 0.7%
HTML 0.6%