β How it works π Results πΌ Screenshots
01 Overview
A small Node.js service that answers one recurring household question β "what should we watch tonight?" β from the movies and shows already in our Jellyfin library. On a timer it reads the full library (about a hundred titles), asks a fast, inexpensive model through our self-hosted LiteLLM proxy to pick three movies and two shows with a one-line pitch for each, and caches the answer. It has no database and no interface of its own: it serves the picks as JSON at /recommendations and shows up as a tile on the home dashboard. The whole thing is roughly 115 lines across three files. The point isn't the size β it's that a single well-placed LLM call, cached and scheduled sensibly, turns a static library into a useful nightly nudge.
02 Why I built it
We self-host our media on Jellyfin, and a library you have to scroll is a library you stop watching from. This is a quick quality-of-life add-on to that stack: let a model do the "pick something for us" step that a person otherwise avoids. It fits a pattern of small LiteLLM-backed utilities around the house β the value is in placing a cheap call where it saves a daily bit of friction, not in building anything large. (Being honest: the service predates any written spec, so this motivation is my own account of it rather than something recorded at the time.)
03 What I built & how it works
One refresh loop feeding an in-memory cache, behind two tiny endpoints.
Fig. 1 β the LLM is called on a schedule, never per request; visitors and the dashboard tile only ever read the cache.
- Read the library β one Jellyfin REST call (
/Items, token-authed) pulls every movie and series title. - Ask once β a single prompt lists the titles and asks for three movies and two shows, varied in genre and mood, each with one compelling sentence, returned as strict JSON.
- Harden the reply β a regex pulls the JSON object out of any markdown fencing, it's parsed, the arrays are shape-checked, and the lists are clamped to three and two.
- Cache β the picks and a timestamp overwrite an in-memory result; if a refresh fails, the last good picks stay and only an
errorfield is stamped. - Serve β
/recommendationsreturns the cached JSON and/healthbacks the container healthcheck and the dashboard ping.
04 π Skills & tech used
05 Notable challenges & decisions
Not much code, but a few deliberate choices that keep a tiny LLM service cheap and unbreakable.
Cache once, serve many
The obvious version calls the model on every request. This one refreshes on a six-hour timer instead, so the whole household shares roughly four LLM calls a day rather than one per page view. The picks are static enough over an evening that caching costs nothing in usefulness and bounds both spend and latency.
Keep the last good answer
If a refresh fails β model hiccup, Jellyfin down β the cache holds the previous picks and only stamps an error alongside them, so a consumer never sees an empty or half-written result. A bootstrap "Loadingβ¦" placeholder covers the window before the first successful refresh.
Treat the model's reply as hostile text
Model output isn't guaranteed to be clean JSON, so the reply is run through a regex that extracts the first {β¦} object (stripping any markdown fencing), parsed, checked that movies and shows are actually arrays, then sliced to three and two β anticipating fenced output and over-generation rather than trusting the format.
Honest limits. It's stateless β no watch history β so with a fixed library the picks repeat: a couple of favorites resurface most refreshes despite the temperature. The library fetch caps at 500 titles and would silently truncate past that. And it shipped with no README or spec, which the home server's nightly context-audit cron correctly flagged as an undocumented service β a fair catch. Secrets live in a compose file on a trusted LAN, which is fine for home but not something I'd ship.
06 Results
Sources: server.js, package.json, the tracked compose block, and container logs (a 101-title library on 2026-07-31). Cost is negligible β a handful of short calls a day on a Haiku-class model.
07 Screenshots
There's no UI to show β the product is a JSON endpoint and a dashboard tile. These are the surfaces a capture would show; response bodies are trimmed to harmless titles and blurbs.
cached JSON β 3 movies + 2 shows, one-line reason each, updatedAt
"Jellyfin Recommendations" Β· star-circle icon Β· health ping
[refresh] Got 101 itemsβ¦ Done. Movies: β¦
08 Honest status
This is a write-up of a genuinely small thing, and that's the point β the judgment, not the line count. It has run healthy on the home server since March 2026 with no code changes, quietly refreshing its picks nightly. It's LAN-only, unauthenticated, and depends on the Jellyfin and LiteLLM containers being up; there are no tests and no persistence, and it isn't tracked in git as its own repo. I'm including it not as a headline project but as a small example of cost-aware LLM-application sense: seeing where one cheap, cached model call earns its keep, and stopping there.
jellyfin-picks β Build Recipe
Take a bare machine to a running "nightly picks" service: it reads a media library and names 3 movies + 2 TV shows to watch tonight, each with a one-sentence reason, refreshing every few hours and serving a small web page. Key-free by default β it picks locally; point it at any OpenAI-compatible LLM to get AI-written reasons instead.
Status: β Verified 2026-08-02 β built from this clone with
docker compose up -d --build(two tiny Node services), both came up, the UI served on:8246, and/recommendationsreturned real picks from a seeded 39-title library key-free (source: local, no errors) β e.g. Plan 9 from Outer Space, The Stranger, Carnival of Souls + two shows. All three tiers covered; the prebuilt tarball (docker save jellyfin-picks-clone jp-mock-jellyfin-clone) is ~149 MB. The clean clone lives inclone/.Sensitive data: none. The original read the author's real Jellyfin library and used a private LiteLLM alias; both are replaced β a mock Jellyfin serves a seeded, public-domain/generic title list, and the LLM is opt-in. No keys, no personal data.
What it is
Two small Node/Express services on one compose network:
| Service | Role |
|---|---|
jellyfin-picks (app/) |
the product β fetches the library, picks 3 movies + 2 shows (LLM or local), caches, serves /recommendations + a web UI on :8246 |
mock-jellyfin (mock-jellyfin/) |
a stand-in Jellyfin: answers the one endpoint the app uses (/Items) from library.json β so the clone needs no real Jellyfin |
No build step for the frontend, no ML in-process, no GPU. The LLM call (opt-in) is the only thing that reaches outside the compose network.
Prerequisites
New machine? See ../SETUP.md. Then, per tier:
| Tier | You need |
|---|---|
| 1 β prebuilt containers (recommended) | Docker (see ../SETUP.md). Nothing else. |
| 2 β build from source | Same as Tier 1, plus this source bundle. |
| 3 β bare-metal | Node 20+ (two processes). |
Configuration β nothing required
Runs key-free (local picks). To get AI-written reasons, set LITELLM_URL / LITELLM_KEY
to any OpenAI-compatible endpoint β see clone/.env.example (a local Ollama works).
Tier 1 β Run the prebuilt containers (recommended)
Availability: the prebuilt image bundle is available on request β it is not published or linked anywhere. Ask Kevin for it, or build from source via Tier 2 below.
docker load -i jellyfin-picks-image.tar # loads both images
cd clone
docker compose up -d # β http://localhost:8246
Tier 2 β Build from source
cd clone
docker compose up -d --build # β http://localhost:8246
curl -s localhost:8246/recommendations # β 3 movies + 2 shows (source: local)
Open http://localhost:8246 for the picks page. It refreshes automatically.
Tier 3 β Bare-metal (two Node processes)
cd clone
# 1) mock Jellyfin
cd mock-jellyfin && npm install --omit=dev && PORT=8096 node server.js &
# 2) the picks app (points at the mock)
cd ../app && npm install --omit=dev
JELLYFIN_URL=http://localhost:8096 JELLYFIN_KEY=demo node server.js # β http://localhost:8246
Optional: AI-written reasons (LLM)
Off by default (local picks). Point at any OpenAI-compatible endpoint for AI reasons β
e.g. a local Ollama (ollama serve + ollama pull llama3.2):
# in clone/.env
LITELLM_URL=http://host.docker.internal:11434/v1
LITELLM_KEY=ollama
LITELLM_MODEL=llama3.2
docker compose up -d again; the UI badge switches from "local picks" to "AI-picked".
Provenance β data & models
- No datasets, no models, no GPU.
mock-jellyfin/library.jsonis a hand-written list of public-domain film titles + generic show names (metadata only β no media, no copyrighted content). The opt-in LLM runs wherever you point it.
What was stripped from the personal version (de-identification)
- The real Jellyfin dependency β the original fetched the author's actual home
library over
JELLYFIN_URL/JELLYFIN_KEY. Replaced with a self-containedmock-jellyfinseeded with generic titles; no real server, no key needed. - A private LiteLLM alias (
fast-chat) and shared-gateway assumption β the LLM is opt-in against any OpenAI-compatible endpoint, default off with a local fallback. - Added a small web UI (the original served JSON only) so the demo is visual.
Known limitations (stated honestly)
Personal-LAN tool: no authentication. Local picks are a random-from-library shuffle
with canned reasons β the point of the project is the LLM path, which needs an endpoint
you supply. With a real Jellyfin, swap the mock for JELLYFIN_URL/JELLYFIN_KEY pointing
at your server.