After moving from Plex to Jellyfin, I went through the server properly for the first time: every drive, every library, every setting. The hardware is from 2014 and has no graphics card, so I expected the answer to be "buy something newer". Most of what was wrong had nothing to do with the hardware:

Found Fixed by Result
2.9 TB of media in no library Adding the libraries It shows up
Trickplay images decoded, then failing to save Saving them in Jellyfin's config instead 5,206 errors a day gone, and the CPU they wasted
Chapter images extracted on every scan Turning them off A rescan's FFmpeg CPU from ~330% to ~9%
Transcoding on the CPU alone Giving Jellyfin the integrated graphics 1080p H.264 three times as fast at a quarter of the CPU
5% of every drive reserved for root tune2fs -m 0 1.2 TB more free space
Every library split across four drives A mergerfs pool One path per library

What it doesn't fix: the CPU's graphics have no HEVC support, and most of the library is HEVC. 4K still can't be transcoded usefully. It has to direct play, or the server needs a newer graphics chip. More on that below.

The server

  • CPU: Intel i5-4690K (Haswell, 2014): four cores, with HD 4600 integrated graphics. No graphics card. 11 GB of RAM.
  • Storage: four WD Red drives (three 6 TB, one 8 TB), each its own ext4 filesystem, holding a 13.6 TB library.
  • Software: Ubuntu, Docker from snap, and Jellyfin 12.2 in a container, with the media mounted read-only. Leaving Plex for Jellyfin has how it was set up, and Putting it behind HTTPS has Caddy in front of it.

2.9 TB that wasn't in any library

The drives held folders from the Plex days that never made it into a Jellyfin library: Korean dramas (about 2.5 TB), Digimon (about 340 GB), stand-up specials (53 GB) and music (16 GB). Jellyfin only knows about the folders you add, and when moving from Plex I'd added the obvious ones.

Adding them found 84 series in one library, 8 series and 17 movies in another, 44 specials and 361 albums. The easy check is to list each drive's top-level folders and compare them with the paths your libraries use.

One folder needed moving first: 31 volumes of manga (.cbz files, 9.5 GB) sitting in an anime's Extras folder. Jellyfin reads comics in a Books library, but it skips a folder that's already inside another library's path as a duplicate. The manga had to move out of the anime library's tree before a Books library could pick it up.

Trickplay that could never be saved

Trickplay images are the little previews you see when you drag along the seek bar. Making them means decoding the whole video, which is the expensive part.

The libraries were set to make them during every scan and save them next to the media. But the media is mounted read-only, which is how it should be: the server has no reason to change the library. So every save failed, after FFmpeg had already decoded the video. One day's log had 5,206 "Read-only file system" errors, and the next scan did it all again.

The fix is two settings in each library's options:

  • Save trickplay images and metadata in Jellyfin's own config, not next to the media.
  • Don't extract trickplay images during the library scan. Leave it to the scheduled task.

If your media is read-only, check the log for Read-only file system after a scan. Any of Jellyfin's "save next to the media" options will fail the same way.

Chapter images: the same trap, found the hard way

After the drives were pooled (below), every file was at a new path, so Jellyfin treated all of the roughly 8,000 videos as new and rescanned them. The scan crawled. The obvious suspect was the disks, but the numbers said otherwise: FFmpeg at about 330% CPU, out of 400% on four cores, with the disks 0-10% busy.

Four libraries were extracting chapter images during scans: a frame for each chapter of each video, which means decoding HEVC video for every one of them. Turning off chapter image extraction during scans dropped the CPU to about 9% within a minute. Later I turned chapter images off altogether: they're only shown in the chapter list, which isn't worth decoding every chapter for.

The lesson: when a scan is slow, check whether the CPU or the disks are busy before blaming the storage.

Hardware transcoding with the CPU's graphics

When a device can't play a file as it is, Jellyfin transcodes it: decodes every frame and encodes a new video, in real time. What is transcoding? explains when that happens and what it costs.

Hardware acceleration was set to none, and the container had no access to the graphics chip, so every transcode ran on the four CPU cores. Haswell's integrated graphics have a media engine that works with VA-API, the Linux interface to video hardware, and Jellyfin's image includes the drivers. Giving the container the chip took two entries in the Compose file:

services:
  jellyfin:
    image: jellyfin/jellyfin:12.2
    volumes:
      - /home/me/.jellyfin-config/config:/config
      - /home/me/.jellyfin-config/cache:/cache
      - /mnt/media:/media:ro
    # The CPU's integrated graphics. The group is the host's `render` group
    # (`getent group render`), which owns the device.
    devices:
      - /dev/dri/renderD128:/dev/dri/renderD128
    group_add:
      - "109"

Without group_add, the device is there but Jellyfin isn't allowed to open it. The number is the host's render group id, which differs between machines.

To see what the hardware can do, run the vainfo that ships with Jellyfin's FFmpeg:

sudo docker exec jellyfin /usr/lib/jellyfin-ffmpeg/vainfo --display drm --device /dev/dri/renderD128

On Haswell, the newer iHD driver fails and it falls back to the older i965 driver on its own, so nothing needs setting. The answer: it decodes and encodes H.264 and MPEG-2, decodes VC-1, and has no HEVC at all. In Dashboard > Playback > Transcoding that means VAAPI, device /dev/dri/renderD128, hardware decoding ticked for H264, MPEG2 and VC1 only, and hardware encoding on.

What it changed, measured

Each test used Jellyfin's own FFmpeg to turn 60 seconds of video into 720p H.264 at 4 Mbps (the 4K test: 30 seconds to 1080p at 8 Mbps). CPU is out of 100% for all four cores:

