652 lines
17 KiB
Markdown
652 lines
17 KiB
Markdown
# Plex Media Library Statistics Generator
|
||
|
||
A production-quality Python 3 command-line tool for scanning Plex-style media libraries and generating CSV statistics about storage size versus total video duration.
|
||
|
||
## Purpose
|
||
|
||
This tool analyzes media libraries (TV shows, anime, and movies) without requiring Plex itself or the Plex API. It operates directly on the filesystem and generates CSV reports showing:
|
||
|
||
- Storage size (in bytes and GB)
|
||
- Total video duration (in seconds and hours)
|
||
- Storage efficiency (GB per hour of video)
|
||
|
||
The key innovation is **incremental scanning with SQLite caching**: after the first full scan, subsequent scans are fast because unchanged files are never re-probed with FFmpeg.
|
||
|
||
## Features
|
||
|
||
- ✅ Supports TV shows, anime (treated as TV), and movies
|
||
- ✅ Automatic library type detection (override with `--type`)
|
||
- ✅ Persistent SQLite cache with change detection
|
||
- ✅ Fast incremental scans using file size + mtime signatures
|
||
- ✅ Comprehensive error handling (one bad file doesn't break the scan)
|
||
- ✅ Progress output during scanning and probing
|
||
- ✅ CSV output sorted alphabetically by series/movie
|
||
- ✅ Support for multiple libraries in a single database
|
||
- ✅ Per-video metadata stored for future feature expansion
|
||
|
||
## Requirements
|
||
|
||
### System
|
||
|
||
- Linux (though Windows/macOS paths would work with minor adjustments)
|
||
- Python 3.10+
|
||
- FFmpeg with `ffprobe` installed
|
||
|
||
### Python
|
||
|
||
Only Python standard library is used:
|
||
- `argparse` (CLI argument parsing)
|
||
- `pathlib` (filesystem operations)
|
||
- `sqlite3` (caching)
|
||
- `subprocess` (invoking ffprobe)
|
||
- `csv` (output generation)
|
||
|
||
## Installation
|
||
|
||
### Ubuntu / Debian
|
||
|
||
```bash
|
||
# Install FFmpeg
|
||
sudo apt update
|
||
sudo apt install ffmpeg
|
||
|
||
# Clone or download the tool
|
||
cd /path/to/media-scrapper
|
||
|
||
# Make the script executable (optional)
|
||
chmod +x plex_stats.py
|
||
|
||
# Test that ffprobe works
|
||
ffprobe -version
|
||
```
|
||
|
||
### CentOS / RHEL
|
||
|
||
```bash
|
||
sudo yum install ffmpeg
|
||
```
|
||
|
||
### macOS (using Homebrew)
|
||
|
||
```bash
|
||
brew install ffmpeg
|
||
```
|
||
|
||
## Quick Start
|
||
|
||
### Scan a TV library
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --type tv --csv-prefix tv
|
||
```
|
||
|
||
Output:
|
||
- `tv_seasons.csv` — one row per season
|
||
- `tv_series.csv` — one row per complete series
|
||
- `plex_stats.db` — SQLite cache
|
||
|
||
### Scan an anime library
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/anime --type tv --csv-prefix anime
|
||
```
|
||
|
||
TV and anime use the same directory structure and CSV format.
|
||
|
||
### Scan a movies library
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/movies --type movies --csv-prefix movies
|
||
```
|
||
|
||
Output:
|
||
- `movies_movies.csv` — one row per movie folder
|
||
|
||
### Re-scan (fast)
|
||
|
||
Run the same command again. Files that haven't changed will be cached and won't be re-probed:
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --type tv --csv-prefix tv
|
||
```
|
||
|
||
Expected output:
|
||
```
|
||
Scanning /plex/tv...
|
||
|
||
Files discovered: 4,812
|
||
Cached/unchanged: 4,790
|
||
New: 17
|
||
Changed: 5
|
||
Removed: 3
|
||
|
||
Probing 22 files...
|
||
[1/22] Breaking Bad...
|
||
[2/22] Game of Thrones...
|
||
...
|
||
|
||
Scan complete.
|
||
|
||
TV shows: 163
|
||
Seasons: 691
|
||
Video files: 4,809
|
||
Total size: 6.82 TB
|
||
Total runtime: 3,921.4 hours
|
||
|
||
Unchanged: 4,790
|
||
New: 14
|
||
Changed: 5
|
||
Removed: 3
|
||
Errors: 0
|
||
|
||
CSV files written:
|
||
tv_seasons.csv
|
||
tv_series.csv
|
||
|
||
Database:
|
||
plex_stats.db
|
||
```
|
||
|
||
## CLI Options
|
||
|
||
```
|
||
usage: plex_stats.py [-h] [--type {tv,movies}] [--db DB]
|
||
[--output-dir OUTPUT_DIR] [--csv-prefix CSV_PREFIX]
|
||
[--force] [--verbose]
|
||
library_root
|
||
|
||
positional arguments:
|
||
library_root Path to media library root (e.g. /plex/tv)
|
||
|
||
optional arguments:
|
||
-h, --help show help message
|
||
--type {tv,movies} Library type. If omitted, inferred from directory name.
|
||
--db DB Path to SQLite database (default: plex_stats.db)
|
||
--output-dir OUTPUT Directory for CSV files (default: current directory)
|
||
--csv-prefix PREFIX Prefix for CSV filenames (default: derived from root name)
|
||
--force Force ffprobe scan of all files (ignore cache)
|
||
--verbose Print verbose output
|
||
```
|
||
|
||
## Usage Examples
|
||
|
||
### Basic single-library scan
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv
|
||
```
|
||
|
||
Infers type as "tv", creates `plex_stats.db` in current directory, outputs `tv_seasons.csv` and `tv_series.csv`.
|
||
|
||
### All three libraries with explicit prefixes
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --type tv --csv-prefix tv
|
||
python3 plex_stats.py /plex/anime --type tv --csv-prefix anime
|
||
python3 plex_stats.py /plex/movies --type movies --csv-prefix movies
|
||
```
|
||
|
||
All use the same `plex_stats.db`, creating six CSV files:
|
||
- `tv_seasons.csv`, `tv_series.csv`
|
||
- `anime_seasons.csv`, `anime_series.csv`
|
||
- `movies_movies.csv`
|
||
|
||
### Centralized database and output directory
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv \
|
||
--type tv \
|
||
--db /var/lib/plex-stats/plex_stats.db \
|
||
--output-dir /home/user/plex-reports \
|
||
--csv-prefix tv
|
||
```
|
||
|
||
### Force complete re-scan (e.g., after fixing bad ffprobe issues)
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --force
|
||
```
|
||
|
||
Every file is re-probed. Useful if you suspect cache corruption or want to update all durations.
|
||
|
||
### Verbose output
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --verbose
|
||
```
|
||
|
||
## Directory Structure
|
||
|
||
### TV / Anime
|
||
|
||
```
|
||
/plex/tv/
|
||
├── Breaking Bad/ <- Series name (first directory level)
|
||
│ ├── Season 01/ <- Season folder (second level)
|
||
│ │ ├── Breaking Bad - S01E01.mkv
|
||
│ │ ├── Breaking Bad - S01E02.mkv
|
||
│ │ └── ...
|
||
│ ├── Season 02/
|
||
│ │ └── ...
|
||
│ └── Specials/ <- Any season name is recognized
|
||
├── Game of Thrones/
|
||
│ ├── Season 01/
|
||
│ │ └── ...
|
||
│ └── Season 02/
|
||
│ └── ...
|
||
```
|
||
|
||
**Season folder names are flexible:**
|
||
- `Season 01`, `Season 1`
|
||
- `Season 00` (for pilots/specials)
|
||
- `Specials`, `Special`
|
||
- `S01`, `S1`
|
||
|
||
The actual folder name found on disk is preserved in the CSV output.
|
||
|
||
### Movies
|
||
|
||
```
|
||
/plex/movies/
|
||
├── Dune (2021)/ <- Movie name (first directory level)
|
||
│ └── Dune (2021).mkv
|
||
├── Interstellar (2014)/
|
||
│ ├── Interstellar (2014).mkv
|
||
│ └── Interstellar (2014) - Extras.mkv <- Multiple files per movie are aggregated
|
||
```
|
||
|
||
## Recognized Video Extensions
|
||
|
||
The tool recognizes these file extensions (case-insensitive):
|
||
|
||
- `.mkv`, `.mp4`, `.m4v`, `.avi`, `.mov`
|
||
- `.ts`, `.m2ts`, `.webm`
|
||
- `.mpg`, `.mpeg`, `.wmv`
|
||
|
||
Other files (subtitles, `.srt`, `.sub`, `.idx`, `.nfo`, images, metadata) are ignored.
|
||
|
||
## CSV Output Format
|
||
|
||
### TV Seasons (`*_seasons.csv`)
|
||
|
||
One row per season of a TV series:
|
||
|
||
```csv
|
||
Library,Series,Season,File_Count,Size_Bytes,Size_GB,Duration_Seconds,Duration_Hours,GB_Per_Hour
|
||
tv,Breaking Bad,Season 01,7,13786845020,12.84,20952,5.82,2.21
|
||
tv,Breaking Bad,Season 02,13,25887942819,24.11,38628,10.73,2.25
|
||
tv,Breaking Bad,Season 03,13,26943589405,25.10,38628,10.73,2.34
|
||
tv,Game of Thrones,Season 01,10,47129348901,43.91,41932,11.65,3.77
|
||
tv,Game of Thrones,Season 02,10,45219374812,42.13,41932,11.65,3.61
|
||
```
|
||
|
||
- **Library**: Always "tv" for TV/anime
|
||
- **Series**: Show name
|
||
- **Season**: Season folder name as found on disk
|
||
- **File_Count**: Number of video files in this season
|
||
- **Size_Bytes**: Total bytes (numeric, for calculation)
|
||
- **Size_GB**: Decimal gigabytes (1 GB = 1,000,000,000 bytes)
|
||
- **Duration_Seconds**: Total duration in seconds
|
||
- **Duration_Hours**: Total duration in hours
|
||
- **GB_Per_Hour**: Storage efficiency (size / duration)
|
||
|
||
### TV Series (`*_series.csv`)
|
||
|
||
One row per complete series, summing all seasons:
|
||
|
||
```csv
|
||
Library,Series,Season_Count,File_Count,Size_Bytes,Size_GB,Duration_Seconds,Duration_Hours,GB_Per_Hour
|
||
tv,Breaking Bad,5,62,128608176245,119.88,191500,53.19,2.25
|
||
tv,Game of Thrones,8,80,434328947123,404.56,334656,92.96,4.35
|
||
```
|
||
|
||
- **Season_Count**: Number of seasons in the series
|
||
- Other fields aggregate across all seasons
|
||
|
||
### Movies (`*_movies.csv`)
|
||
|
||
One row per movie:
|
||
|
||
```csv
|
||
Library,Movie,File_Count,Size_Bytes,Size_GB,Duration_Seconds,Duration_Hours,GB_Per_Hour
|
||
movies,Dune (2021),1,4829348901,4.50,10680,2.97,1.51
|
||
movies,Interstellar (2014),2,6234919234,5.81,14400,4.00,1.45
|
||
```
|
||
|
||
- **Movie**: Movie folder name
|
||
- **File_Count**: Number of video files under the movie folder (includes extras)
|
||
- Other fields aggregate all files
|
||
|
||
### CSV Import to Google Sheets
|
||
|
||
All numeric columns remain numeric (not formatted as strings), so they import cleanly into Google Sheets:
|
||
|
||
1. Open Google Sheets
|
||
2. Click "File" > "Import" > "Upload"
|
||
3. Select the CSV file
|
||
4. Choose "Replace spreadsheet"
|
||
5. Numeric columns are automatically detected
|
||
|
||
## SQLite Cache Database
|
||
|
||
### Schema
|
||
|
||
The tool automatically creates and maintains a SQLite database with this schema:
|
||
|
||
```sql
|
||
CREATE TABLE media_files (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
path TEXT UNIQUE NOT NULL,
|
||
library_root TEXT NOT NULL,
|
||
library_type TEXT NOT NULL,
|
||
show_movie TEXT NOT NULL,
|
||
season TEXT,
|
||
size_bytes INTEGER NOT NULL,
|
||
mtime_ns INTEGER NOT NULL,
|
||
duration_seconds REAL,
|
||
scan_timestamp REAL NOT NULL,
|
||
error_message TEXT
|
||
);
|
||
|
||
CREATE INDEX idx_library_root ON media_files(library_root);
|
||
CREATE INDEX idx_path ON media_files(path);
|
||
CREATE INDEX idx_show_movie ON media_files(show_movie);
|
||
CREATE INDEX idx_season ON media_files(season);
|
||
```
|
||
|
||
### Per-Video Storage
|
||
|
||
Every video file is stored individually, not just aggregated season/movie totals. This enables future features like:
|
||
|
||
- Per-episode statistics
|
||
- Codec and resolution analysis
|
||
- Average file size per series
|
||
- Duplicate detection
|
||
- Video quality metrics
|
||
|
||
## Change Detection & Incremental Scanning
|
||
|
||
### How It Works
|
||
|
||
1. **File discovery**: Walk the filesystem and list all video files.
|
||
2. **Stat check**: For each file, read `stat()` to get size and modification time.
|
||
3. **Cache lookup**: Check SQLite for this file's cached entry.
|
||
4. **Decision logic**:
|
||
- If cached entry exists **AND** size and mtime match → **Reuse cache** (no ffprobe)
|
||
- If cached entry exists **BUT** size or mtime differs → **Re-probe** (file changed)
|
||
- If no cached entry → **Probe** (new file)
|
||
- If cached entry is in SQLite **BUT** file no longer exists → **Delete from cache**
|
||
|
||
### Why Size + mtime?
|
||
|
||
- **Size**: Detects if video content changed
|
||
- **mtime (modification time)**: Detects if metadata or the file was touched
|
||
- **Together**: Extremely fast check without hashing or reading file content
|
||
- **Safe**: Works reliably for normal media workflows (copying files, minor edits)
|
||
|
||
### Limitations
|
||
|
||
If you manually edit file bytes without updating mtime (unusual), the cache will not detect it. This is acceptable for media libraries.
|
||
|
||
## Handling Unchanged, New, and Changed Files
|
||
|
||
### Unchanged
|
||
```
|
||
ffprobe not called → instant retrieval from cache
|
||
```
|
||
|
||
### New
|
||
```
|
||
File doesn't exist in cache → ffprobe called → database entry created
|
||
```
|
||
|
||
### Changed
|
||
```
|
||
File exists but size_bytes or mtime_ns differs → ffprobe called → database entry updated
|
||
```
|
||
|
||
### Removed
|
||
```
|
||
Cached file path no longer exists → removed from database
|
||
Note: Only scanned library root is checked.
|
||
Other libraries' files are not affected.
|
||
```
|
||
|
||
## Forced Rescans
|
||
|
||
Use `--force` to ignore the cache and re-probe every file:
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --force
|
||
```
|
||
|
||
This is useful if:
|
||
- You suspect cache corruption
|
||
- FFmpeg was updated and you want fresh probes
|
||
- You want to update all durations to the latest ffprobe version
|
||
|
||
Forced rescans still respect the database schema and update all cached entries.
|
||
|
||
## Error Handling
|
||
|
||
### FFprobe Failures
|
||
|
||
If ffprobe fails on a specific file:
|
||
- A warning is printed to console
|
||
- The error is recorded in the database (`error_message` column)
|
||
- The scan continues with other files
|
||
- The file is eligible for retry on future scans
|
||
|
||
### What's Not an Error
|
||
|
||
- Permission denied reading a file → warning, continue
|
||
- Corrupted video file → ffprobe fails, recorded, continue
|
||
- Network timeouts on NFS → handled, continue
|
||
|
||
### Error Summary
|
||
|
||
At scan completion, total error count is displayed:
|
||
|
||
```
|
||
Errors: 5
|
||
```
|
||
|
||
To see details, query the database:
|
||
|
||
```bash
|
||
sqlite3 plex_stats.db "SELECT path, error_message FROM media_files WHERE error_message IS NOT NULL;"
|
||
```
|
||
|
||
## Database Management
|
||
|
||
### View cached files for a library
|
||
|
||
```bash
|
||
sqlite3 plex_stats.db "SELECT COUNT(*) FROM media_files WHERE library_root = '/plex/tv';"
|
||
```
|
||
|
||
### Export all files for a series
|
||
|
||
```bash
|
||
sqlite3 plex_stats.db "SELECT path, size_bytes, duration_seconds FROM media_files WHERE show_movie = 'Breaking Bad';"
|
||
```
|
||
|
||
### Clear all cache for one library
|
||
|
||
```bash
|
||
sqlite3 plex_stats.db "DELETE FROM media_files WHERE library_root = '/plex/tv';"
|
||
sqlite3 plex_stats.db "VACUUM;" # Reclaim disk space
|
||
```
|
||
|
||
### Clear entire database
|
||
|
||
```bash
|
||
rm plex_stats.db
|
||
```
|
||
|
||
The tool will recreate it on the next run.
|
||
|
||
## Performance Tips
|
||
|
||
1. **First scan is slow**: Thousands of ffprobe calls can take 10–30 minutes for large libraries.
|
||
2. **Subsequent scans are fast**: Usually under a minute if only a few files changed.
|
||
3. **Use `--force` sparingly**: Only when you have a specific reason.
|
||
4. **Database is small**: Even for 10,000+ files, SQLite database is typically < 1 MB.
|
||
5. **CSV generation is instant**: All heavy lifting is in the scanning phase.
|
||
|
||
### Estimated Timeline
|
||
|
||
- 1,000 files: 3–10 minutes (first scan), 10–30 seconds (subsequent)
|
||
- 5,000 files: 15–50 minutes (first scan), 30–60 seconds (subsequent)
|
||
- 10,000 files: 30–90 minutes (first scan), 1–2 minutes (subsequent)
|
||
|
||
Varies based on filesystem speed, network latency (NFS/SMB), and average file size.
|
||
|
||
## Multiple Libraries in One Database
|
||
|
||
All three examples below use the same `plex_stats.db`:
|
||
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --type tv --csv-prefix tv
|
||
python3 plex_stats.py /plex/anime --type tv --csv-prefix anime
|
||
python3 plex_stats.py /plex/movies --type movies --csv-prefix movies
|
||
```
|
||
|
||
**Database records are scoped by `library_root`**, so:
|
||
- Scanning `/plex/tv` only touches records where `library_root = '/plex/tv'`
|
||
- Deletion detection only removes files that belong to that library
|
||
- Each library can be scanned independently
|
||
|
||
This allows a single CSV output directory with files from multiple sources:
|
||
```
|
||
tv_seasons.csv (from /plex/tv)
|
||
tv_series.csv (from /plex/tv)
|
||
anime_seasons.csv (from /plex/anime)
|
||
anime_series.csv (from /plex/anime)
|
||
movies_movies.csv (from /plex/movies)
|
||
```
|
||
|
||
## Troubleshooting
|
||
|
||
### ffprobe not found
|
||
|
||
```
|
||
Error: ffprobe was not found.
|
||
|
||
Install FFmpeg, for example on Ubuntu/Debian:
|
||
|
||
sudo apt install ffmpeg
|
||
```
|
||
|
||
**Solution**: Install FFmpeg as shown in the error message.
|
||
|
||
### Permission denied scanning directory
|
||
|
||
```
|
||
Warning: Could not access /plex/tv: Permission denied
|
||
```
|
||
|
||
**Solution**: Run with appropriate permissions:
|
||
```bash
|
||
sudo python3 plex_stats.py /plex/tv
|
||
```
|
||
|
||
### Database locked
|
||
|
||
```
|
||
sqlite3.OperationalError: database is locked
|
||
```
|
||
|
||
**Solution**: Ensure only one instance of the tool is running. If multiple processes try to write simultaneously, wait for the first to finish.
|
||
|
||
### CSV files not created
|
||
|
||
Check that the output directory exists and is writable:
|
||
```bash
|
||
python3 plex_stats.py /plex/tv --output-dir /tmp
|
||
```
|
||
|
||
### Strange results in CSV
|
||
|
||
1. Verify the directory structure matches TV or movie format
|
||
2. Check for videos in unexpected locations
|
||
3. Re-scan with `--force` to regenerate all probes
|
||
|
||
## Extending the Tool
|
||
|
||
The database stores per-video metadata, so future enhancements are straightforward:
|
||
|
||
### Adding codec analysis
|
||
```python
|
||
# Expand ffprobe query to include codec
|
||
ffprobe ... -show_entries stream=codec_name
|
||
# Add columns: video_codec, audio_codec
|
||
# Aggregate in CSV generation
|
||
```
|
||
|
||
### Adding resolution tracking
|
||
```python
|
||
# Query video stream height
|
||
ffprobe ... -show_entries stream=height
|
||
# Add columns: resolution, resolution_count
|
||
```
|
||
|
||
### Per-episode breakdown
|
||
```python
|
||
# Parse episode numbers from filenames
|
||
# Create *_episodes.csv with per-episode stats
|
||
```
|
||
|
||
All of this is possible without redesigning the cache because per-video data is preserved.
|
||
|
||
## License
|
||
|
||
MIT License. Use freely.
|
||
|
||
## Support
|
||
|
||
For issues or feature requests, consult the source code and modify as needed. The tool is designed to be maintainable and extensible.
|
||
|
||
---
|
||
|
||
**Example complete workflow:**
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
|
||
# Install FFmpeg (first time only)
|
||
sudo apt install ffmpeg
|
||
|
||
# Create output directory
|
||
mkdir -p ~/plex-reports
|
||
|
||
# Scan TV library
|
||
python3 plex_stats.py /plex/tv \
|
||
--type tv \
|
||
--db ~/plex-stats.db \
|
||
--output-dir ~/plex-reports \
|
||
--csv-prefix tv
|
||
|
||
# Scan anime library
|
||
python3 plex_stats.py /plex/anime \
|
||
--type tv \
|
||
--db ~/plex-stats.db \
|
||
--output-dir ~/plex-reports \
|
||
--csv-prefix anime
|
||
|
||
# Scan movies library
|
||
python3 plex_stats.py /plex/movies \
|
||
--type movies \
|
||
--db ~/plex-stats.db \
|
||
--output-dir ~/plex-reports \
|
||
--csv-prefix movies
|
||
|
||
# View results
|
||
ls -lh ~/plex-reports/*.csv
|
||
|
||
# Import into Google Sheets:
|
||
# - Open Google Sheets
|
||
# - File > Import
|
||
# - Upload ~/plex-reports/tv_seasons.csv
|
||
```
|