McGarrah Technical Blog

Jellyfin Media Integrity Scanner: Architecture & Design Decisions

· 8 min read

The first article introduced the problem: media files rot silently, and Jellyfin doesn’t validate stream integrity. This article covers the architectural decisions that shape the plugin’s design — the tradeoffs, constraints, and reasoning behind each choice.

This is Part 2 of the Jellyfin Media Integrity Scanner development series.

Decision 1: Plugin vs. Standalone Script

The first major decision: should this be a Jellyfin plugin (C#/.NET, runs inside the server process) or an external script (bash/Python, runs independently)?

Why a Plugin Wins

Factor Plugin Script
Library awareness Direct access to Jellyfin’s item database Must query API or parse filesystem
Event hooks Subscribes to ItemAdded/Removed events Requires polling or webhooks
Admin UI Native dashboard integration Separate web interface needed
Configuration Jellyfin’s plugin config system Separate config file
Scheduling Jellyfin’s scheduled task system External cron/systemd timer
User visibility Shows in Plugins page Invisible to Jellyfin admins
State management Plugin lifecycle managed by Jellyfin Must handle own process lifecycle

Why Scripts Still Have a Role

Scripts remain useful for:

The architecture supports both: the core scanning logic wraps ffmpeg in a way that could be extracted into a standalone CLI tool later.

Decision 2: Two-Phase Scanning Strategy

Not all integrity checks are equal in cost. A full byte-stream decode of a 4K Blu-ray remux can take 10-15 minutes and read 50+ GB. Running that against every file on every scan is impractical.

Phase 1: Fast Header & Metadata Check

Cost: ~100ms per file
What it catches: Truncated files, corrupt containers, missing streams, invalid codecs

ffprobe -v error -show_entries format=duration,size,bit_rate \
  -show_entries stream=codec_type,codec_name,width,height \
  -of json "input.mkv"

If ffprobe returns errors or can’t parse the container, the file is immediately flagged.

Phase 2: Full Byte-Stream Decode

Cost: Minutes per file (proportional to file size)
What it catches: Corrupt frames, audio glitches, mid-file corruption, encoding errors

ffmpeg -v error -i "input.mkv" -f null - 2>&1

This decodes every frame without producing output. Any decode errors are captured from stderr.

When to Use Each Phase

Decision 3: I/O Throttling Model

The most critical design constraint: scanning must never degrade playback.

The Problem with Unthrottled Scanning

A naive scan reads files sequentially at maximum disk speed. On shared storage (CephFS, NFS), this:

The Throttling Approach

┌─────────────────────────────────────┐
│        Throttle Configuration       │
├─────────────────────────────────────┤
│  MaxConcurrentScans: 1              │
│  DelayBetweenFiles: 5000ms          │
│  MaxBytesPerSecond: 10MB/s          │
│  PauseDuringPlayback: true          │
│  ActiveHoursOnly: false             │
│  QuietHoursStart: 02:00             │
│  QuietHoursEnd: 06:00               │
└─────────────────────────────────────┘

Key throttling mechanisms:

  1. Inter-file delay — Configurable pause between scanning each file (default: 5 seconds)
  2. Bandwidth cap — Limit read throughput via ionice or application-level rate limiting
  3. Playback awareness — Pause scanning when active playback sessions exist
  4. Concurrency limit — Never scan more than N files simultaneously (default: 1)
  5. Time-of-day scheduling — Optional restriction to quiet hours

CephFS-Specific Considerations

CephFS distributes data across OSDs. Sequential reads from scanning spread across the cluster, but:

The plugin uses posix_fadvise(FADV_SEQUENTIAL | FADV_DONTNEED) semantics (via ffmpeg’s I/O behavior) to hint that scanned data shouldn’t be cached.

Decision 4: SQLite for Persistent State

Why SQLite

Schema Design

CREATE TABLE scan_results (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id TEXT NOT NULL,           -- Jellyfin item GUID
    file_path TEXT NOT NULL,
    file_size INTEGER,
    last_modified TEXT,              -- File mtime at scan time
    scan_phase INTEGER NOT NULL,     -- 1 = header, 2 = full decode
    scan_status INTEGER NOT NULL,    -- 0 = pending, 1 = pass, 2 = fail, 3 = error
    scan_timestamp TEXT NOT NULL,
    error_output TEXT,               -- ffmpeg/ffprobe stderr on failure
    scan_duration_ms INTEGER,
    UNIQUE(item_id, scan_phase)
);

CREATE TABLE scan_config (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL
);

CREATE INDEX idx_scan_results_status ON scan_results(scan_status);
CREATE INDEX idx_scan_results_item ON scan_results(item_id);
CREATE INDEX idx_scan_results_timestamp ON scan_results(scan_timestamp);

Incremental Scanning Logic

On each scheduled scan:

  1. Query Jellyfin for all media items
  2. For each item, check if scan_results has an entry where last_modified matches current file mtime
  3. If mtime matches and previous scan passed → skip
  4. If mtime differs or no entry exists → queue for scan

This means only new or modified files get scanned on subsequent runs.

Update: step 3 as originally implemented didn’t account for scan_phase — it skipped an item if any passing record existed with a matching mtime, regardless of whether that pass came from the quick Header check or the full Phase 2 decode. Since files are almost always Header-scanned first, this meant the Phase 2 deep scan would treat a Header-only pass as “current” and skip it forever. The fix requires the passing record’s scan_phase to be at or above the phase being requested. See the scanner core article for the full story.

Decision 5: Event-Driven Library Monitoring

Rather than relying solely on scheduled scans, the plugin hooks into Jellyfin’s library events:

This keeps the database in sync with the actual library state and ensures new imports are validated promptly.

Update: ItemUpdated never actually got a handler — only ItemAdded and ItemRemoved are wired up in the shipped LibraryMonitor. A re-encoded file currently only gets re-scanned once its mtime no longer matches the stored record, via the incremental-scanning check above, not immediately on replacement.

Library events are only two of six things that can put a file in front of the scanner. Scheduled tasks and the dashboard/API reach into the same engine, and all of them land in the same currency check before anything runs:

%%{init: {"theme": "base", "themeVariables": {
  "primaryColor": "#17221f",
  "primaryTextColor": "#e7ede9",
  "primaryBorderColor": "#3e6e67",
  "lineColor": "#7c93a3",
  "secondaryColor": "#1d2b27",
  "tertiaryColor": "#101a18",
  "fontFamily": "monospace",
  "fontSize": "14px"
}}}%%
flowchart TD
    IA["ItemAdded<br/>library event"]:::ev
    IR["ItemRemoved<br/>library event"]:::ev
    HS["HeaderScanTask<br/>daily, 03:00"]:::ev
    DS["DeepScanTask<br/>Sunday, 01:00"]:::ev
    API["POST /MediaIntegrity/Scan<br/>dashboard or API"]:::ev
    CAN["POST /MediaIntegrity/Cancel<br/>dashboard or API"]:::ev

    IA -->|"ScanOnItemAdded"| SIA1["ScanItemAsync<br/>Header · this file"]:::act
    IR -->|"PurgeOnItemRemoved"| PURGE["PurgeItemAsync"]:::act

    HS --> LOOP1{"IsCurrentAsync<br/>at Header?"}:::gate
    DS -->|"only if EnableDeepScan"| LOOP2{"IsCurrentAsync<br/>at FullDecode?"}:::gate

    LOOP1 -->|"current"| SKIP1["file untouched"]:::stop
    LOOP1 -->|"stale"| SIA1
    LOOP2 -->|"current"| SKIP2["file untouched"]:::stop
    LOOP2 -->|"stale"| SIA2["ScanItemAsync<br/>FullDecode · this file"]:::act

    API --> BUSY{"IsScanning?"}:::gate
    BUSY -->|"yes → 409"| REJECT["request refused,<br/>nothing changes"]:::stop
    BUSY -->|"no → 202"| SCOPE{"itemId given?"}:::gate
    SCOPE -->|"yes — skips the<br/>currency check"| SIA3["ScanItemAsync<br/>forced phase · one file"]:::act
    SCOPE -->|"no"| SLA["ScanLibraryAsync<br/>checks IsCurrentAsync per item"]:::act

    CAN -.->|"cancellation token"| SIA1
    CAN -.-> SIA2
    CAN -.-> SIA3
    CAN -.-> SLA

    SIA1 --> GATES(["gate pipeline — see the<br/>scanner core article"]):::pipe
    SIA2 --> GATES
    SIA3 --> GATES
    SLA --> GATES

    classDef ev fill:#1d2b27,stroke:#3e6e67,color:#e7ede9
    classDef gate fill:#2a2013,stroke:#e3a857,color:#f4d9a8
    classDef act fill:#16332c,stroke:#5fa88f,color:#cfefe2
    classDef stop fill:#331c1a,stroke:#d96c5d,color:#f3c8c2
    classDef pipe fill:#101a18,stroke:#e3a857,color:#e3a857,stroke-width:2px

The one asymmetry worth keeping in mind: an itemId-scoped API call skips the currency check entirely — it’s the only way to force a re-scan of a file the scheduled tasks would otherwise wave through as “already handled.”

Decision 6: Cross-Platform FFmpeg Resolution

Jellyfin runs on Linux, Windows, and macOS. FFmpeg’s binary location varies:

Platform Common Paths
Linux (apt) /usr/bin/ffmpeg, /usr/bin/ffprobe
Linux (Jellyfin bundle) /usr/lib/jellyfin-ffmpeg/ffmpeg
Windows C:\ProgramData\Jellyfin\Server\ffmpeg.exe
macOS (brew) /opt/homebrew/bin/ffmpeg
Docker /usr/lib/jellyfin-ffmpeg/ffmpeg

The plugin resolves ffmpeg using:

  1. Jellyfin’s own configured ffmpeg path (from server config)
  2. PATH environment variable lookup
  3. Platform-specific known locations
  4. User-configurable override in plugin settings

What’s Next

The next article implements the scanner core: the .NET 9 plugin structure, ffmpeg process management, the bounded task queue, and cross-platform path resolution.

Series Navigation

  1. Introduction & Problem Statement
  2. Architecture & Design Decisions (this post)
  3. Building the Scanner Core
  4. The Dashboard & API
  5. Deployment & Operations
  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