McGarrah Technical Blog

Jellyfin Media Integrity Scanner: Deployment & Operations

· 18 min read

The plugin is built — scanner core, SQLite persistence, REST API, admin dashboard. Now it needs to run reliably in production without causing problems. This article covers the deployment story: installing the plugin, configuring it for shared storage, monitoring its operation, and automating the release pipeline.

This is Part 5 of the Jellyfin Media Integrity Scanner development series. The build pipeline, release pipeline, and Proxmox LXC provisioning described below are all live and operational, and MaxReadRateMbPerSec and the quiet-hours settings in the CephFS/NFS tuning tables are genuinely enforced (see CephFS-Specific Configuration for the mechanism). The CI/CD section reflects the real, current workflows — automated manifest.json version bumps on tagged releases, plus a unit test suite and a Docker-based integration suite gating every build (see CI/CD Pipeline for what that testing found). There’s also a real in-app settings page now (see the dashboard article), so the “Configure throttling settings” step in the Operational Runbook no longer means hand-editing an XML file. CephFS OSD-level tuning and Prometheus/Grafana monitoring remain aspirational — see the final article’s Future Work section for where those stand.

Installation Methods

Add the custom repository to Jellyfin:

  1. Dashboard → Plugins → Repositories → Add
  2. Name: mcgarrah-plugins
  3. URL: https://raw.githubusercontent.com/mcgarrah/jellyfin-plugin-media-integrity-scanner/main/manifest.json
  4. Save → Go to Catalog → Install Media Integrity Scanner
  5. Restart Jellyfin

If the plugin doesn’t appear in the Catalog after adding the repository, this is almost always the web client, not the server. Hit exactly this standing up a second test instance: the repository saved correctly, the manifest was reachable (confirmed both from a browser and with curl from inside the container), and querying the server’s own /Packages API directly with an authenticated request showed the plugin present in the catalog data all along — correct GUID, correctly tagged with the repository name. The web UI was just holding a stale, cached view of the catalog fetched before the repository was added. A hard refresh of the Catalog page (Ctrl+Shift+R / Cmd+Shift+R, not a normal reload) resolved it immediately. If that doesn’t do it, a full logout/login or a private-browsing window rules out cached session state, and it’s worth checking the Catalog page’s category filter dropdown hasn’t been left on something other than “General” from a previous session.

Method 2: Manual DLL Installation

For development or airgapped environments:

# Download the latest release
wget https://github.com/mcgarrah/jellyfin-plugin-media-integrity-scanner/releases/latest/download/media-integrity-scanner.zip

# Extract to plugin directory
unzip media-integrity-scanner.zip -d \
  /var/lib/jellyfin/plugins/MediaIntegrityScanner

# Restart Jellyfin
systemctl restart jellyfin

Method 3: Build from Source

git clone https://github.com/mcgarrah/jellyfin-plugin-media-integrity-scanner.git
cd jellyfin-plugin-media-integrity-scanner

dotnet build --configuration Release
dotnet publish --configuration Release --output ./publish

# Copy to plugins directory
cp -r ./publish /var/lib/jellyfin/plugins/MediaIntegrityScanner

systemctl restart jellyfin

Proxmox LXC Configuration

My Jellyfin runs in an unprivileged Proxmox LXC container. Key considerations:

FFmpeg Availability

The jellyfin-ffmpeg package bundles a compatible ffmpeg build:

# Inside the LXC container
apt install jellyfin-ffmpeg6

# Verify
/usr/lib/jellyfin-ffmpeg/ffmpeg -version

The plugin auto-detects this path. No configuration needed.

Resource Limits

The LXC container should have adequate resources for background scanning:

# /etc/pve/lxc/XXX.conf additions for scan workload
lxc.cgroup2.cpu.max: 200000 100000  # 2 cores max
lxc.cgroup2.memory.max: 4G
lxc.cgroup2.io.max: /dev/sdX rbps=10485760  # 10MB/s read limit

The io.max cgroup limit provides a hard ceiling on disk I/O at the container level, complementing the plugin’s application-level throttling.

Storage Mount

CephFS media storage mounted into the container:

# /etc/pve/lxc/XXX.conf
mp0: /mnt/cephfs/media,mp=/media,ro=0

For integrity scanning, read-only access is sufficient. Consider mounting as read-only to prevent any accidental writes:

mp0: /mnt/cephfs/media,mp=/media,ro=1

