A Jellyfin-to-AV1 production line

My media server was running out of room, so I built a two-part pipeline: one tool writes a plain-text index of the library, and the other works through it, re-encoding everything to AV1 with Intel Quick Sync.

3 min read

Painted illustration of a conveyor belt feeding film reels into a glowing machine that turns them into small crystal discs.

Media libraries only grow. Mine lives on a Jellyfin server, and a lot of it was encoded years ago in whatever codec the ripper defaulted to at the time. AV1 can cut that down dramatically at the same visual quality, and modern Intel GPUs can encode it in hardware. The math was obvious. The logistics weren't: thousands of files, a server I didn't want to hammer, and a job that would take weeks and had to survive every interruption.

I split it into two small programs connected by the humblest interface there is, a text file.

Step one: write down what exists

jfdownload walks the Jellyfin library and writes an index. Movies are one line each. Shows are a title, indented seasons, and further-indented episodes, with | separating the fields:

MediaIndex.txt
101 Dalmatians (1996)|https://…/Items/e255…/Download
12 Monkeys
  Season 1
    Episode 1|Splinter|https://…/Items/ad09…/Download
    Episode 2|Mentally Divergent|https://…/Items/1af2…/Download

That looks almost too simple. It's the best decision in the project. I can read it, grep it, sort it, delete lines from it, or hand-edit it to process one show first. The indexer doesn't need to know anything about encoding, and the encoder doesn't need to know anything about Jellyfin's API. When something goes wrong, I open a text file instead of a debugger.

Step two: the production line

MediaProcessor reads the index and handles one item at a time:

  1. Download the movie or episode from Jellyfin.
  2. Check it with ffprobe. If it's already AV1, skip the encode.
  3. Otherwise, encode it with hardware-accelerated ffmpeg on Intel Quick Sync, keeping the original container (.mkv stays .mkv, .mp4 stays .mp4).
  4. Upload the result into an organized movie or Show/Season N folder.
  5. Clean up the temporary files, record progress, and move on.

The hardware path is set up explicitly so frames stay on the GPU from decode to encode:

ffmpeg -init_hw_device qsv=qs -hwaccel qsv -hwaccel_output_format qsv \
       -i input.mkv -c:v av1_qsv … output.mkv

Built to be interrupted

Batch jobs that run for weeks need to be defensive, not clever. Before starting, MediaProcessor loads an inventory of what's already on the destination server, and anything already there is marked “Already on server” and skipped. That means there's no separate progress file to get out of sync: the output is the checkpoint, and restarting after a crash just continues.

It keeps going past individual failures: one corrupt episode is logged as a non-fatal error rather than ending the run at 3 a.m. And it overlaps the slow parts. While one item is encoding, the next one is already downloading, which keeps both the network and the GPU busy:

MediaProcessing.cs
PreparedMediaItem? currentItem = await currentPreparationTask!;
currentPreparationTask = index + 1 < items.Count
    ? PrepareItemAsync(items[index + 1], sessionRoot, progress, token)
    : null;

await ProcessPreparedItemAsync(currentItem, progress, token);

It has a plain Start/Stop UI with progress bars for download, encode, and upload, and the per-run temp folder is deleted however the run ends.

There's a commented-out vpp_qsv line in the source for denoising and downscaling to 720p. It's evidence of an evening spent convincing myself that I wanted to shrink the files, not the picture.

The part I'd reuse everywhere

Whenever a job has a discovery phase and a doing phase, I now reach for a plain intermediate file between them. It turns one big fragile program into two small, testable ones, and it gives me a place to step in by hand. It's the same staged thinking that shaped my earlier disc-ripping pipeline, just with fewer moving parts.