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.

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:
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:
- Download the movie or episode from Jellyfin.
- Check it with
ffprobe. If it's already AV1, skip the encode. - Otherwise, encode it with hardware-accelerated
ffmpegon Intel Quick Sync, keeping the original container (.mkvstays.mkv,.mp4stays.mp4). - Upload the result into an organized movie or
Show/Season Nfolder. - 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:
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.