← Kevin Yoder
Personal, Family & Home

Jellyfin Nightly Picks

Reads the whole home Jellyfin library every six hours and uses one cheap LLM call to name three movies and two shows for tonight β€” each with a one-sentence reason.

Running on the home server Built Mar 2026 ~115 lines Β· 1 dependency LAN-only JSON API

βš™ 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.

at boot + every 6h (setInterval) refresh() GET Jellyfin /Items · build JSON-only prompt POST litellm fast-chat temp 0.8 · parse · clamp 3+2 writes to cache in-memory cache 3 movies · 2 shows · updatedAt · stale-on-error endpoints read cache Express · :8246 GET /health → {status:ok} GET /recommendations → cached JSON

Fig. 1 β€” the LLM is called on a schedule, never per request; visitors and the dashboard tile only ever read the cache.

  1. Read the library β€” one Jellyfin REST call (/Items, token-authed) pulls every movie and series title.
  2. 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.
  3. 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.
  4. Cache β€” the picks and a timestamp overwrite an in-memory result; if a refresh fails, the last good picks stay and only an error field is stamped.
  5. Serve β€” /recommendations returns the cached JSON and /health backs the container healthcheck and the dashboard ping.

04 πŸ›  Skills & tech used

Languages
Node 20JavaScript (strict, async/await, native fetch)
ML / AI
LiteLLM proxy (OpenAI-compatible)fast-chat alias (Haiku-class) + fallback chainstrict-JSON prompt engineeringdefensive LLM-output parsing
Infra
Docker (alpine Β· mem_limit Β· healthcheck)compose on a shared network12-factor env configHomepage dashboard tile
Data
Jellyfin REST API (/Items Β· X-Emby-Token)Express JSON endpoints
Techniques
scheduled background refreshin-memory cachestale-on-error retentionresponse clamping / validation

05 Notable challenges & decisions

Not much code, but a few deliberate choices that keep a tiny LLM service cheap and unbreakable.

Cost

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.

Resilience

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.

Untrusted output

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

~115
lines β€” the whole service
1
npm dependency (express)
5
picks a night β€” 3 movies, 2 shows
~4
LLM calls per day (cache-and-serve)
6h
auto-refresh cadence
128MB
container memory cap

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.

[ GET /recommendations ]
cached JSON β€” 3 movies + 2 shows, one-line reason each, updatedAt
The actual product: the cached picks with a one-sentence pitch per title and a refresh timestamp.
[ dashboard tile ]
"Jellyfin Recommendations" Β· star-circle icon Β· health ping
The Homepage tile among the media services β€” a link and a health check, no data widget.
[ docker logs ]
[refresh] Got 101 items… Done. Movies: …
The whole refresh cycle told in three log lines, once at boot and every six hours after.

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 /recommendations returned 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 in clone/.

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.json is 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-contained mock-jellyfin seeded 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.