Jellyfin LXC¶
Jellyfin runs as an unprivileged LXC container (ID 109) on the Proxmox host. It serves as the home media server, live TV DVR (via HDHomeRun), and recording post-processor.
Container specs: 4 cores, 4 GB RAM, 24 GB root disk, Ubuntu, IP 192.168.0.75
What runs in this container¶
| Component | Purpose |
|---|---|
| Jellyfin | Media server and DVR |
| HDHomeRun XMLTV grabber | Fetches guide data from HDHomeRun API on a 24h timer |
| DVR post-processor | Transcodes .ts recordings to .mp4 via Intel VA-API (iGPU) |
Part 1 — LXC Setup¶
The container was provisioned using the community-scripts Proxmox helper.
1.1 — Media mounts¶
Two bind mounts are configured in /etc/pve/lxc/109.conf on the Proxmox host:
| Mount | Source (host) | Destination (LXC) | Notes |
|---|---|---|---|
mp0 |
/mnt/omv_media |
/omv_media |
OMV CIFS share — recordings, media library |
mp1 |
/sync_pool/arr_data/media |
/mnt/arr_media |
Arr stack media, read-only |
The OMV CIFS share is mounted on the Proxmox host first, then bind-mounted into the LXC. See OpenMediaVault VM for the CIFS credentials and fstab setup.
109.conf
#<div align='center'>
# <a href='https%3A//Helper-Scripts.com' target='_blank' rel='noopener noreferrer'>
# <img src='https%3A//raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/images/logo-81x112.png' alt='Logo' style='width%3A81px;height%3A112px;'/>
# </a>
#
# <h2 style='font-size%3A 24px; margin%3A 20px 0;'>Jellyfin LXC</h2>
#
# <p style='margin%3A 16px 0;'>
# <a href='https%3A//ko-fi.com/community_scripts' target='_blank' rel='noopener noreferrer'>
# <img src='https%3A//img.shields.io/badge/☕-Buy us a coffee-blue' alt='spend Coffee' />
# </a>
# </p>
#
# <span style='margin%3A 0 10px;'>
# <i class="fa fa-github fa-fw" style="color%3A #f5f5f5;"></i>
# <a href='https%3A//github.com/community-scripts/ProxmoxVE' target='_blank' rel='noopener noreferrer' style='text-decoration%3A none; color%3A #00617f;'>GitHub</a>
# </span>
# <span style='margin%3A 0 10px;'>
# <i class="fa fa-comments fa-fw" style="color%3A #f5f5f5;"></i>
# <a href='https%3A//github.com/community-scripts/ProxmoxVE/discussions' target='_blank' rel='noopener noreferrer' style='text-decoration%3A none; color%3A #00617f;'>Discussions</a>
# </span>
# <span style='margin%3A 0 10px;'>
# <i class="fa fa-exclamation-circle fa-fw" style="color%3A #f5f5f5;"></i>
# <a href='https%3A//github.com/community-scripts/ProxmoxVE/issues' target='_blank' rel='noopener noreferrer' style='text-decoration%3A none; color%3A #00617f;'>Issues</a>
# </span>
#</div>
# iGPU passthrough
arch: amd64
cores: 4
features: nesting=1,keyctl=1
hostname: jellyfin
memory: 4096
mp0: /mnt/omv_media,mp=/omv_media
mp1: /sync_pool/arr_data/media,mp=/mnt/arr_media,ro=1
net0: name=eth0,bridge=vmbr0,gw=192.168.0.1,hwaddr=BC:24:11:37:E7:22,ip=192.168.0.75/24,ip6=auto,type=veth
onboot: 0
ostype: ubuntu
rootfs: local-lvm:vm-109-disk-1,size=24G
startup: up=60
swap: 512
tags: community-script;media
timezone: America/Phoenix
unprivileged: 1
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
lxc.cgroup2.devices.allow: c 226:1 rwm
lxc.cgroup2.devices.allow: c 226:128 rwm
lxc.mount.entry: /dev/dri/card1 dev/dri/card1 none bind,optional,create=file
lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file
Mount the OMV CIFS share on the Proxmox host¶
# Create credentials file
nano /etc/pve/priv/omv.creds
# Contents:
# username=your_omv_user
# password=your_omv_password
# domain=WORKGROUP
chmod 600 /etc/pve/priv/omv.creds
# Create mount point
mkdir -p /mnt/omv_media
# Add to /etc/fstab (replace with actual OMV IP and share name)
# //192.168.0.149/OMVstorage /mnt/omv_media cifs credentials=/etc/pve/priv/omv.creds,iocharset=utf8,uid=100000,gid=110000,file_mode=0775,dir_mode=0775 0 0
mount -a
mountpoint /mnt/omv_media
uid/gid mapping
uid=100000 and gid=110000 map files to the root user of an unprivileged LXC container. Run sudo -u jellyfin ls -la /omv_media inside the container to verify the jellyfin user can read the share.
Bind mount into the LXC¶
pct set 109 -mp0 /mnt/omv_media,mp=/omv_media
pct config 109 # verify mp0 entry appears
1.2 — Tailscale SSL certificate¶
Jellyfin is accessible over Tailscale HTTPS using a MagicDNS certificate. A renewal script runs weekly via cron.
Prerequisites: - MagicDNS enabled in Tailscale admin console - HTTPS Certificates enabled in the DNS tab
Create the renewal script on the LXC:
nano /usr/local/bin/renew-jellyfin-cert.sh
#!/bin/bash
DOMAIN="jellyfin.tailnet-1234.ts.net" # replace with your actual MagicDNS name
CERT_DIR="/etc/jellyfin"
PFX_PASS="your-secure-password"
/usr/bin/tailscale cert "$DOMAIN"
openssl pkcs12 -export \
-out "$CERT_DIR/jellyfin.pfx" \
-inkey "$DOMAIN.key" \
-in "$DOMAIN.crt" \
-passout pass:"$PFX_PASS"
chown jellyfin:jellyfin "$CERT_DIR/jellyfin.pfx"
chmod 640 "$CERT_DIR/jellyfin.pfx"
rm "$DOMAIN.key" "$DOMAIN.crt"
systemctl restart jellyfin
chmod +x /usr/local/bin/renew-jellyfin-cert.sh
/usr/local/bin/renew-jellyfin-cert.sh # run once to generate first cert
Configure Jellyfin (Dashboard → Networking → HTTPS Settings):
- Enable HTTPS: ✅
- Certificate Path:
/etc/jellyfin/jellyfin.pfx - Certificate Password: the password set in the script
Schedule weekly renewal (crontab -e):
0 3 * * 1 /usr/local/bin/renew-jellyfin-cert.sh >> /var/log/jellyfin-cert-renewal.log 2>&1
1.3 — HDHomeRun tuner setup¶
- Download the Windows HDHomeRun software, update firmware, and scan channels
- In Jellyfin Dashboard → Live TV → Tuners, add the HDHomeRun tuner
onboot setting
onboot: 0 in 109.conf — the container does not auto-start on host boot. Start manually via Proxmox UI or pct start 109 if needed.
Part 2 — HDHomeRun XMLTV Guide Grabber¶
Guide data is fetched from the HDHomeRun API and saved as an XMLTV file that Jellyfin reads. The grabber runs on a 24-hour timer with a 2-hour random offset.
xmltv_grabber.py
import os
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 0. clear cache using manual call to scheduled task
try:
requests.post(
"https://localhost:8920/ScheduledTasks/Running/241d4fcb19a1d557ee62428e411da609",
params={"api_key": "cbdfae9e8aa6468ca70e90e9d07fba34"},
verify=False
)
print("Cache directory successfully cleared")
except:
print("Failed to clean cache directory")
# 1. Get DeviceAuth and fetch XMLTV
discover_url = "http://192.168.0.245/discover.json"
response = requests.get(discover_url)
device_auth = response.json().get("DeviceAuth")
if device_auth:
# 2. Fetch the guide data using the token
xmltv_url = f"https://api.hdhomerun.com/api/xmltv?DeviceAuth={device_auth}"
guide_response = requests.get(xmltv_url)
# 3. Save as a properly encoded XML file read by Jellyfin
with open("/opt/hdhomerun/hdhomerun_guide.xml", "wb") as f:
f.write(guide_response.content)
print("Guide data successfully saved to hdhomerun_guide.xml")
else:
print("Failed to retrieve DeviceAuth token.")
# 4. refresh guide data using manual call to scheduled task
try:
requests.post(
"https://localhost:8920/ScheduledTasks/Running/bea9b218c97bbf98c5dc1303bdb9a0ca",
params={"api_key": "cbdfae9e8aa6468ca70e90e9d07fba34"},
verify=False
)
print("Guide data successfully refreshed")
except:
print("Failed to refresh guide data")
run_grabber.sh
#!/bin/bash
SCRIPT_PATH="/opt/hdhomerun/xmltv_grabber.py"
# Point directly to the virtual environment's python execution file
PYTHON_BIN="/opt/hdhomerun/hdhomerun_env/bin/python3"
echo "Starting HDHomeRun XMLTV download..."
$PYTHON_BIN "$SCRIPT_PATH"
echo "Download finished."
xml-grabber.service
[Unit]
Description=Run HDHomeRun XMLTV grabber
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/hdhomerun/run_grabber.sh
User=jake
xml-grabber.timer
[Unit]
Description=Timer for HDHomerun XMLTV grabber
[Timer]
# Bootstrap: run 5 minutes after startup
OnBootSec=5min
# Run 24 hours after last timer finished
OnUnitInactiveSec=24h
# Add a random delay
RandomizedDelaySec=2h
[Install]
WantedBy=timers.target
2.1 — What the grabber does¶
- Calls the Jellyfin API to clear the guide cache (scheduled task
241d4fcb19a1d557ee62428e411da609) - Fetches
DeviceAuthtoken from the HDHomeRun athttp://192.168.0.245/discover.json - Downloads XMLTV guide data from
https://api.hdhomerun.com/api/xmltv?DeviceAuth=<token> - Saves to
/opt/hdhomerun/hdhomerun_guide.xml - Calls the Jellyfin API to trigger a guide data refresh (scheduled task
bea9b219c97bbf98c5dc1303bdb9a0ca)
2.2 — Install dependencies¶
# Create a basic user to own the grabber files
adduser jake
usermod -aG sudo jake
# Create the script directory
mkdir -p /opt/hdhomerun
chown -R jake:jake /opt/hdhomerun
chmod 755 /opt/hdhomerun
# Install Python venv support
sudo apt update && sudo apt install -y python3-venv
# Create venv and install requests
cd /opt/hdhomerun
python3 -m venv hdhomerun_env
source hdhomerun_env/bin/activate
pip install requests
deactivate
Shared venv
This venv at /opt/hdhomerun/hdhomerun_env/ is also used by the DVR post-processor (see Part 3). Both scripts share it.
2.3 — Deploy scripts¶
# Copy scripts to the container (from Proxmox host)
pct push 109 /tmp/xmltv_grabber.py /opt/hdhomerun/xmltv_grabber.py --user jake --group jake
pct push 109 /tmp/run_grabber.sh /opt/hdhomerun/run_grabber.sh --user jake --group jake
chmod +x /opt/hdhomerun/run_grabber.sh
2.4 — Jellyfin API key¶
The grabber uses the Jellyfin API key hardcoded in xmltv_grabber.py. To regenerate:
- Jellyfin Dashboard → API Keys (under Advanced) → create new key
- Update
api_keyinxmltv_grabber.py
2.5 — Systemd service and timer¶
# Deploy service and timer
nano /etc/systemd/system/xml-grabber.service
nano /etc/systemd/system/xml-grabber.timer
xml-grabber.service:
[Unit]
Description=Run HDHomeRun XMLTV grabber
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/hdhomerun/run_grabber.sh
User=jake
xml-grabber.timer:
[Unit]
Description=Timer for HDHomerun XMLTV grabber
[Timer]
OnBootSec=5min
OnUnitInactiveSec=24h
RandomizedDelaySec=2h
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now xml-grabber.timer
systemctl list-timers | grep xml-grabber
2.6 — Configure Jellyfin guide source¶
In Jellyfin Dashboard → Live TV → Guide Data Providers, add an XMLTV source pointing to:
/opt/hdhomerun/hdhomerun_guide.xml
Map channels and refresh guide data.
Part 3 — DVR Post-Processing Pipeline¶
When a recording finishes, Jellyfin calls run_post_process.sh, which invokes post_process.py. The script transcodes the raw .ts recording to .mp4 using Intel VA-API hardware encoding via the iGPU, then deletes the original.
run_post_process.sh
#!/bin/bash
# =============================================================================
# run_post_process.sh
# Called directly by Jellyfin DVR when a recording finishes.
# Jellyfin passes the recorded file path as the first argument.
#
# Deploy to e.g. /opt/jellyfin-postprocess/run_post_process.sh
# Set this path in: Jellyfin Dashboard → DVR → Post-processing application
# Set arguments to: "{path}"
# =============================================================================
# --- Configuration -----------------------------------------------------------
SCRIPT_DIR="/opt/jellyfin-postprocess"
PYTHON_SCRIPT="$SCRIPT_DIR/post_process.py"
VENV_PYTHON="/opt/hdhomerun/hdhomerun_env/bin/python3" # Path to your existing venv python
LOG_DIR="/opt/jellyfin-postprocess/logs"
# -----------------------------------------------------------------------------
mkdir -p "$LOG_DIR"
LOGFILE="$LOG_DIR/$(date +"%Y-%m-%d_%H-%M-%S")-runner.log"
# Redirect all stdout/stderr to log file
exec > "$LOGFILE" 2>&1
echo "============================================"
echo "Jellyfin DVR Post-Process Runner"
echo "Started: $(date)"
echo "============================================"
if [ -z "$1" ]; then
echo "ERROR: No file path argument provided by Jellyfin. Exiting."
exit 1
fi
INPUT_FILE="$1"
echo "Input file: $INPUT_FILE"
if [ ! -f "$INPUT_FILE" ]; then
echo "ERROR: Input file does not exist: $INPUT_FILE"
exit 1
fi
if [ ! -f "$PYTHON_SCRIPT" ]; then
echo "ERROR: Python post-process script not found: $PYTHON_SCRIPT"
exit 1
fi
if [ ! -x "$VENV_PYTHON" ]; then
echo "ERROR: Venv Python not found or not executable: $VENV_PYTHON"
echo "Falling back to system python3..."
VENV_PYTHON=$(which python3)
fi
echo "Python interpreter: $VENV_PYTHON"
echo "Starting post-processor..."
echo ""
"$VENV_PYTHON" "$PYTHON_SCRIPT" "$INPUT_FILE"
EXIT_CODE=$?
echo ""
echo "============================================"
echo "Post-processor finished with exit code: $EXIT_CODE"
echo "Ended: $(date)"
echo "============================================"
exit $EXIT_CODE
post_process.py
#!/usr/bin/env python3
# =============================================================================
# post_process.py
# Jellyfin DVR post-processing script.
#
# What it does (in order):
# 1. Validates the input .ts file
# 2. Transcodes to H.264/AAC .mp4 using ffmpeg with Intel VA-API hardware encoding
# (iGPU via /dev/dri/renderD128 — deinterlaces MPEG-2 source before encoding)
# 3. Runs comskip on the ORIGINAL .ts to generate an EDL file
# 4. Generates a chapter-marked version using the comskip EDL (comchap)
# 5. Copies the Jellyfin .nfo metadata file alongside the new .mp4
# 6. Moves the original .ts (and .info) to /omv_media/old_files/
# 7. Triggers a Jellyfin library refresh via the API
#
# Usage: Called by run_post_process.sh with the recorded .ts path as $1
# =============================================================================
import os
import sys
import shutil
import logging
import subprocess
import time
import urllib.request
import urllib.error
import json
from pathlib import Path
from datetime import datetime
# =============================================================================
# --- USER CONFIGURATION — Edit these values for your setup -------------------
# =============================================================================
# Jellyfin API settings (for library refresh)
JELLYFIN_HOST = "http://localhost:8096" # Change if Jellyfin is on another host/port
JELLYFIN_API_KEY = "2a691a3e25724076b64c20f97d507610" # Admin API key from Jellyfin Dashboard → API Keys
# Tool paths (system installs)
# FFMPEG_PATH = "/usr/bin/ffmpeg" # 'which ffmpeg'
FFMPEG_PATH = "/usr/lib/jellyfin-ffmpeg/ffmpeg"
# FFPROBE_PATH = "/usr/bin/ffprobe" # 'which ffprobe'
FFPROBE_PATH = "/usr/lib/jellyfin-ffmpeg/ffprobe"
COMSKIP_PATH = "/usr/local/bin/comskip" # 'which comskip'
COMCHAP_PATH = "/usr/local/bin/comchap" # path to comchap script
# Optional: path to a comskip.ini config file (leave empty "" to use defaults)
RUN_COMSKIP = False
# COMSKIP_INI = "/opt/jellyfin-postprocess/config/comskip.ini"
COMSKIP_INI = ""
# Directory to move the original .ts files into (must exist or will be created)
OLD_FILES_DIR = "/omv_media/old_files"
# Recordings base directory (used for Jellyfin library scan targeting)
RECORDINGS_DIR = "/omv_media/recordings"
# Logging directory
LOG_DIR = "/opt/jellyfin-postprocess/logs"
# FFmpeg encode settings
# VA-API hardware encoding via Intel iGPU (UHD 630)
# VAAPI_DEVICE: the DRI render node exposed to this LXC container
VAAPI_DEVICE = "/dev/dri/renderD128"
# global_quality 23 = good quality/size balance (lower = better quality, larger file)
# Recommended range: 18-28. Note: VA-API uses -global_quality, not -crf.
FFMPEG_CRF = 23
FFMPEG_PRESET = "faster" # ultrafast/superfast/veryfast/faster/fast/medium/slow
# Note: VA-API preset has less impact than libx264
MAX_HEIGHT = 1080 # Cap resolution at 1080p
# Audio: "copy" to passthrough original audio (fastest, best quality if source is AC3/AAC)
# Use "aac" to re-encode audio (needed if source has incompatible audio)
AUDIO_CODEC = "copy"
# Subtitle extraction: extract closed captions from .ts to .srt alongside .mp4
EXTRACT_SUBTITLES = False
DELETE_OLD = True
# =============================================================================
# --- END OF USER CONFIGURATION -----------------------------------------------
# =============================================================================
def setup_logging(input_file: Path) -> logging.Logger:
"""Set up file and console logging."""
os.makedirs(LOG_DIR, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
safe_name = input_file.stem.replace(" ", "_")[:40]
log_path = os.path.join(LOG_DIR, f"{timestamp}-{safe_name}-postprocess.log")
logger = logging.getLogger("postprocess")
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
fh = logging.FileHandler(log_path)
fh.setLevel(logging.DEBUG)
fh.setFormatter(formatter)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
ch.setFormatter(formatter)
logger.addHandler(fh)
logger.addHandler(ch)
logger.info(f"Log file: {log_path}")
return logger
def run_command(cmd: list, logger: logging.Logger, timeout: int = 7200) -> subprocess.CompletedProcess:
"""Run a subprocess command, log output, and return the result."""
logger.info(f"Running: {' '.join(str(c) for c in cmd)}")
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=timeout
)
if result.stdout:
for line in result.stdout.strip().splitlines():
logger.debug(f" > {line}")
if result.returncode != 0:
logger.warning(f"Command exited with code {result.returncode}")
return result
def get_video_info(input_path: Path, logger: logging.Logger) -> dict:
"""Use ffprobe to get video stream info."""
cmd = [
FFPROBE_PATH, "-v", "quiet",
"-print_format", "json",
"-show_streams", str(input_path)
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
logger.warning("ffprobe failed — proceeding without stream info")
return {}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {}
def build_ffmpeg_cmd(input_path: Path, output_path: Path, logger: logging.Logger) -> list:
"""
Build the ffmpeg transcode command using Intel VA-API hardware encoding.
- Video: h264_vaapi (Intel iGPU), quality via -global_quality, capped at 1080p
- Deinterlacing: yadif on CPU before hwupload (required for MPEG-2 .ts sources)
- Audio: copy or aac re-encode (configurable above)
- Subtitles: passed through if present in stream
- Metadata: copied from source
"""
info = get_video_info(input_path, logger)
streams = info.get("streams", [])
# Determine if we need to scale down
video_streams = [s for s in streams if s.get("codec_type") == "video"]
needs_scale = False
if video_streams:
height = video_streams[0].get("height", 0)
if height > MAX_HEIGHT:
needs_scale = True
logger.info(f"Source height {height}px > {MAX_HEIGHT}px — will scale down")
else:
logger.info(f"Source height {height}px — no scaling needed")
# Video filter is built inside the VA-API command block below (vaapi_vf)
# Original CPU encoder (libx264) — kept for reference
#cmd = [
# FFMPEG_PATH,
# "-i", str(input_path),
# "-map", "0:v:0",
# "-map", "0:a?",
# "-map", "0:s?",
# "-vf", vf,
# "-c:v", "libx264",
# "-crf", str(FFMPEG_CRF),
# "-preset", FFMPEG_PRESET,
# "-profile:v", "high",
# "-level:v", "4.1",
# "-c:a", AUDIO_CODEC,
#]
# VA-API hardware encoder via Intel iGPU (UHD 630)
# - yadif deinterlaces the MPEG-2 source on CPU before handing to GPU
# (required: interlaced frames cause buffer overflow in h264_vaapi)
# - format=nv12 converts colorspace to what VA-API expects
# - hwupload transfers frames to GPU memory for encoding
# - scale_vaapi applied on GPU if downscaling is needed (>1080p sources)
# if needs_scale:
# vaapi_vf = f"yadif=mode=1,format=nv12,hwupload,scale_vaapi=-2:{MAX_HEIGHT}"
# else:
# vaapi_vf = "yadif=mode=1,format=nv12,hwupload"
if needs_scale:
vaapi_vf = f"yadif=mode=0,fps=30000/1001,format=nv12,hwupload,scale_vaapi=-2:{MAX_HEIGHT}"
else:
vaapi_vf = "yadif=mode=0,fps=30000/1001,format=nv12,hwupload"
cmd = [
FFMPEG_PATH,
"-vaapi_device", VAAPI_DEVICE, # Point FFmpeg at the iGPU render node
"-i", str(input_path),
"-map", "0:v:0", # First video stream
"-map", "0:a?", # All audio streams (if present)
"-map", "0:s?", # All subtitle streams (if present)
"-vf", vaapi_vf,
"-c:v", "h264_vaapi", # Intel VA-API H.264 hardware encoder
"-global_quality", str(FFMPEG_CRF), # Quality target (like CRF for VA-API)
"-c:a", AUDIO_CODEC,
]
# If audio codec is copy but source might be AC3 (common from TV tuners),
# force AAC re-encode on copy failures by trying copy first
if AUDIO_CODEC == "copy":
cmd += ["-c:a", "copy", "-c:a:0", "copy"]
else:
cmd += ["-c:a", "aac", "-b:a", "192k", "-ac", "2"]
cmd += [
"-c:s", "mov_text", # Convert subtitles to MP4-compatible format
"-movflags", "+faststart", # Move moov atom to front for streaming
"-metadata", f"comment=Post-processed by Jellyfin DVR pipeline",
"-y", # Overwrite output without asking
str(output_path)
]
return cmd
def extract_subtitles(input_path: Path, output_base: Path, logger: logging.Logger):
"""Attempt to extract closed captions from the .ts to a .srt file."""
srt_path = output_base.with_suffix(".srt")
cmd = [
FFMPEG_PATH,
"-i", str(input_path),
"-map", "0:s:0", # First subtitle stream
"-c:s", "srt",
"-y",
str(srt_path)
]
try:
result = run_command(cmd, logger, timeout=300)
if result.returncode == 0 and srt_path.exists() and srt_path.stat().st_size > 100:
logger.info(f"Subtitles extracted: {srt_path}")
else:
logger.info("No subtitle stream found or extraction produced empty file — skipping")
if srt_path.exists():
srt_path.unlink()
except subprocess.TimeoutExpired:
logger.warning("Subtitle extraction timed out — skipping")
def run_comskip(input_path: Path, logger: logging.Logger) -> Path | None:
"""
Run comskip on the original .ts file to detect commercial segments.
Returns the path to the generated .edl file, or None on failure.
EDL output is controlled ONLY via the ini file in this build of comskip
(--edl flag does not exist). A minimal runtime ini is always written to a
temp dir with output_edl=1 as the baseline. If COMSKIP_INI points to a
user ini, its contents are appended so user settings take precedence.
"""
logger.info("Running comskip commercial detection...")
output_dir = Path("/tmp") / f"comskip_{input_path.stem[:40]}"
output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"comskip output dir: {output_dir}")
# Always write a runtime ini guaranteeing output_edl=1.
# Without this, comskip detects commercials but writes no output files.
runtime_ini_path = output_dir / "comskip_runtime.ini"
ini_lines = [
"; Runtime ini generated by post_process.py",
"; output_edl=1 is required — comskip writes no files without it",
"output_edl=1",
"",
]
user_ini_path = Path(COMSKIP_INI).resolve() if COMSKIP_INI else None
if user_ini_path and user_ini_path.is_file():
logger.info(f"Merging user comskip ini: {user_ini_path}")
ini_lines.append(f"; --- merged from {user_ini_path} ---")
ini_lines.append(user_ini_path.read_text())
else:
if COMSKIP_INI:
logger.warning(f"User comskip.ini not found at '{COMSKIP_INI}' — using built-in defaults + output_edl=1")
else:
logger.info("No user comskip.ini configured — using built-in defaults + output_edl=1")
runtime_ini_path.write_text("\n".join(ini_lines))
cmd = [
COMSKIP_PATH,
f"--ini={runtime_ini_path}", # = syntax avoids path-with-spaces ambiguity
"--output", str(output_dir),
str(input_path),
]
try:
result = run_command(cmd, logger, timeout=3600)
except subprocess.TimeoutExpired:
logger.error("comskip timed out after 1 hour")
shutil.rmtree(str(output_dir), ignore_errors=True)
return None
edl_path = output_dir / (input_path.stem + ".edl")
produced = list(output_dir.iterdir())
logger.info(f"comskip output files: {[f.name for f in produced]}")
if edl_path.exists() and edl_path.stat().st_size > 0:
logger.info(f"comskip EDL file created: {edl_path}")
return edl_path
elif edl_path.exists():
logger.info("comskip produced an empty EDL — no commercials detected in this recording")
shutil.rmtree(str(output_dir), ignore_errors=True)
return None
else:
logger.warning(
f"comskip did not produce an EDL file. Return code was {result.returncode}."
)
shutil.rmtree(str(output_dir), ignore_errors=True)
return None
def apply_comchap(mp4_path: Path, edl_path: Path, logger: logging.Logger):
"""
Run comchap to embed commercial break chapters into the .mp4 using the EDL.
comchap adds chapter markers so clients can skip commercials.
Cleans up the comskip temp directory in /tmp when done.
"""
if not os.path.isfile(COMCHAP_PATH):
logger.warning(f"comchap not found at {COMCHAP_PATH} — skipping chapter marking")
return
logger.info("Applying comchap chapter markers to transcoded file...")
cmd = [COMCHAP_PATH, str(edl_path), str(mp4_path)]
try:
result = run_command(cmd, logger, timeout=300)
if result.returncode == 0:
logger.info("Commercial chapter markers applied successfully")
else:
logger.warning("comchap returned non-zero — chapter marking may have failed")
except subprocess.TimeoutExpired:
logger.warning("comchap timed out")
finally:
temp_dir = edl_path.parent
if str(temp_dir).startswith("/tmp"):
shutil.rmtree(str(temp_dir), ignore_errors=True)
logger.debug(f"Cleaned up comskip temp dir: {temp_dir}")
def copy_info_file(input_path: Path, output_path: Path, logger: logging.Logger):
"""Copy the Jellyfin .nfo metadata file to sit alongside the new .mp4."""
info_src = input_path.with_suffix(".nfo")
if info_src.exists():
info_dst = output_path.with_suffix(".nfo")
if info_src != info_dst:
shutil.copy2(str(info_src), str(info_dst))
logger.info(f"Copied .nfo metadata: {info_dst}")
else:
logger.info("no need to copy, .nfo source and destination identical")
else:
logger.info("No .nfo file found alongside .ts — skipping metadata copy")
def move_to_old_files(input_path: Path, logger: logging.Logger):
"""Move original .ts and its .nfo file to the OLD_FILES_DIR archive directory."""
os.makedirs(OLD_FILES_DIR, exist_ok=True)
if DELETE_OLD:
src = input_path.with_suffix(".ts")
src.unlink()
logger.info(f"deleted original file: {src}")
else:
for suffix in [".ts", ".nfo"]:
src = input_path.with_suffix(suffix)
dst = Path(OLD_FILES_DIR) / src.name
# Handle name collisions
if dst.exists():
dst = Path(OLD_FILES_DIR) / f"{src.stem}_{int(time.time())}{suffix}"
shutil.move(str(src), str(dst))
logger.info(f"Moved {suffix}: {src.name} → {dst}")
# comskip output files are written to /tmp and cleaned up by apply_comchap
# def refresh_jellyfin_library(logger: logging.Logger):
# """
# Trigger a Jellyfin library scan via the REST API.
# This causes Jellyfin to pick up the new .mp4 with chapter markers.
# """
# if JELLYFIN_API_KEY == "YOUR_API_KEY_HERE":
# logger.warning("Jellyfin API key not configured — skipping library refresh")
# logger.warning("Set JELLYFIN_API_KEY in this script to enable auto-refresh")
# return
# url = f"{JELLYFIN_HOST}/Library/Refresh"
# req = urllib.request.Request(
# url,
# method="POST",
# headers={
# "X-Emby-Authorization": (
# f'MediaBrowser Client="PostProcess", Device="DVR Script", '
# f'DeviceId="dvr-postprocess-01", Version="1.0", Token="{JELLYFIN_API_KEY}"'
# ),
# "Content-Length": "0",
# }
# )
# try:
# with urllib.request.urlopen(req, timeout=30) as response:
# logger.info(f"Jellyfin library refresh triggered — HTTP {response.status}")
# except urllib.error.HTTPError as e:
# logger.warning(f"Jellyfin library refresh failed — HTTP {e.code}: {e.reason}")
# except urllib.error.URLError as e:
# logger.warning(f"Jellyfin library refresh failed — Could not connect: {e.reason}")
# except Exception as e:
# logger.warning(f"Jellyfin library refresh failed — Unexpected error: {e}")
def refresh_jellyfin_library(logger: logging.Logger):
"""
Trigger a Jellyfin library scan via the REST API.
This causes Jellyfin to pick up the new .mp4 with chapter markers.
"""
if JELLYFIN_API_KEY == "YOUR_API_KEY_HERE":
logger.warning("Jellyfin API key not configured — skipping library refresh")
logger.warning("Set JELLYFIN_API_KEY in this script to enable auto-refresh")
return
# Strip trailing slash so we never produce double-slash URLs
host = JELLYFIN_HOST.rstrip("/")
auth_header = (
f'MediaBrowser Client="PostProcess", Device="DVR Script", '
f'DeviceId="dvr-postprocess-01", Version="1.0", Token="{JELLYFIN_API_KEY}"'
)
def do_post(url, redirect_count=0):
"""POST to url, following 307/308 redirects while preserving the POST method."""
if redirect_count > 5:
logger.warning("Jellyfin library refresh failed — too many redirects")
return
import http.client
from urllib.parse import urlparse
parsed = urlparse(url)
use_https = parsed.scheme == "https"
port = parsed.port or (443 if use_https else 80)
path = parsed.path or "/"
if parsed.query:
path += "?" + parsed.query
conn_cls = http.client.HTTPSConnection if use_https else http.client.HTTPConnection
conn = conn_cls(parsed.hostname, port, timeout=30)
try:
conn.request(
"POST", path,
headers={
"X-Emby-Authorization": auth_header,
"Content-Length": "0",
"Content-Type": "application/json",
}
)
resp = conn.getresponse()
resp.read() # drain body
if resp.status in (307, 308):
location = resp.getheader("Location", "")
logger.debug(f"Jellyfin redirected ({resp.status}) to: {location}")
if not location:
logger.warning("Jellyfin redirect had no Location header")
return
if location.startswith("/"):
location = f"{parsed.scheme}://{parsed.hostname}:{port}{location}"
do_post(location, redirect_count + 1)
elif 200 <= resp.status < 300:
logger.info(f"Jellyfin library refresh triggered — HTTP {resp.status}")
else:
logger.warning(f"Jellyfin library refresh failed — HTTP {resp.status}")
except Exception as e:
logger.warning(f"Jellyfin library refresh failed — {e}")
finally:
conn.close()
try:
do_post(f"{host}/Library/Refresh")
except Exception as e:
logger.warning(f"Jellyfin library refresh failed — Unexpected error: {e}")
# =============================================================================
# Main
# =============================================================================
def main():
if len(sys.argv) < 2:
print("Usage: post_process.py <path_to_recording.ts>")
sys.exit(1)
input_path = Path(sys.argv[1]).resolve()
logger = setup_logging(input_path)
logger.info("=" * 60)
logger.info("Jellyfin DVR Post-Processing Script")
logger.info(f"Processing: {input_path}")
logger.info("=" * 60)
# --- Validation ----------------------------------------------------------
if not input_path.exists():
logger.error(f"Input file does not exist: {input_path}")
sys.exit(1)
if input_path.suffix.lower() not in [".ts", ".mkv", ".mp4"]:
logger.warning(f"Unexpected file extension: {input_path.suffix} — proceeding anyway")
for tool, path in [("ffmpeg", FFMPEG_PATH), ("ffprobe", FFPROBE_PATH), ("comskip", COMSKIP_PATH)]:
if not os.path.isfile(path):
logger.error(f"{tool} not found at {path}. Install it and update the path in this script.")
sys.exit(1)
file_size_mb = input_path.stat().st_size / (1024 * 1024)
logger.info(f"Input file size: {file_size_mb:.1f} MB")
# --- Output paths --------------------------------------------------------
# The transcoded .mp4 replaces the .ts in the same directory
output_path = input_path.with_suffix(".mp4")
if output_path.exists():
logger.warning(f"Output already exists, will overwrite: {output_path}")
# --- Step 1: Transcode with ffmpeg ---------------------------------------
logger.info("")
logger.info("--- Step 1: Transcoding with ffmpeg ---")
start_time = time.time()
ffmpeg_cmd = build_ffmpeg_cmd(input_path, output_path, logger)
try:
result = run_command(ffmpeg_cmd, logger, timeout=14400) # 4 hour max
except subprocess.TimeoutExpired:
logger.error("FFmpeg transcode timed out after 4 hours — aborting")
sys.exit(1)
if result.returncode != 0:
logger.error("FFmpeg transcode FAILED. Aborting post-processing.")
logger.error("Original .ts file has NOT been moved.")
sys.exit(1)
elapsed = time.time() - start_time
out_size_mb = output_path.stat().st_size / (1024 * 1024)
reduction = (1 - out_size_mb / file_size_mb) * 100
logger.info(f"Transcode complete in {elapsed:.0f}s")
logger.info(f"Output size: {out_size_mb:.1f} MB (reduced by {reduction:.1f}%)")
# --- Step 2: Extract subtitles (optional) --------------------------------
if EXTRACT_SUBTITLES:
logger.info("")
logger.info("--- Step 2: Extracting closed captions ---")
extract_subtitles(input_path, output_path, logger)
# --- Step 3: Run comskip commercial detection ----------------------------
if RUN_COMSKIP:
logger.info("")
logger.info("--- Step 3: comskip commercial detection ---")
edl_path = run_comskip(input_path, logger)
else:
edl_path = False
# --- Step 4: Apply comchap chapter markers to the .mp4 ------------------
if edl_path and edl_path.exists():
logger.info("")
logger.info("--- Step 4: Applying comchap chapter markers ---")
apply_comchap(output_path, edl_path, logger)
else:
logger.info("--- Step 4: Skipped (no EDL file from comskip) ---")
# --- Step 5: Copy .nfo metadata file ------------------------------------
logger.info("")
logger.info("--- Step 5: Copying .nfo metadata ---")
copy_info_file(input_path, output_path, logger)
# --- Step 6: Move original .ts to archive --------------------------------
logger.info("")
logger.info("--- Step 6: Moving original .ts to archive ---")
move_to_old_files(input_path, logger)
# --- Step 7: Refresh Jellyfin library ------------------------------------
logger.info("")
logger.info("--- Step 7: Refreshing Jellyfin library ---")
refresh_jellyfin_library(logger)
logger.info("")
logger.info("=" * 60)
logger.info("Post-processing complete!")
logger.info(f"New file: {output_path}")
logger.info("=" * 60)
sys.exit(0)
if __name__ == "__main__":
main()
comskip.ini
; Comskip configuration
; See https://www.kaashoek.com/comskip/ for all options
; Detection sensitivity (0 = most aggressive, 100 = least)
detect_method=43 ; bitmask: logo+scene change+silence+AR change+blank frames
; Minimum/maximum commercial block lengths (seconds)
minimum_commercial_break=25
maximum_commercial_break=600
minimum_show_segment_length=120
; Output formats
output_edl=1 ; Required for comchap
output_txt=1 ; Human-readable break list
output_logo=0
; Logo detection
test_logo=0
3.1 — Pipeline overview¶
Recording finishes (.ts saved to /omv_media/recordings/)
↓
Jellyfin calls run_post_process.sh "{path}"
↓
run_post_process.sh → post_process.py
↓
1. FFmpeg (VA-API): .ts → .mp4 (h264_vaapi, global_quality 23, 1080p max)
2. Extract subtitles → .srt (disabled: EXTRACT_SUBTITLES = False)
3. Comskip: detect commercials (disabled: RUN_COMSKIP = False)
4. Comchap: embed chapter marks (skipped if no EDL)
5. Copy .nfo metadata alongside .mp4
6. Delete original .ts (DELETE_OLD = True)
7. Trigger Jellyfin library refresh via API
Comskip currently disabled
RUN_COMSKIP = False and EXTRACT_SUBTITLES = False in the live config. The comskip.ini is deployed at /opt/jellyfin-postprocess/config/comskip/comskip.ini but COMSKIP_INI = "" in the script — to re-enable, set RUN_COMSKIP = True and set COMSKIP_INI to the ini path.
3.2 — Install system packages¶
sudo apt update && sudo apt upgrade -y
# FFmpeg (system package — used only for verification; post-processor uses jellyfin-ffmpeg)
sudo apt install -y ffmpeg
# jellyfin-ffmpeg (required — system ffmpeg 6.x has a bug with h264_vaapi on 720p/59.94fps MPEG-2)
sudo apt install -y jellyfin-ffmpeg7
/usr/lib/jellyfin-ffmpeg/ffmpeg -version | head -1 # verify
# Comskip build dependencies
sudo apt install -y \
git build-essential autoconf automake libtool pkg-config \
libavformat-dev libavcodec-dev libavutil-dev libswscale-dev \
libargtable2-dev libmpeg2-4-dev
# Build and install comskip
cd /tmp
git clone https://github.com/erikkaashoek/Comskip.git
cd Comskip
autoreconf --install
./configure
make
sudo make install
comskip --version
# Comchap
cd /tmp
git clone https://github.com/BrettSheleski/comchap.git
sudo cp comchap/comchap /usr/local/bin/comchap
sudo chmod +x /usr/local/bin/comchap
# mkvtoolnix (provides mkvpropedit, required by comchap)
sudo apt install -y mkvtoolnix
# mutagen in the shared venv
source /opt/hdhomerun/hdhomerun_env/bin/activate
pip install mutagen
deactivate
3.3 — Deploy scripts¶
sudo mkdir -p /opt/jellyfin-postprocess/logs
sudo mkdir -p /opt/jellyfin-postprocess/config/comskip
sudo chown -R jellyfin:jellyfin /opt/jellyfin-postprocess
# Copy scripts (from Proxmox host)
pct push 109 /tmp/post_process.py /opt/jellyfin-postprocess/post_process.py --user jellyfin --group jellyfin
pct push 109 /tmp/run_post_process.sh /opt/jellyfin-postprocess/run_post_process.sh --user jellyfin --group jellyfin
pct push 109 /tmp/comskip.ini /opt/jellyfin-postprocess/config/comskip/comskip.ini --user jellyfin --group jellyfin
chmod +x /opt/jellyfin-postprocess/run_post_process.sh
3.4 — Configure Jellyfin DVR post-processing¶
In Jellyfin Dashboard → Live TV → Recording Post Processing:
- Post-processing application:
/opt/jellyfin-postprocess/run_post_process.sh - Post-processor command line arguments:
"{path}"
3.5 — Key config values in post_process.py¶
| Setting | Live value | Notes |
|---|---|---|
FFMPEG_PATH |
/usr/lib/jellyfin-ffmpeg/ffmpeg |
Must use jellyfin-ffmpeg, not /usr/bin/ffmpeg |
FFPROBE_PATH |
/usr/lib/jellyfin-ffmpeg/ffprobe |
Same binary package |
VAAPI_DEVICE |
/dev/dri/renderD128 |
iGPU render node |
FFMPEG_CRF |
23 |
VA-API global_quality — lower = better quality |
FFMPEG_PRESET |
faster |
VA-API preset has less impact than libx264 |
MAX_HEIGHT |
1080 |
Caps output at 1080p |
AUDIO_CODEC |
copy |
Passes through AC3/EAC3 from tuner |
RUN_COMSKIP |
False |
Commercial detection disabled |
EXTRACT_SUBTITLES |
False |
Subtitle extraction disabled |
DELETE_OLD |
True |
Original .ts is deleted after transcode |
OLD_FILES_DIR |
/omv_media/old_files |
Used only when DELETE_OLD = False |
The shell wrapper run_post_process.sh uses the shared venv:
VENV_PYTHON="/opt/hdhomerun/hdhomerun_env/bin/python3"
3.6 — Test manually¶
# Switch to jellyfin user to match runtime permissions
sudo -u jellyfin bash
/opt/jellyfin-postprocess/run_post_process.sh "/omv_media/recordings/ShowName S01E01.ts"
# Watch the log
tail -f /opt/jellyfin-postprocess/logs/*.log
Part 4 — iGPU Passthrough (Intel UHD 630 → LXC)¶
The Proxmox host is a Dell OptiPlex 5070 with an Intel i7-9700 (UHD Graphics 630, Gen9.5). The iGPU is passed through to the Jellyfin LXC for VA-API hardware encoding.
QSV does not work on this hardware
Intel Quick Sync Video (QSV) via libmfx-gen/libvpl targets Gen12+ hardware (Tiger Lake and newer). The UHD 630 is Gen9.5 and MFX session creation fails with error -9. VA-API is the correct API for this GPU generation. The Jellyfin WebUI setting should be VA-API, not Intel QuickSync.
grub
# If you change this file or any /etc/default/grub.d/*.cfg file,
# run 'update-grub' afterwards to update /boot/grub/grub.cfg.
# For full documentation of the options in these files, see:
# info -f grub -n 'Simple configuration'
GRUB_DEFAULT=0
GRUB_TIMEOUT=5
GRUB_DISTRIBUTOR=`( . /etc/os-release && echo ${NAME} )`
GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"
GRUB_CMDLINE_LINUX=""
# If your computer has multiple operating systems installed, then you
# probably want to run os-prober. However, if your computer is a host
# for guest OSes installed via LVM or raw disk devices, running
# os-prober can cause damage to those guest OSes as it mounts
# filesystems to look for things.
#GRUB_DISABLE_OS_PROBER=false
# Uncomment to enable BadRAM filtering, modify to suit your needs
# This works with Linux (no patch required) and with any kernel that obtains
# the memory map information from GRUB (GNU Mach, kernel of FreeBSD ...)
#GRUB_BADRAM="0x01234567,0xfefefefe,0x89abcdef,0xefefefef"
# Uncomment to disable graphical terminal
#GRUB_TERMINAL=console
# The resolution used on graphical terminal
# note that you can use only modes which your graphic card supports via VBE/GOP/UGA
# you can see them in real GRUB with the command `videoinfo'
#GRUB_GFXMODE=640x480
# Uncomment if you don't want GRUB to pass "root=UUID=xxx" parameter to Linux
#GRUB_DISABLE_LINUX_UUID=true
# Uncomment to disable generation of recovery mode menu entries
#GRUB_DISABLE_RECOVERY="true"
# Uncomment to get a beep at grub start
#GRUB_INIT_TUNE="480 440 1"
109.conf
#<div align='center'>
# <a href='https%3A//Helper-Scripts.com' target='_blank' rel='noopener noreferrer'>
# <img src='https%3A//raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/images/logo-81x112.png' alt='Logo' style='width%3A81px;height%3A112px;'/>
# </a>
#
# <h2 style='font-size%3A 24px; margin%3A 20px 0;'>Jellyfin LXC</h2>
#
# <p style='margin%3A 16px 0;'>
# <a href='https%3A//ko-fi.com/community_scripts' target='_blank' rel='noopener noreferrer'>
# <img src='https%3A//img.shields.io/badge/☕-Buy us a coffee-blue' alt='spend Coffee' />
# </a>
# </p>
#
# <span style='margin%3A 0 10px;'>
# <i class="fa fa-github fa-fw" style="color%3A #f5f5f5;"></i>
# <a href='https%3A//github.com/community-scripts/ProxmoxVE' target='_blank' rel='noopener noreferrer' style='text-decoration%3A none; color%3A #00617f;'>GitHub</a>
# </span>
# <span style='margin%3A 0 10px;'>
# <i class="fa fa-comments fa-fw" style="color%3A #f5f5f5;"></i>
# <a href='https%3A//github.com/community-scripts/ProxmoxVE/discussions' target='_blank' rel='noopener noreferrer' style='text-decoration%3A none; color%3A #00617f;'>Discussions</a>
# </span>
# <span style='margin%3A 0 10px;'>
# <i class="fa fa-exclamation-circle fa-fw" style="color%3A #f5f5f5;"></i>
# <a href='https%3A//github.com/community-scripts/ProxmoxVE/issues' target='_blank' rel='noopener noreferrer' style='text-decoration%3A none; color%3A #00617f;'>Issues</a>
# </span>
#</div>
# iGPU passthrough
arch: amd64
cores: 4
features: nesting=1,keyctl=1
hostname: jellyfin
memory: 4096
mp0: /mnt/omv_media,mp=/omv_media
mp1: /sync_pool/arr_data/media,mp=/mnt/arr_media,ro=1
net0: name=eth0,bridge=vmbr0,gw=192.168.0.1,hwaddr=BC:24:11:37:E7:22,ip=192.168.0.75/24,ip6=auto,type=veth
onboot: 0
ostype: ubuntu
rootfs: local-lvm:vm-109-disk-1,size=24G
startup: up=60
swap: 512
tags: community-script;media
timezone: America/Phoenix
unprivileged: 1
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
lxc.cgroup2.devices.allow: c 226:1 rwm
lxc.cgroup2.devices.allow: c 226:128 rwm
lxc.mount.entry: /dev/dri/card1 dev/dri/card1 none bind,optional,create=file
lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file
99-igpu-lxc.rules
SUBSYSTEM=="drm", KERNEL=="renderD128", MODE="0666"
SUBSYSTEM=="drm", KERNEL=="card1", MODE="0666"
i915.conf
options i915 enable_guc=2
4.1 — Pre-flight¶
Before making any changes:
# Snapshot the Jellyfin LXC in Proxmox UI: Snapshots → Take Snapshot → "pre-igpu-passthrough"
# Back up GRUB config on Proxmox host
cp /etc/default/grub /etc/default/grub.bak
# Back up LXC config
cp /etc/pve/lxc/109.conf /etc/pve/lxc/109.conf.bak
4.2 — Dell BIOS¶
Reboot OptiPlex → F2 → BIOS Setup:
- Virtualization Support → Virtualization: Intel VT ✅, Intel VT for Direct I/O (VT-d) ✅
- Video → Primary Display: Auto or Intel HD Graphics ✅
F10 to save and reboot.
4.3 — GRUB (Proxmox host)¶
nano /etc/default/grub
Set:
GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"
update-grub
reboot
Verify after reboot:
dmesg | grep -e IOMMU -e iommu | head -20
# Look for: DMAR: IOMMU enabled
ls /dev/dri/
# Expected: card1 renderD128
To roll back:
cp /etc/default/grub.bak /etc/default/grub && update-grub && reboot
4.4 — HuC firmware (Proxmox host)¶
HuC firmware authentication is required for VA-API hardware encode sessions on Gen9 hardware. Without it, encode sessions fail at the driver level.
echo "options i915 enable_guc=2" > /etc/modprobe.d/i915.conf
update-initramfs -u
reboot
Use enable_guc=2, not =3
enable_guc=2 loads HuC only. enable_guc=3 also enables GuC submission which is not supported on this kernel configuration and taints the kernel.
Verify after reboot:
dmesg | grep -i huc
# Expected:
# i915 0000:00:02.0: [drm] GT0: HuC firmware i915/kbl_huc_4.0.0.bin version 4.0.0
# i915 0000:00:02.0: [drm] GT0: HuC: authenticated for all workloads
To roll back:
rm /etc/modprobe.d/i915.conf && update-initramfs -u && reboot
4.5 — udev rule (Proxmox host)¶
In an unprivileged LXC, the host render GID (993) falls outside the container's subgid range and appears as 65534/nogroup inside the container. A udev rule sets the DRI devices world-readable/writable, bypassing group ownership.
nano /etc/udev/rules.d/99-igpu-lxc.rules
SUBSYSTEM=="drm", KERNEL=="renderD128", MODE="0666"
SUBSYSTEM=="drm", KERNEL=="card1", MODE="0666"
udevadm control --reload-rules && udevadm trigger
ls -la /dev/dri/
# Both card1 and renderD128 should show crw-rw-rw-
4.6 — LXC config (Proxmox host)¶
Add to the bottom of /etc/pve/lxc/109.conf:
# iGPU passthrough
lxc.cgroup2.devices.allow: c 226:1 rwm
lxc.cgroup2.devices.allow: c 226:128 rwm
lxc.mount.entry: /dev/dri/card1 dev/dri/card1 none bind,optional,create=file
lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file
pct stop 109 && pct start 109
# Verify devices are visible inside the container
pct exec 109 -- ls -la /dev/dri/
# Expected: card1 and renderD128 listed
To roll back:
cp /etc/pve/lxc/109.conf.bak /etc/pve/lxc/109.conf
pct stop 109 && pct start 109
4.7 — VA-API drivers (inside LXC)¶
apt update
apt install -y intel-media-va-driver vainfo libmfx-gen1.2
Verify:
LIBVA_DRIVER_NAME=iHD LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri vainfo --display drm --device /dev/dri/renderD128
Healthy output lists supported profiles. On UHD 630 expect:
- VAProfileH264Main/High : VAEntrypointEncSliceLP (Low Power — this is normal, fully functional)
- VAProfileMPEG2Simple/Main : VAEntrypointVLD
- VAProfileHEVCMain : VAEntrypointVLD
EncSliceLP vs EncSlice
UHD 630 shows VAEntrypointEncSliceLP (Low Power) rather than VAEntrypointEncSlice (Full). This is expected and fully functional for hardware encoding.
If vainfo fails, the udev rule from 4.5 is not applied. Re-check and restart the container.
4.8 — Jellyfin transcoding settings¶
Dashboard → Playback → Transcoding:
- Hardware acceleration: VA-API
- VA-API Device:
/dev/dri/renderD128 - Enable: H.264 encoding ✅, H.264 decoding ✅, H.265/HEVC encoding ✅, H.265/HEVC decoding ✅, MPEG-2 video decoding ✅
- Disable: VP8/VP9 ⛔, AV1 ⛔ (not supported on UHD 630)
- Encoding preset: Faster or Fast
4.9 — Verify GPU utilization¶
Install intel-gpu-tools on the Proxmox host:
apt install -y intel-gpu-tools
Trigger a post-processing job (or run the script manually), then on the host:
intel_gpu_top
The Render/3D and Video engines should spike to 50–60% during encoding. If both stay near zero, FFmpeg is falling back to CPU encoding.
In FFmpeg output, confirm VA-API is active:
Stream #0:0: Video: h264 (High), vaapi(tv, progressive), ...
encoder: Lavc61.x.x h264_vaapi
4.10 — Full rollback¶
- Restore LXC config:
cp /etc/pve/lxc/109.conf.bak /etc/pve/lxc/109.conf && pct stop 109 && pct start 109 - Restore GRUB:
cp /etc/default/grub.bak /etc/default/grub && update-grub && reboot - Remove HuC config:
rm /etc/modprobe.d/i915.conf && update-initramfs -u && reboot - Revert BIOS: F2 → F9 (Load Defaults) → F10
- Revert Jellyfin: Dashboard → Playback → Transcoding → Hardware acceleration → None
Troubleshooting¶
| Symptom | Likely cause | Where to look |
|---|---|---|
/dev/dri empty inside LXC |
BIOS VT-d off or mount entries wrong | Part 4.2, Part 4.6 |
vainfo fails |
udev rule not applied | Part 4.5 |
Access unit too large FFmpeg error |
Using /usr/bin/ffmpeg instead of jellyfin-ffmpeg |
Part 3.2 |
| FFmpeg falls back to libx264 | Wrong FFmpeg binary or -vaapi_device flag missing |
Part 3.5 |
intel_gpu_top shows 0% during encode |
CPU encoder active | Part 3.5, Part 4.9 |
| HuC not authenticated | enable_guc=2 not set or initramfs not updated |
Part 4.4 |
| Container won't start after config change | LXC config syntax error | Restore 109.conf.bak |
| Host won't boot after GRUB change | Typo in kernel parameters | GRUB recovery: Esc at boot → Advanced options → previous kernel |
| Guide data stale | Grabber timer not running | systemctl list-timers \| grep xml-grabber |
| Library refresh 401 error | API key expired or wrong | Regenerate in Dashboard → API Keys |