CephFS-Specific Configuration

CephFS distributed storage requires careful throttling to avoid impacting other clients:

{
  "MaxConcurrentScans": 1,
  "DelayBetweenFilesMs": 10000,
  "MaxReadRateMbPerSec": 5,
  "PauseDuringPlayback": true,
  "UseQuietHoursOnly": true,
  "QuietHoursStart": "01:00",
  "QuietHoursEnd": "07:00"
}

Why These Values

Update (July 31, 2026) — how the read-rate cap and quiet hours actually work: For a while, MaxReadRateMbPerSec and the quiet-hours settings were config fields that did nothing — a gap found during a later review. They’re enforced now, but worth understanding the mechanism:

The OS-level lxc.cgroup2.io.max limit from the Resource Limits section above is still the harder guarantee — the plugin’s pacing is a cooperative, application-level complement to it, not a replacement.

Monitoring CephFS Impact

Watch OSD utilization during scanning:

# On Proxmox host
ceph osd perf

# Per-OSD bandwidth
ceph daemon osd.X perf dump | jq '.osd.op_r_out_bytes'

# Client I/O from the Jellyfin container
ceph daemon mds.X session ls | jq '.[].inst'

NFS Storage Configuration

For NFS-backed media libraries:

{
  "MaxConcurrentScans": 1,
  "DelayBetweenFilesMs": 5000,
  "MaxReadRateMbPerSec": 20,
  "PauseDuringPlayback": true
}

NFS is more tolerant of sequential reads than CephFS, so the rate limit can be higher. Monitor with nfsstat on the server side.

Monitoring & Alerting

Prometheus Metrics (Future Enhancement)

The API endpoint provides data suitable for Prometheus scraping:

# prometheus.yml scrape config
- job_name: 'jellyfin-media-integrity'
  metrics_path: '/MediaIntegrity/Status'
  static_configs:
    - targets: ['jellyfin-host:8096']
  # Custom relabeling to extract metrics from JSON response

Simple Health Check Script

Until native Prometheus support is added:

#!/bin/bash
# /usr/local/bin/check-media-integrity.sh

API_KEY="your-api-key"
JELLYFIN_URL="http://localhost:8096"

STATUS=$(curl -s "${JELLYFIN_URL}/MediaIntegrity/Status" \
  -H "X-Emby-Token: ${API_KEY}")

FAILED=$(echo "$STATUS" | jq '.failedFiles')
HEALTH=$(echo "$STATUS" | jq '.healthPercentage')

if [ "$FAILED" -gt 0 ]; then
    echo "WARNING: $FAILED media files failed integrity check"
    echo "Library health: ${HEALTH}%"
    # Send notification (adapt to your alerting system)
    # curl -d "Media integrity: $FAILED failed files" ntfy.sh/your-topic
    exit 1
fi

echo "OK: Library health ${HEALTH}%"
exit 0

Systemd Timer for Health Checks

# /etc/systemd/system/media-integrity-check.timer
[Unit]
Description=Check media integrity scan results