Source Done by Speed CPU
1080p H.264 CPU only 95 fps 97%
1080p H.264 graphics decodes and encodes 279 fps 22%
1080p HEVC 10-bit CPU only 84 fps 96%
1080p HEVC 10-bit CPU decodes, graphics encodes 98 fps 56%
4K HEVC 10-bit HDR, to 1080p CPU decodes, graphics encodes 33 fps (24 needed) 96%

For H.264, about three times the speed at under a quarter of the CPU. For HEVC, moving the encode to the graphics chip frees some CPU, but the decode is still on the CPU, and that's the cost left.

I'd written "4K can't transcode at all" in my notes before measuring. It can: 33 frames a second where the film needs 24. But that's one stream, with the CPU flat out, and the HDR comes out washed out because there's no tone mapping. In a sample of 60 of the 4K movies, 59 were HEVC, mostly HDR10, HDR10+ or Dolby Vision. So 4K is only for devices that can play it directly: the TV apps, Android TV, Swiftfin or Infuse.

Two more settings on the same page were off and are now on: throttle transcodes (stop transcoding far ahead of the viewer) and delete segments (remove transcoded pieces once they've been watched).

Trickplay, sized and scheduled

With trickplay no longer failing, the question was whether to make it at all, and how big it gets. At 320 pixels wide, one image every 10 seconds, it's about 3-4.5 MB per hour of video. The 5,400 hours outside the 4K libraries come to about 20 GB, which is nothing.

The cost is time. Decoding HEVC in software for trickplay takes about 17 minutes per hour of video, so the backlog was about 1,500 hours of work. Three settings bring that down:

  • Key frames only (Dashboard > Playback > Trickplay) decodes only the key frames, roughly 10-20 times less work, for slightly less exact previews.
  • A time limit on the scheduled task. Generate Trickplay Images ran daily with no limit, so it could have run for weeks. It now starts at 3:00 AM and stops after four hours, so the backlog is worked through at night.
  • No trickplay for the 4K libraries, where decoding costs the most.

Free space ext4 was holding back

ext4 reserves 5% of every filesystem for root by default, so the system can keep running when a disk fills up. That matters on the system disk. On a drive that only holds media, it's space nothing can use: about 1.3 TB across these four, 400 GB on the 8 TB drive alone.

tune2fs changes it without touching the data:

lsblk -o NAME,MODEL,SIZE      # make sure sdX is a data drive, never the system disk
sudo tune2fs -m 0 /dev/sdX

Free space went from 3.2 TB to 4.4 TB. The two fullest drives went from 193 GB and 159 GB free to 473 GB and 532 GB. Device letters can change between boots, so check lsblk every time.

The old native Plex package was still installed too, from before Plex ran in Docker. Purging it (sudo apt purge plexmediaserver) freed 7.8 GB on the system disk.

One library path instead of four

Every category was split across all four drives: each library listed four folders, and ten shows had seasons on different drives. mergerfs shows the four drives' media folders as one tree, without moving or reformatting anything. Each file stays whole on one drive, so it isn't RAID: a dead drive loses only its own files. Jellyfin now mounts that single tree.

The catch was the one in the chapter images section: every file had a new path, so the whole library was rescanned.

A one-time music cleanup

The 16 GB of music was 2,734 files from a backup made before 2012. Only 56% were fully tagged; 1,061 had no usable tags at all, 537 of them just numbered tracks. Jellyfin's music library groups by tags, so it showed a mess.

beets with AcoustID fingerprinting was the obvious tool, but it matched 2 of 107 tracks from DJ mixes, both wrongly. A short Python script with mutagen did better, working only on copies with the original kept untouched:

  • Keep tags that already exist.
  • Read "Artist - Title" filenames, deciding which side is the artist per folder, by vote against artists already known.
  • File unknown mix tracks under Various Artists, as an album named after the folder.
  • Drop duplicates by artist and title (keeping the best bitrate) and by checksum.

2,397 songs came out, with 336 duplicates dropped. One snag: a remix crediting 22 artists made a filename longer than Linux's 255-byte limit. The tools were uninstalled afterwards; this was a one-time job, and nothing new gets added.

What 2014 hardware can't do

The fixes above made the server fine for everything except HEVC, which is most of the library:

  • 1080p H.264 transcodes fully in hardware, easily.
  • 1080p HEVC transcodes, with the CPU doing the decoding. Fine for one or two streams.
  • 4K HEVC barely transcodes, one stream at a time, without tone mapping. It needs a device that plays it directly.

If 4K needs to transcode (remote viewing, or a TV that can't play Dolby Vision), the fix is a graphics chip with HEVC and AV1 decoding and tone mapping. An Intel Arc A310 is the cheap option, and Jellyfin would use it the same way, through a device under /dev/dri. Until then, the answer is direct play at home.

The settings, in one place

Where Setting
Dashboard > Playback > Transcoding VAAPI, /dev/dri/renderD128. Hardware decoding for H264, MPEG2 and VC1 only. Hardware encoding on. Tone mapping off. Throttle transcodes and delete segments on.
Each library > Manage library Trickplay and metadata saved in Jellyfin's config, not next to the media. Trickplay not extracted during scans. Chapter images off. Trickplay off for the 4K libraries.
Dashboard > Playback > Trickplay Hardware acceleration on, key frames only, 1 thread, below-normal priority.
Dashboard > Scheduled Tasks > Generate Trickplay Images Daily at 3:00 AM, with a 4-hour limit.
The data drives tune2fs -m 0.