Jellyfin Media Integrity Scanner: Architecture & Design Decisions
· 8 min readThe 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:
- One-off scans before initial library import
- Environments where plugin installation isn’t possible
- Debugging and development (faster iteration than plugin reload)
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
- Phase 1 runs on all files during scheduled scans
- Phase 2 runs on:
- Files that fail Phase 1 (confirm the failure)
- Newly added files (validate before marking as “good”)
- User-triggered deep scans from the dashboard
- Scheduled full-library deep scans (opt-in, runs over days/weeks)
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:
- Saturates network bandwidth between storage nodes
- Fills OSD/NFS server read caches with scan data, evicting hot playback data
- Creates I/O contention that adds latency to concurrent reads
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:
- Inter-file delay — Configurable pause between scanning each file (default: 5 seconds)
- Bandwidth cap — Limit read throughput via ionice or application-level rate limiting
- Playback awareness — Pause scanning when active playback sessions exist
- Concurrency limit — Never scan more than N files simultaneously (default: 1)
- 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:
- MDS (metadata server) load increases with rapid stat() calls during file enumeration
- OSD read-ahead can be wasteful for scanning (we read the whole file regardless)
- Client-side caching of scanned data wastes RAM since we won’t re-read
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
- Zero configuration — single file, no server process
- ACID compliant — safe against crashes during scan
- Embedded — ships with .NET, no external dependency
- Concurrent reads — multiple threads can read while one writes
- Small footprint — scan metadata for 100K files fits in <50MB
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:
- Query Jellyfin for all media items
- For each item, check if
scan_resultshas an entry wherelast_modifiedmatches current file mtime - If mtime matches and previous scan passed → skip
- 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’sscan_phaseto 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:
- ItemAdded — New file imported → queue Phase 1 scan
- ItemUpdated — File replaced/re-encoded → queue Phase 1 scan
- ItemRemoved — File deleted → purge scan_results entry
This keeps the database in sync with the actual library state and ensures new imports are validated promptly.
Update:
ItemUpdatednever actually got a handler — onlyItemAddedandItemRemovedare wired up in the shippedLibraryMonitor. 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:
- Jellyfin’s own configured ffmpeg path (from server config)
PATHenvironment variable lookup- Platform-specific known locations
- 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
- Introduction & Problem Statement
- Architecture & Design Decisions (this post)
- Building the Scanner Core
- The Dashboard & API
- Deployment & Operations
- v0.1.1 Release: Update Checker & Auto-Update