~$ klumbsyd / tech / posts / self-hosting / dovi-pipeline.md
post.config
1# post metadata
2title="Building a DoVi conversion pipeline on UnRAID"
3author="dustin"
4date="2026-04-08"
5tags=["self-hosting", "docker", "plex", "automation", "unraid"]
6read_time="4 min"
7word_count="737"
8status="published"

Building a DoVi conversion pipeline on UnRAID

If you run a Plex server with Dolby Vision content, you’ve probably hit the wall: your LG or Samsung TV app chokes on Profile 5 files. The screen goes black, audio keeps playing, and your family thinks you broke the TV again.

The fix is converting Profile 5 to Profile 8 using dovi_tool. It’s lossless – you’re restructuring the DV metadata layer, not re-encoding video. The hard part is doing it automatically for every new file that arrives.

The tools

Two tools do the actual work:

  • dovi_tool – extracts and rebuilds the Dolby Vision layer
  • ghcr.io/jlesage/mkvtoolnix – run as a throwaway Docker container for MKV inspection, HEVC extraction, and remuxing

No persistent MKVToolNix container to manage. The script spins one up per step and discards it.

Installing dovi_tool on UnRAID

UnRAID runs from RAM – anything installed to /usr/local/bin is wiped on reboot. Drop the install command in /boot/config/go so it reinstalls itself at every boot:

# /boot/config/go
curl -sL https://github.com/quietvoid/dovi_tool/releases/download/2.3.2/dovi_tool-2.3.2-x86_64-unknown-linux-musl.tar.gz \
  | tar -xz --strip-components=1 -C /usr/local/bin/ \
  && chmod +x /usr/local/bin/dovi_tool

The go file lives on the boot flash drive so it survives reboots. This is the right pattern for any binary you need on UnRAID that isn’t packaged as a plugin.

The pipeline

The script is a Radarr and Sonarr Custom Script connection – it fires on every import event and handles both movie and episode files. The pipeline runs in four steps:

Step 1 – Profile detection. Run mkvmerge -J on the file and parse the JSON output to check for a Dolby Vision track and its profile. Non-MKV files exit immediately. Files without a DV track exit immediately. Profile 5 is the only one that triggers conversion.

Step 2 – Disk space check. Conversion needs scratch space for the raw HEVC stream, the converted stream, and the output MKV – roughly 3x the source file size. The script checks available space before touching anything:

FILE_SIZE=$(stat -c%s "$INPUT_FILE")
REQUIRED_SPACE=$(echo "$FILE_SIZE * 3" | bc)
AVAIL_SPACE=$(df -B1 "$SCRATCH" | awk "NR==2{print \$4}")

if [ "$AVAIL_SPACE" -lt "$REQUIRED_SPACE" ]; then
    # Discord alert + add to retry queue + exit
fi

Step 3 – Conversion. Three sub-steps: extract the HEVC stream with mkvextract, convert P5→P8 with dovi_tool -m 2 convert --discard, then remux back into MKV with mkvmerge. The dovi_tool step retries once on failure with a 5-second pause before giving up.

Step 4 – Sanity checks before replacing the original. Two checks run before the original file is touched:

# Check 1: output must be >= 90% of original size
MIN_SIZE=$(echo "$FILE_SIZE * 9 / 10" | bc)
if [ "$OUTPUT_SIZE" -lt "$MIN_SIZE" ]; then
    # original untouched, output kept for inspection
fi

# Check 2: verify the output actually is Profile 8
OUT_PROFILE=$(mkvmerge -J output.mkv | python3 _parse_dovi.py)
if [ "$OUT_PROFILE" != "8" ]; then
    # original untouched, fail with Discord alert
fi

“Output file exists and is larger than 100MB” is not a sufficient sanity check. A corrupted remux can produce a file that passes that threshold. Verifying the actual DV profile in the output protects against silent corruption.

If both checks pass, mv replaces the original in-place. Radarr and Sonarr don’t need to be told – the file path hasn’t changed.

The retry queue

Failures get added to a queue at /mnt/user/appdata/scripts/dovi_retry_queue.txt. A second script (dovi_retry.sh) runs nightly via cron, works through the queue, and removes entries that succeed. Files that fail again stay in the queue for the next run. At the end of each retry run, Discord reports how many succeeded and lists anything still failing.

The queue deduplicates entries so a file that fails multiple imports in a row only appears once.

Discord notifications

Every stage sends a Discord embed:

  • Yellow – conversion started (file name, size, profile detected)
  • Green – conversion complete (original size, output size, elapsed time)
  • Red – any failure (reason, which file, instructions for manually re-importing)

The failure embeds include the exact steps to trigger a re-import in Radarr and Sonarr, which matters at 2AM when you’re half asleep trying to figure out why something didn’t work.

The Radarr/Sonarr Test event

Any Custom Script connection in Radarr or Sonarr sends a Test event when you save it. Your script must handle this and exit 0 or the UI won’t let you save:

if [ "${radarr_eventtype}" = "Test" ] || [ "${sonarr_eventtype}" = "Test" ]; then
    echo "Test event — script OK."
    exit 0
fi