[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true

[Install]
WantedBy=timers.target

CI/CD Pipeline

Build environment note (updated July 31, 2026): The Proxmox LXC build/integration-test container is operational (.NET 9 SDK, jellyfin-ffmpeg, test Jellyfin instance, sample media). GitHub-hosted Ubuntu runners remain the primary CI path; the LXC is used for local/manual verification.

Update: The workflows below are the real, current ones — including two things that were missing for a while: a wired-up unit test suite (tests/Jellyfin.Plugin.MediaIntegrityScanner.Tests, covering the quiet-hours/read-rate pacing logic from the scanner core article), and a real scripts/update-manifest.py wired into the release workflow (the script existed only as an unimplemented reference in this article before). Note that dotnet build/dotnet publish target the plugin’s .csproj explicitly rather than the solution file — once the test project joined the .sln, publishing the whole solution would have bundled test binaries into the release artifact.

Update (August 1, 2026) — the test suite grew a lot, and it found real bugs: The unit test project grew from 20 tests to 113 across 8 files, covering the database layer, the ffmpeg process wrapper, the API controller, and ScanEngine itself with mocked dependencies. Separately, the Docker-based integration suite (tests/run-integration-tests.sh and integration-test.yml, kept in sync with each other) grew from a basic “plugin loads, config endpoint responds” smoke check into something that actually exercises the scanning pipeline: a settings-page configuration round-trip, both web pages being served, a full scan-and-verify flow (trigger → poll → assert results), item-detail lookups, an item-scoped deep scan, and the cancel endpoint.

Two things surfaced along the way that are worth knowing about if you’re troubleshooting a similar setup:

Update (August 2, 2026) — a real corruption matrix, and a real-browser test suite: The integration suite’s test media was, until now, a single always-valid clip — every scan test proved the scanner ran, but none proved it actually detected anything. tests/generate-test-media.sh now generates seven files: two valid (different container/codec pairs) and five corrupted in distinct, verified-for-real ways — an empty file, random bytes with a video extension, a zeroed header, a truncated copy, and a file with a few KB zeroed out mid-stream. The last two are the interesting pair: both pass a header-only ffprobe scan (the corruption doesn’t touch moov/ftyp) but fail a full ffmpeg decode — the first automated proof that this plugin’s two-phase scanning design actually does what it claims, rather than just running twice for show.

Building on top of that, a Playwright suite now drives the dashboard and settings pages through a real Chromium session — logging in via the actual web form, triggering a real scan, and asserting the UI reflects it — instead of the integration suite’s curl+grep, which never executes a page’s own JavaScript or its real ApiClient-backed session. That gap is exactly what caught the dashboard-was-never-reachable bug described in the dashboard article’s update. It runs in its own playwright-e2e.yml workflow, deliberately kept separate from build.yml/integration-test.yml — a real-browser suite is slower and more prone to environmental flakiness than a curl-based check, so its result is independent rather than blocking those other checks.

One operational gotcha worth keeping in mind for any script that starts a Jellyfin container and installs a plugin into it in the same breath: the plugin DLL has to land in the bind-mounted config directory before the container’s first boot, not after. Jellyfin loads plugins once, early in its own startup; copying a DLL in after that point is a no-op until the container is restarted. This is a genuine race, not a hypothetical — reproduced locally by starting the container and copying the DLL in immediately after, which intermittently lost the race and left every /MediaIntegrity/* route 404ing until an explicit restart. Both CI workflows now copy the plugin in before docker run/docker compose up, not after.

Update: Automated Development Releases

Adding an in-plugin update checker that can offer a Development channel meant that channel needed something real to point at — a plugin repository manifest that actually gets newer entries as work lands on main, not just at tagged milestones.

A second workflow, release-dev.yml, now runs on every push to main and cuts a real GitHub pre-release plus a new manifest-unstable.json, parallel to the existing tagged-release path but never touching the stable manifest.json. The tricky part was version numbering: Jellyfin manifest versions have to be a clean 4-part numeric System.Version — no semver -dev/-rc suffix survives round-tripping through it — so the dev workflow keeps Major.Minor.Build from the current stable base and bumps only the fourth (Revision) component using the run’s own unique, ever-increasing run number (0.1.0.147, say). The human-friendly v0.1.0-dev.147 form still shows up in the GitHub release title and changelog text, just not in the version Jellyfin actually compares against.

One thing deliberately not done: bumping Directory.Build.props on every single dev push and committing it back. That’s fine for a build artifact (the compiled DLL needs the real version baked in to compare correctly against what’s installed), but committing it every push would spam the repo and collide with the stable release workflow’s own version-bump commits to the same file — so the dev workflow bumps it locally within the CI run only, discards that change, and commits just the manifest update.

Also fixed while wiring this up: the existing stable release.yml updated manifest.json’s version on every tag but never actually bumped Directory.Build.props — meaning the built assembly’s own version number never moved past 0.1.0.0, tag after tag. Harmless before there was any code that cared about the plugin’s own version, but a real problem for an update checker: it would report “update available” forever, even seconds after actually installing one, since the thing it’s comparing against never changes. Both workflows now agree on the version story.

No safe way to dry-run a workflow whose only trigger is “push to main” without literally pushing to main first, so the real test was the first real merge. It worked cleanly on the first attempt: a genuine v0.1.0-dev.1 pre-release appeared with the built zip attached, and manifest-unstable.json picked up a matching 0.1.0.1 entry with the right checksum and source URL, no fix-forward required.

GitHub Actions: Build & Test

# .github/workflows/build.yml
name: Build Plugin

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup .NET 9
        uses: actions/setup-dotnet@v6
        with:
          dotnet-version: '9.0.x'

      - name: Restore dependencies
        run: dotnet restore --verbosity normal

      - name: Build
        run: dotnet build --configuration Release --no-restore --verbosity normal

      - name: Test
        run: dotnet test --configuration Release --no-restore --no-build --verbosity normal

      - name: Publish
        run: dotnet publish Jellyfin.Plugin.MediaIntegrityScanner/Jellyfin.Plugin.MediaIntegrityScanner.csproj --configuration Release --output ./artifacts --no-build

      - name: List artifacts
        run: ls -la ./artifacts/

      - name: Upload build artifact
        uses: actions/upload-artifact@v7
        with:
          name: media-integrity-scanner
          path: ./artifacts/
          retention-days: 30

GitHub Actions: Release

# .github/workflows/release.yml
name: Release Plugin

on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup .NET 9
        uses: actions/setup-dotnet@v6
        with:
          dotnet-version: '9.0.x'

      - name: Restore dependencies
        run: dotnet restore Jellyfin.Plugin.MediaIntegrityScanner/Jellyfin.Plugin.MediaIntegrityScanner.csproj

      - name: Build Release
        run: dotnet publish Jellyfin.Plugin.MediaIntegrityScanner/Jellyfin.Plugin.MediaIntegrityScanner.csproj --configuration Release --output ./publish

      - name: Package
        run: |
          cd publish
          zip -r ../media-integrity-scanner-$.zip .

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: media-integrity-scanner-$.zip
          generate_release_notes: true

      - name: Update manifest.json
        run: |
          python3 scripts/update-manifest.py "$" "media-integrity-scanner-$.zip"
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add manifest.json
          git commit -m "chore: update manifest.json for $"
          # Assumes the tag was created from the current tip of main.
          git push origin HEAD:main

scripts/update-manifest.py normalizes the git tag (v0.2.00.2.0.0), computes the MD5 checksum of the release zip (the convention Jellyfin plugin manifests use), derives targetAbi from the Jellyfin.Controller package reference in the .csproj, and prepends a new version entry to manifest.json — replacing any existing entry for the same version, so re-running for the same tag is idempotent.

Operational Runbook

First-Time Setup

  1. Install plugin via repository or manual DLL
  2. Restart Jellyfin
  3. Navigate to Dashboard → Plugins → Media Integrity Scanner
  4. Configure throttling settings for your storage backend
  5. Optionally enable “Scan on Item Added” for new file validation
  6. Run initial header scan from the dashboard (this may take hours for large libraries)

Handling Failed Files

When files are flagged as failed:

  1. Check the error output in the dashboard detail view
  2. Common failures:
    • "Invalid data found when processing input" → Corrupt container
    • "moov atom not found" → Truncated MP4/MOV
    • "Error while decoding stream" → Corrupt video frames
  3. Attempt repair: ffmpeg -i broken.mkv -c copy repaired.mkv
  4. If repair fails: re-download or re-rip the source
  5. After fix: trigger a rescan from the dashboard

Database Maintenance

The SQLite database grows slowly. Occasional maintenance:

# Location
ls /var/lib/jellyfin/plugins/MediaIntegrityScanner/data/

# Vacuum to reclaim space (while Jellyfin is stopped)
sqlite3 media-integrity.db "VACUUM;"

# Check database integrity
sqlite3 media-integrity.db "PRAGMA integrity_check;"

Upgrading the Plugin

  1. Check the repository for new releases
  2. Jellyfin’s plugin auto-update handles it if installed via repository
  3. For manual installs: replace the DLL files and restart

What’s Next

The plugin is deployed, monitored, and releasing on a real CI/CD pipeline — but v0.1.0 wasn’t the end of the story. The final article covers what production use and real testing turned up afterward: an update checker, session-aware auto-restart, a packaging bug that broke non-Linux installs, and a full future-work list of what’s still being evaluated.

Resources

Series Navigation

  1. Introduction & Problem Statement
  2. Architecture & Design Decisions
  3. Building the Scanner Core
  4. The Dashboard & API
  5. Deployment & Operations (this post)
  6. v0.1.1 Release: Update Checker & Auto-Update
Categories: homelab, media-server

About the Author: Michael McGarrah is a Cloud Architect with 25+ years in enterprise infrastructure, machine learning, and system administration. He holds an M.S. in Computer Science (AI/ML) from Georgia Tech and a B.S. in Computer Science from NC State University, and is currently pursuing an Executive MBA at UNC Wilmington. LinkedIn · Substack · GitHub · ORCID · Google Scholar · Resume