Apollo: Building a Netflix-Style Client for Jellyfin
I built a modern web client for my Jellyfin server — real per-user match scores, a proper player, and a one-command Proxmox install. Here are the engineering bits worth sharing.
I run Jellyfin on my homelab, and while the official web UI is capable, I wanted something that felt like the streaming apps I actually use — a full-bleed hero, hover-expand cards, and a player that just works. So I built Apollo: a modern, Netflix-style web client for Jellyfin. It's unofficial, talks to the standard Jellyfin REST API, and runs alongside (or instead of) the official UI.
The stack
Apollo is a static SPA — Vite + React 19 + TypeScript, styled with Tailwind CSS v4. TanStack Query handles caching, dedupe, and infinite scroll for large libraries; hls.js covers transcoded streams while direct play uses the native video element.
One deliberate choice: I don't use the Jellyfin SDK's generated axios client.
I kept its generated DTOs for types but hand-rolled a thin fetch
wrapper, because the image and streaming URLs depend on precise control of URL
shape and auth headers.
Match scores that actually mean something
The percentage on each card is a real per-user score, not a relabelled community rating. Once per session Apollo builds a taste profile from your 150 most recently played titles plus your favourites, counting genres, studios, and tags. Favourites weigh 3× a play, rewatches get a bonus, and recent viewing counts about double the oldest in the window.
Raw counts are square-root damped and normalised against your strongest term, so one heavily-watched genre doesn't flatten everything else to zero. Scoring a candidate blends its facet affinities (75%) with the community rating as a prior (25%), and only scores facets the item actually carries — otherwise a title with no tags would be punished for the omission rather than judged on what it has.
A few honest details: there's no artificial floor (real scores run 1–99, not a flattering 50–99), and under five watched items there's no profile at all, so the UI shows the community rating instead of inventing a match.
The bug that looked like everything and nothing
The most confusing bug I hit wasn't really my logic being wrong. Unauthenticated calls succeeded — the server connected and reported its version — but every authenticated call, including sign-in, silently never fired.
The culprit: crypto.randomUUID, which I used to label the browser
in Jellyfin's device list, is undefined on plain HTTP over a LAN
address because it's a secure-context-only API. It's called while
building the Authorization header, so it threw before any
authenticated request was even sent. Development on localhost never
sees this — localhost counts as a secure context. The fix was falling back to
getRandomValues, which has no such restriction. Lesson: test on a
real LAN address, not just localhost.
Mixed content will ruin your day
A related trap: the browser talks to Jellyfin directly. Serve Apollo over
HTTPS while pointing it at a plain http:// LAN Jellyfin, and the
browser blocks every request as mixed content — the app looks completely broken,
with CORS-looking console errors that never say "mixed content." The answer is to
keep the scheme consistent: HTTPS Jellyfin, or plain-HTTP Apollo on the LAN, or
both behind one reverse proxy.
Deploying it
Apollo ships with Docker Compose, but the part I'm happiest with is the one-command Proxmox installer — run it on the Proxmox host and it prompts for a container ID, resources, storage, network, and your Jellyfin address, then creates an unprivileged Debian LXC, installs Node, builds Apollo, and starts it under systemd.
Why bother?
Because self-hosting should feel as good as the commercial thing it replaces. Apollo is open source on GitHub — the kind of project that scratches a real itch on my own homelab and handed me a few genuinely interesting problems to solve.