Skip to content

Jellyfin + the *arr Stack

Jellyfin is the media server, with an automated *arr pipeline (Radarr/Sonarr/Lidarr/Bazarr/Prowlarr), qBittorrent as the download client, and Gluetun routing every torrent through a VPN. The network setup is the thing I'd hold on to: *all the arr containers share Gluetun's network through network_mode: "service:gluetun", so only Gluetun exposes ports and nothing leaks outside the VPN.

Jellyfin

jellyfin:
  image: jellyfin/jellyfin
  container_name: jellyfin
  restart: unless-stopped
  volumes:
    - ./jellyfin-config:/config
    - /mnt/md0/apps/jellyfin/media:/media
  ports:
    - 8700:8096          # remapped if 8096 is taken
  devices:
    - /dev/dri:/dev/dri  # hardware acceleration (Intel/AMD)
  environment:
    - TZ=America/Denver

Library layout: separate media and downloads folders; inside media, per-type folders (movies, tv, music, audiobooks). Add each as a library in the Jellyfin dashboard pointing at /media/movies, /media/tv, etc.

The stack

Every *arr app + qBittorrent + FlareSolverr runs with network_mode: "service:gluetun", so their traffic exits through the VPN. Ports are exposed on Gluetun only:

gluetun:
  image: qmcgaw/gluetun
  container_name: gluetun
  cap_add:
    - NET_ADMIN
  devices:
    - /dev/net/tun
  ports:
    - 8701:8701    # qBittorrent web UI
    - 6881:6881    # torrent port (tcp + udp)
    - 7878:7878    # Radarr
    - 8989:8989    # Sonarr
    - 8686:8686    # Lidarr
    - 6767:6767    # Bazarr
    - 9696:9696    # Prowlarr
    - 8191:8191    # FlareSolverr
  volumes:
    - ./gluetun:/gluetun
  environment:
    - VPN_TYPE=openvpn
    - VPN_SERVICE_PROVIDER=protonvpn
    - OPENVPN_USER=${OPENVPN_USER}
    - OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
    - VPN_PORT_FORWARDING=on
    - SERVER_COUNTRIES=United States
    - TZ=${TIMEZONE}

Each *arr service is the standard lscr.io/linuxserver/<app> image with network_mode: "service:gluetun", depends_on: gluetun, a ./<app>:/config volume, and the shared ${DATA_LOCATION}:/data mount (qBittorrent downloads land in /data, the *arrs import from the same path). FlareSolverr handles indexers behind Cloudflare.

Wiring: Prowlarr grants each *arr app an API key and syncs indexers; each *arr points its download client at qBittorrent (reachable through Gluetun's network); Jellyfin watches the media folders.

Live TV

Live TV runs on an HDHomeRun tuner with the guide served by hdhr-guide, a small container that queries the tuner's DeviceAuth and serves XMLTV over the Docker network. The whole thing is three lines of compose and one Jellyfin setting.

Guide provider: XmlTV, URL http://hdhr-guide:9000/xmlguide.xmltv. Free guide data is 2 days, 14 days with an HDHomeRun DVR subscription.

Guide Data Providers with the XmlTV entry, and the HDHomeRun tuner device

Setting the recording path is not the whole job

The RecordingPath field lives in the livetv named config, not in the library API. Setting it alone leaves the DVR library unregistered — the wizard's finish step is what actually registers it, via GetInternalLiveTvFolder, which creates the virtual folder lazily as a named view. That view is invisible in GET /Library/VirtualFolders, so people set the field, see nothing in the library list, and assume it failed. MediaLocationsCreated staying empty is not a failure either; RecordingPath is the load-bearing field.

Set it by read-modify-write of POST /System/Configuration/livetv: GET the config, change only RecordingPath, POST the whole object back. A PUT returns 405 on 12.x.

Live TV latency — the honest answer

Live TV is a live passthrough, not disk-backed. The HDHomeRun demodulates OTA and streams MPEG-TS over the network in real time; Jellyfin direct-plays or transcodes it. It does not write the live stream to the HDD first, so an SSD does not reduce live-viewing latency.

The latency people feel comes from three places: the playback client's jitter buffer (the big one — Fire Stick, Roku, web all hold one), direct-play versus transcode (transcode adds buffering), and the network between Jellyfin and the client. To chase real live latency, find the watching device and check its direct-play path, not the storage.

Where an SSD does help is recordings. DVR writes continuously, and on this box that lands on the same busy 12T array that serves the whole library, so steady DVR I/O contends with library reads. A dedicated SSD or mount for recordings is the legitimate use case. There is no SSD on the media VM right now — both disks report rotational=1.

Pause while watching live is RAM, not disk

Pausing or rewinding a live stream is the client's jitter buffer, tens of seconds, nothing written anywhere. It is the same knob as the perceived lag: a bigger buffer gives more pause room but leaves you further behind the broadcast. A real DVR — unlimited pause, watch-later, survives a restart — is what the recording path gives you.

Seerr

Seerr (formerly Jellyseerr) is the request front-end, v3.4.1, on the media VM at :5055. It syncs with Jellyfin on a 5-minute scan.

The log line is not proof

Seerr logs Recently Added Scan Complete every 5 minutes regardless of whether the sync actually succeeded. That line tells you the job ran, not that it worked. Trust the data instead.

The four checks that actually confirm a working sync, all read-only:

  1. ServerId match. Seerr stores its Jellyfin serverId in settings.json under jellyfin.serverId. GET /system/info .Id must equal it (normalize: lowercase, strip dashes). A mismatch means Seerr is pointed at a stale server. Mine matches: e13f61d1be7a428bad6978a062228de9.
  2. Auth works. Seerr's key must return 200 in the modern MediaBrowser scheme. The installed client already sends the right header — confirm by grepping /app/dist/api/jellyfin.js for MediaBrowser, not by assuming.
  3. Freshness — the real tell. Seerr v3.4.x stores content in db.sqlite3, table media, camelCase columns (createdAt, updatedAt). MAX(media.updatedAt) being recent is the proof. Mine is current relative to Jellyfin's index.
  4. Do not trust DateLastActivity. On 12.x the ApiKeys.DateLastActivity column stays 0001-01-01 even after genuine 200s — 12.x stopped writing it. Reading a dead epoch there once led me to declare Seerr broken when it was fine.

Request statuses observed live in media_request: 2 (available/processed) 47, 4 1, 5 (terminal, declined/not-processed) 188, no pending — the queue is drained.

DVR

DVR records through the HDHomeRun. For the NFL, timers run roughly 6 games a week.

DVR settings — default recording path

Retention — the field that actually works

There are two retention fields on a series timer and only one of them does anything.

KeepUntil (UntilDeleted, UntilSpaceNeeded, UntilWatched, UntilDate) looks like the retention setting, but it is vestigial. I searched the whole Jellyfin source and that enum is never read for enforcement — it only gets copied around in the DTO service. Setting it to UntilWatched does nothing.

The field that actually prunes is KeepUpTo, a count of recordings to keep per series. In RecordingsManager.EnforceKeepUpTo, the function returns immediately if KeepUpTo <= 0, which is the default. So a timer with KeepUpTo = 0 keeps everything forever, which is exactly what mine were doing.

Enforcement runs at the next recording, not retroactively

EnforceKeepUpTo is called from OnStarted, when a recording begins. Setting KeepUpTo on a series does not clean up what is already on disk — it prunes down to the count the next time that series records. A series that never records again keeps its backlog.

The delete path handles an unregistered library gracefully: DeleteLibraryItemForTimer tries FindByPath first, and if the recording is not in the library it falls back to _fileSystem.DeleteFile. So it still deletes the file even though my recordings folder is not a registered virtual library.

Off-by-one

EnforceKeepUpTo does OrderByDescending(EndDate).Skip(KeepUpTo - 1), which keeps KeepUpTo - 1 recordings rather than KeepUpTo. So KeepUpTo = 8 retains 7 games. Worth knowing when you pick a number.

What I settled on

KeepUpTo = 8 on every series timer, set via POST /LiveTV/SeriesTimers/{timerId} with the full timer body.

I originally wanted age-based retention — keep each recording for 5 days — but Jellyfin has no way to do that. KeepUntil is the field that looks like it should, and it is vestigial. If you want true age-based retention, it has to be a cron job that deletes .ts files older than N days, which is a different mechanism entirely.

Count-based is simpler and it is what the box actually enforces, so that is what I went with.

Permissions if you prune files directly

The recordings are owned by the Jellyfin container's user. A prune script running as your own user gets PermissionError on the .nfo and -thumb.jpg sidecars. Run it inside the container or as the owning uid.

Notifications

The *arr apps notify through Gotify/Apprise — see Notifications.

See also