initial commit
This commit is contained in:
commit
f8c8442a6e
651
README.md
Normal file
651
README.md
Normal file
@ -0,0 +1,651 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
BIN
plex_stats.db
Normal file
BIN
plex_stats.db
Normal file
Binary file not shown.
863
plex_stats.py
Normal file
863
plex_stats.py
Normal file
@ -0,0 +1,863 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Plex Media Library Statistics Generator
|
||||||
|
|
||||||
|
Scans a Plex-style media library and generates CSV statistics about movies, TV shows, and anime.
|
||||||
|
Uses SQLite for persistent caching to avoid rescanning unchanged files.
|
||||||
|
|
||||||
|
Author: Media Scraper Tool
|
||||||
|
License: MIT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration constants
|
||||||
|
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.m4v', '.avi', '.mov', '.ts', '.m2ts', '.webm', '.mpg', '.mpeg', '.wmv'}
|
||||||
|
BYTES_PER_GB = 1_000_000_000
|
||||||
|
SECONDS_PER_HOUR = 3600
|
||||||
|
CSV_DECIMAL_PLACES = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaFile:
|
||||||
|
"""Represents a single video file with its metadata."""
|
||||||
|
path: str
|
||||||
|
library_root: str
|
||||||
|
library_type: str # 'tv' or 'movies'
|
||||||
|
show_movie: str
|
||||||
|
season: Optional[str] # None for movies
|
||||||
|
size_bytes: int
|
||||||
|
mtime_ns: int
|
||||||
|
duration_seconds: Optional[float]
|
||||||
|
scan_timestamp: float
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlexStatsDB:
|
||||||
|
"""Manages SQLite database for caching media file metadata."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
"""Initialize database connection and create schema if needed."""
|
||||||
|
self.db_path = db_path
|
||||||
|
self.conn: Optional[sqlite3.Connection] = None
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
"""Initialize database connection and create tables if needed."""
|
||||||
|
self.conn = sqlite3.connect(self.db_path)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
|
# Create media_files table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 indexes for common queries
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_library_root ON media_files(library_root)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_path ON media_files(path)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_show_movie ON media_files(show_movie)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_season ON media_files(season)')
|
||||||
|
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close database connection."""
|
||||||
|
if self.conn:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Context manager entry."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Context manager exit."""
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def get_cached_file(self, path: str) -> Optional[MediaFile]:
|
||||||
|
"""Retrieve a cached media file by path."""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM media_files WHERE path = ?', (path,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return MediaFile(
|
||||||
|
path=row['path'],
|
||||||
|
library_root=row['library_root'],
|
||||||
|
library_type=row['library_type'],
|
||||||
|
show_movie=row['show_movie'],
|
||||||
|
season=row['season'],
|
||||||
|
size_bytes=row['size_bytes'],
|
||||||
|
mtime_ns=row['mtime_ns'],
|
||||||
|
duration_seconds=row['duration_seconds'],
|
||||||
|
scan_timestamp=row['scan_timestamp'],
|
||||||
|
error_message=row['error_message']
|
||||||
|
)
|
||||||
|
|
||||||
|
def insert_or_update_file(self, media_file: MediaFile) -> None:
|
||||||
|
"""Insert or update a media file record."""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR REPLACE INTO media_files
|
||||||
|
(path, library_root, library_type, show_movie, season, size_bytes,
|
||||||
|
mtime_ns, duration_seconds, scan_timestamp, error_message)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
media_file.path,
|
||||||
|
media_file.library_root,
|
||||||
|
media_file.library_type,
|
||||||
|
media_file.show_movie,
|
||||||
|
media_file.season,
|
||||||
|
media_file.size_bytes,
|
||||||
|
media_file.mtime_ns,
|
||||||
|
media_file.duration_seconds,
|
||||||
|
media_file.scan_timestamp,
|
||||||
|
media_file.error_message
|
||||||
|
))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_files_by_library_root(self, library_root: str) -> List[MediaFile]:
|
||||||
|
"""Retrieve all files cached for a specific library root."""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM media_files WHERE library_root = ?', (library_root,))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
MediaFile(
|
||||||
|
path=row['path'],
|
||||||
|
library_root=row['library_root'],
|
||||||
|
library_type=row['library_type'],
|
||||||
|
show_movie=row['show_movie'],
|
||||||
|
season=row['season'],
|
||||||
|
size_bytes=row['size_bytes'],
|
||||||
|
mtime_ns=row['mtime_ns'],
|
||||||
|
duration_seconds=row['duration_seconds'],
|
||||||
|
scan_timestamp=row['scan_timestamp'],
|
||||||
|
error_message=row['error_message']
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_file(self, path: str) -> None:
|
||||||
|
"""Delete a file record from the database."""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM media_files WHERE path = ?', (path,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def check_ffprobe_available() -> bool:
|
||||||
|
"""Check if ffprobe is available in PATH."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['ffprobe', '-version'],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_duration_from_ffprobe(file_path: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Extract duration in seconds from a video file using ffprobe.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Duration in seconds as float, or None if extraction fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
file_path
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
text=True,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
duration_str = result.stdout.strip()
|
||||||
|
if not duration_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return float(duration_str)
|
||||||
|
|
||||||
|
except (subprocess.TimeoutExpired, ValueError, OSError) as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def infer_library_type(library_root: str) -> str:
|
||||||
|
"""
|
||||||
|
Infer library type from directory name.
|
||||||
|
|
||||||
|
Returns 'tv' or 'movies' based on directory name.
|
||||||
|
Defaults to 'tv' if uncertain.
|
||||||
|
"""
|
||||||
|
root_name = Path(library_root).name.lower()
|
||||||
|
if 'movie' in root_name or 'film' in root_name:
|
||||||
|
return 'movies'
|
||||||
|
return 'tv'
|
||||||
|
|
||||||
|
|
||||||
|
def extract_show_movie_name(file_path: str, library_root: str) -> Tuple[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Extract show/movie name and season (if applicable) from file path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Full path to the video file
|
||||||
|
library_root: Root of the library
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (show/movie_name, season_name)
|
||||||
|
season_name is None for movies.
|
||||||
|
"""
|
||||||
|
relative_path = Path(file_path).relative_to(library_root)
|
||||||
|
parts = relative_path.parts
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
# Unusual structure, use filename
|
||||||
|
return file_path.split(os.sep)[-2], None
|
||||||
|
|
||||||
|
show_movie = parts[0]
|
||||||
|
season = parts[1] if len(parts) > 2 else None
|
||||||
|
|
||||||
|
return show_movie, season
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_files_in_library(library_root: Path, library_type: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Recursively find all video files in a library root.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
library_root: Path to library root directory
|
||||||
|
library_type: 'tv' or 'movies'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of absolute paths to video files.
|
||||||
|
"""
|
||||||
|
video_files: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for path in library_root.rglob('*'):
|
||||||
|
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
video_files.append(str(path.resolve()))
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
print(f"Warning: Could not access {library_root}: {e}")
|
||||||
|
|
||||||
|
return video_files
|
||||||
|
|
||||||
|
|
||||||
|
def scan_library(
|
||||||
|
library_root: Path,
|
||||||
|
library_type: str,
|
||||||
|
db: PlexStatsDB,
|
||||||
|
force_rescan: bool = False,
|
||||||
|
verbose: bool = False
|
||||||
|
) -> Tuple[List[MediaFile], Dict[str, int]]:
|
||||||
|
"""
|
||||||
|
Scan a media library and update cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
library_root: Path to library root
|
||||||
|
library_type: 'tv' or 'movies'
|
||||||
|
db: Database instance
|
||||||
|
force_rescan: If True, ffprobe all files regardless of cache
|
||||||
|
verbose: Print verbose output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of MediaFile objects, stats dict)
|
||||||
|
"""
|
||||||
|
library_root_str = str(library_root.resolve())
|
||||||
|
stats = {
|
||||||
|
'discovered': 0,
|
||||||
|
'cached_unchanged': 0,
|
||||||
|
'new': 0,
|
||||||
|
'changed': 0,
|
||||||
|
'removed': 0,
|
||||||
|
'errors': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"\nScanning {library_root_str}...")
|
||||||
|
|
||||||
|
# Get all video files currently in library
|
||||||
|
current_files = get_video_files_in_library(library_root, library_type)
|
||||||
|
current_files_set: Set[str] = set(current_files)
|
||||||
|
stats['discovered'] = len(current_files)
|
||||||
|
|
||||||
|
print(f"Files discovered: {stats['discovered']:,}")
|
||||||
|
|
||||||
|
# Get previously cached files for this library
|
||||||
|
cached_files = db.get_files_by_library_root(library_root_str)
|
||||||
|
cached_by_path: Dict[str, MediaFile] = {f.path: f for f in cached_files}
|
||||||
|
|
||||||
|
# Find removed files
|
||||||
|
for cached_path in cached_by_path:
|
||||||
|
if cached_path not in current_files_set:
|
||||||
|
db.delete_file(cached_path)
|
||||||
|
stats['removed'] += 1
|
||||||
|
|
||||||
|
# Process current files
|
||||||
|
media_files: List[MediaFile] = []
|
||||||
|
files_to_probe: List[str] = []
|
||||||
|
|
||||||
|
for file_path in current_files:
|
||||||
|
try:
|
||||||
|
stat_info = os.stat(file_path)
|
||||||
|
size_bytes = stat_info.st_size
|
||||||
|
mtime_ns = stat_info.st_mtime_ns
|
||||||
|
|
||||||
|
show_movie, season = extract_show_movie_name(file_path, library_root_str)
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cached = cached_by_path.get(file_path)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not force_rescan
|
||||||
|
and cached is not None
|
||||||
|
and cached.size_bytes == size_bytes
|
||||||
|
and cached.mtime_ns == mtime_ns
|
||||||
|
):
|
||||||
|
# File unchanged, reuse cache
|
||||||
|
media_files.append(cached)
|
||||||
|
stats['cached_unchanged'] += 1
|
||||||
|
else:
|
||||||
|
# File is new or changed
|
||||||
|
media_file = MediaFile(
|
||||||
|
path=file_path,
|
||||||
|
library_root=library_root_str,
|
||||||
|
library_type=library_type,
|
||||||
|
show_movie=show_movie,
|
||||||
|
season=season,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
mtime_ns=mtime_ns,
|
||||||
|
duration_seconds=None,
|
||||||
|
scan_timestamp=time.time()
|
||||||
|
)
|
||||||
|
|
||||||
|
if cached is not None:
|
||||||
|
stats['changed'] += 1
|
||||||
|
else:
|
||||||
|
stats['new'] += 1
|
||||||
|
|
||||||
|
files_to_probe.append(file_path)
|
||||||
|
media_files.append(media_file)
|
||||||
|
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
print(f"Warning: Could not access {file_path}: {e}")
|
||||||
|
stats['errors'] += 1
|
||||||
|
|
||||||
|
print(f"Cached/unchanged: {stats['cached_unchanged']:,}")
|
||||||
|
print(f"New: {stats['new']}")
|
||||||
|
print(f"Changed: {stats['changed']}")
|
||||||
|
print(f"Removed: {stats['removed']}")
|
||||||
|
|
||||||
|
# Probe new/changed files
|
||||||
|
if files_to_probe:
|
||||||
|
print(f"\nProbing {len(files_to_probe)} files...")
|
||||||
|
for idx, file_path in enumerate(files_to_probe, 1):
|
||||||
|
# Find the media file object for this path
|
||||||
|
media_file = next((mf for mf in media_files if mf.path == file_path), None)
|
||||||
|
if media_file is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Print progress
|
||||||
|
show_name = media_file.show_movie[:50]
|
||||||
|
print(f" [{idx}/{len(files_to_probe)}] {show_name}...", flush=True)
|
||||||
|
|
||||||
|
# Get duration
|
||||||
|
duration = get_duration_from_ffprobe(file_path)
|
||||||
|
if duration is not None:
|
||||||
|
media_file.duration_seconds = duration
|
||||||
|
else:
|
||||||
|
media_file.error_message = "ffprobe failed or returned no duration"
|
||||||
|
stats['errors'] += 1
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
db.insert_or_update_file(media_file)
|
||||||
|
|
||||||
|
# Insert cached files that weren't probed
|
||||||
|
for media_file in media_files:
|
||||||
|
if media_file.path not in files_to_probe:
|
||||||
|
db.insert_or_update_file(media_file)
|
||||||
|
|
||||||
|
return media_files, stats
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_tv_seasons(media_files: List[MediaFile]) -> Dict[Tuple[str, str], Dict]:
|
||||||
|
"""
|
||||||
|
Aggregate media files by series and season for TV output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_files: List of MediaFile objects
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping (series, season) tuples to aggregated stats
|
||||||
|
"""
|
||||||
|
seasons: Dict[Tuple[str, str], Dict] = {}
|
||||||
|
|
||||||
|
for media_file in media_files:
|
||||||
|
if media_file.season is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (media_file.show_movie, media_file.season)
|
||||||
|
|
||||||
|
if key not in seasons:
|
||||||
|
seasons[key] = {
|
||||||
|
'series': media_file.show_movie,
|
||||||
|
'season': media_file.season,
|
||||||
|
'file_count': 0,
|
||||||
|
'size_bytes': 0,
|
||||||
|
'duration_seconds': 0.0,
|
||||||
|
'error_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seasons[key]['file_count'] += 1
|
||||||
|
seasons[key]['size_bytes'] += media_file.size_bytes
|
||||||
|
|
||||||
|
if media_file.duration_seconds is not None:
|
||||||
|
seasons[key]['duration_seconds'] += media_file.duration_seconds
|
||||||
|
|
||||||
|
if media_file.error_message:
|
||||||
|
seasons[key]['error_count'] += 1
|
||||||
|
|
||||||
|
return seasons
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_tv_series(season_aggregates: Dict[Tuple[str, str], Dict]) -> Dict[str, Dict]:
|
||||||
|
"""
|
||||||
|
Aggregate season data by series for series-level output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season_aggregates: Dict from aggregate_tv_seasons
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping series names to aggregated stats
|
||||||
|
"""
|
||||||
|
series: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
for (series_name, season_name), season_data in season_aggregates.items():
|
||||||
|
if series_name not in series:
|
||||||
|
series[series_name] = {
|
||||||
|
'series': series_name,
|
||||||
|
'season_count': 0,
|
||||||
|
'file_count': 0,
|
||||||
|
'size_bytes': 0,
|
||||||
|
'duration_seconds': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
series[series_name]['season_count'] += 1
|
||||||
|
series[series_name]['file_count'] += season_data['file_count']
|
||||||
|
series[series_name]['size_bytes'] += season_data['size_bytes']
|
||||||
|
series[series_name]['duration_seconds'] += season_data['duration_seconds']
|
||||||
|
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_movies(media_files: List[MediaFile]) -> Dict[str, Dict]:
|
||||||
|
"""
|
||||||
|
Aggregate media files by movie name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_files: List of MediaFile objects (should all be movies)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping movie names to aggregated stats
|
||||||
|
"""
|
||||||
|
movies: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
for media_file in media_files:
|
||||||
|
movie_name = media_file.show_movie
|
||||||
|
|
||||||
|
if movie_name not in movies:
|
||||||
|
movies[movie_name] = {
|
||||||
|
'movie': movie_name,
|
||||||
|
'file_count': 0,
|
||||||
|
'size_bytes': 0,
|
||||||
|
'duration_seconds': 0.0,
|
||||||
|
'error_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
movies[movie_name]['file_count'] += 1
|
||||||
|
movies[movie_name]['size_bytes'] += media_file.size_bytes
|
||||||
|
|
||||||
|
if media_file.duration_seconds is not None:
|
||||||
|
movies[movie_name]['duration_seconds'] += media_file.duration_seconds
|
||||||
|
|
||||||
|
if media_file.error_message:
|
||||||
|
movies[movie_name]['error_count'] += 1
|
||||||
|
|
||||||
|
return movies
|
||||||
|
|
||||||
|
|
||||||
|
def format_number(value: float, decimal_places: int = CSV_DECIMAL_PLACES) -> float:
|
||||||
|
"""Format a number to a specific number of decimal places."""
|
||||||
|
return round(value, decimal_places)
|
||||||
|
|
||||||
|
|
||||||
|
def write_tv_season_csv(
|
||||||
|
season_aggregates: Dict[Tuple[str, str], Dict],
|
||||||
|
output_path: Path,
|
||||||
|
csv_prefix: str
|
||||||
|
) -> None:
|
||||||
|
"""Write season-level TV statistics to CSV."""
|
||||||
|
csv_file = output_path / f"{csv_prefix}_seasons.csv"
|
||||||
|
|
||||||
|
# Sort by series, then season
|
||||||
|
sorted_seasons = sorted(
|
||||||
|
season_aggregates.items(),
|
||||||
|
key=lambda x: (x[0][0], x[0][1]) # (series, season)
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(csv_file, 'w', newline='') as f:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
f,
|
||||||
|
fieldnames=[
|
||||||
|
'Library', 'Series', 'Season', 'File_Count',
|
||||||
|
'Size_Bytes', 'Size_GB', 'Duration_Seconds', 'Duration_Hours', 'GB_Per_Hour'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for (series, season), data in sorted_seasons:
|
||||||
|
size_gb = format_number(data['size_bytes'] / BYTES_PER_GB)
|
||||||
|
duration_hours = format_number(data['duration_seconds'] / SECONDS_PER_HOUR)
|
||||||
|
gb_per_hour = (
|
||||||
|
format_number(size_gb / duration_hours)
|
||||||
|
if duration_hours > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.writerow({
|
||||||
|
'Library': 'tv',
|
||||||
|
'Series': series,
|
||||||
|
'Season': season,
|
||||||
|
'File_Count': data['file_count'],
|
||||||
|
'Size_Bytes': data['size_bytes'],
|
||||||
|
'Size_GB': size_gb,
|
||||||
|
'Duration_Seconds': data['duration_seconds'],
|
||||||
|
'Duration_Hours': duration_hours,
|
||||||
|
'GB_Per_Hour': gb_per_hour
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" {csv_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_tv_series_csv(
|
||||||
|
series_aggregates: Dict[str, Dict],
|
||||||
|
output_path: Path,
|
||||||
|
csv_prefix: str
|
||||||
|
) -> None:
|
||||||
|
"""Write series-level TV statistics to CSV."""
|
||||||
|
csv_file = output_path / f"{csv_prefix}_series.csv"
|
||||||
|
|
||||||
|
# Sort by series name
|
||||||
|
sorted_series = sorted(series_aggregates.items(), key=lambda x: x[0])
|
||||||
|
|
||||||
|
with open(csv_file, 'w', newline='') as f:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
f,
|
||||||
|
fieldnames=[
|
||||||
|
'Library', 'Series', 'Season_Count', 'File_Count',
|
||||||
|
'Size_Bytes', 'Size_GB', 'Duration_Seconds', 'Duration_Hours', 'GB_Per_Hour'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for series_name, data in sorted_series:
|
||||||
|
size_gb = format_number(data['size_bytes'] / BYTES_PER_GB)
|
||||||
|
duration_hours = format_number(data['duration_seconds'] / SECONDS_PER_HOUR)
|
||||||
|
gb_per_hour = (
|
||||||
|
format_number(size_gb / duration_hours)
|
||||||
|
if duration_hours > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.writerow({
|
||||||
|
'Library': 'tv',
|
||||||
|
'Series': series_name,
|
||||||
|
'Season_Count': data['season_count'],
|
||||||
|
'File_Count': data['file_count'],
|
||||||
|
'Size_Bytes': data['size_bytes'],
|
||||||
|
'Size_GB': size_gb,
|
||||||
|
'Duration_Seconds': data['duration_seconds'],
|
||||||
|
'Duration_Hours': duration_hours,
|
||||||
|
'GB_Per_Hour': gb_per_hour
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" {csv_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_movies_csv(
|
||||||
|
movies_aggregates: Dict[str, Dict],
|
||||||
|
output_path: Path,
|
||||||
|
csv_prefix: str
|
||||||
|
) -> None:
|
||||||
|
"""Write movie statistics to CSV."""
|
||||||
|
csv_file = output_path / f"{csv_prefix}_movies.csv"
|
||||||
|
|
||||||
|
# Sort by movie name
|
||||||
|
sorted_movies = sorted(movies_aggregates.items(), key=lambda x: x[0])
|
||||||
|
|
||||||
|
with open(csv_file, 'w', newline='') as f:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
f,
|
||||||
|
fieldnames=[
|
||||||
|
'Library', 'Movie', 'File_Count',
|
||||||
|
'Size_Bytes', 'Size_GB', 'Duration_Seconds', 'Duration_Hours', 'GB_Per_Hour'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for movie_name, data in sorted_movies:
|
||||||
|
size_gb = format_number(data['size_bytes'] / BYTES_PER_GB)
|
||||||
|
duration_hours = format_number(data['duration_seconds'] / SECONDS_PER_HOUR)
|
||||||
|
gb_per_hour = (
|
||||||
|
format_number(size_gb / duration_hours)
|
||||||
|
if duration_hours > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.writerow({
|
||||||
|
'Library': 'movies',
|
||||||
|
'Movie': movie_name,
|
||||||
|
'File_Count': data['file_count'],
|
||||||
|
'Size_Bytes': data['size_bytes'],
|
||||||
|
'Size_GB': size_gb,
|
||||||
|
'Duration_Seconds': data['duration_seconds'],
|
||||||
|
'Duration_Hours': duration_hours,
|
||||||
|
'GB_Per_Hour': gb_per_hour
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" {csv_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_scan_summary(
|
||||||
|
library_type: str,
|
||||||
|
media_files: List[MediaFile],
|
||||||
|
stats: Dict[str, int],
|
||||||
|
season_agg: Optional[Dict[Tuple[str, str], Dict]] = None,
|
||||||
|
series_agg: Optional[Dict[str, Dict]] = None,
|
||||||
|
movie_agg: Optional[Dict[str, Dict]] = None,
|
||||||
|
db_path: Path = None,
|
||||||
|
csv_files: List[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""Print a human-readable summary of the scan."""
|
||||||
|
if csv_files is None:
|
||||||
|
csv_files = []
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
total_size_gb = sum(mf.size_bytes for mf in media_files) / BYTES_PER_GB
|
||||||
|
total_duration_seconds = sum(
|
||||||
|
(mf.duration_seconds or 0) for mf in media_files if mf.duration_seconds
|
||||||
|
)
|
||||||
|
total_duration_hours = total_duration_seconds / SECONDS_PER_HOUR
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Scan Complete")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if library_type == 'tv':
|
||||||
|
unique_series = len(series_agg) if series_agg else 0
|
||||||
|
unique_seasons = len(season_agg) if season_agg else 0
|
||||||
|
print(f"TV series: {unique_series}")
|
||||||
|
print(f"Seasons: {unique_seasons}")
|
||||||
|
else:
|
||||||
|
unique_movies = len(movie_agg) if movie_agg else 0
|
||||||
|
print(f"Movies: {unique_movies}")
|
||||||
|
|
||||||
|
print(f"Video files: {len(media_files):,}")
|
||||||
|
print(f"Total size: {format_number(total_size_gb, 2)} GB")
|
||||||
|
print(f"Total runtime: {format_number(total_duration_hours, 1)} hours")
|
||||||
|
print()
|
||||||
|
print(f"Unchanged: {stats['cached_unchanged']:,}")
|
||||||
|
print(f"New: {stats['new']}")
|
||||||
|
print(f"Changed: {stats['changed']}")
|
||||||
|
print(f"Removed: {stats['removed']}")
|
||||||
|
print(f"Errors: {stats['errors']}")
|
||||||
|
|
||||||
|
if csv_files:
|
||||||
|
print()
|
||||||
|
print("CSV files written:")
|
||||||
|
for csv_file in csv_files:
|
||||||
|
print(f" {csv_file}")
|
||||||
|
|
||||||
|
if db_path:
|
||||||
|
print()
|
||||||
|
print("Database:")
|
||||||
|
print(f" {db_path}")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Generate statistics for Plex-style media libraries.'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'library_root',
|
||||||
|
help='Path to the media library root (e.g., /plex/tv or /plex/movies)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--type',
|
||||||
|
choices=['tv', 'movies'],
|
||||||
|
help='Library type (tv or movies). If omitted, will be inferred from directory name.'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--db',
|
||||||
|
type=Path,
|
||||||
|
default=Path('plex_stats.db'),
|
||||||
|
help='Path to SQLite database (default: plex_stats.db)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-dir',
|
||||||
|
type=Path,
|
||||||
|
default=Path('.'),
|
||||||
|
help='Directory for output CSV files (default: current directory)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--csv-prefix',
|
||||||
|
help='Prefix for CSV filenames (e.g., "tv" -> tv_seasons.csv). '
|
||||||
|
'If omitted, derived from library directory name.'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--force',
|
||||||
|
action='store_true',
|
||||||
|
help='Force ffprobe scan of all files, ignoring cache'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose',
|
||||||
|
action='store_true',
|
||||||
|
help='Print verbose output'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
library_root = Path(args.library_root).resolve()
|
||||||
|
if not library_root.exists():
|
||||||
|
print(f"Error: Library root does not exist: {library_root}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not library_root.is_dir():
|
||||||
|
print(f"Error: Library root is not a directory: {library_root}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Check ffprobe availability
|
||||||
|
if not check_ffprobe_available():
|
||||||
|
print("Error: ffprobe was not found.")
|
||||||
|
print()
|
||||||
|
print("Install FFmpeg, for example on Ubuntu/Debian:")
|
||||||
|
print()
|
||||||
|
print(" sudo apt install ffmpeg")
|
||||||
|
print()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Determine library type
|
||||||
|
library_type = args.type or infer_library_type(str(library_root))
|
||||||
|
|
||||||
|
# Determine CSV prefix
|
||||||
|
csv_prefix = args.csv_prefix or library_root.name
|
||||||
|
|
||||||
|
# Create output directory if needed
|
||||||
|
output_dir = args.output_dir
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Open database
|
||||||
|
with PlexStatsDB(args.db) as db:
|
||||||
|
# Scan library
|
||||||
|
media_files, stats = scan_library(
|
||||||
|
library_root,
|
||||||
|
library_type,
|
||||||
|
db,
|
||||||
|
force_rescan=args.force,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter out files with errors for aggregation
|
||||||
|
valid_files = [mf for mf in media_files if mf.duration_seconds is not None]
|
||||||
|
|
||||||
|
csv_files_written: List[str] = []
|
||||||
|
|
||||||
|
# Generate CSV output
|
||||||
|
print("\nGenerating CSV files...")
|
||||||
|
|
||||||
|
if library_type == 'tv':
|
||||||
|
season_agg = aggregate_tv_seasons(valid_files)
|
||||||
|
series_agg = aggregate_tv_series(season_agg)
|
||||||
|
|
||||||
|
if season_agg:
|
||||||
|
write_tv_season_csv(season_agg, output_dir, csv_prefix)
|
||||||
|
csv_files_written.append(str(output_dir / f"{csv_prefix}_seasons.csv"))
|
||||||
|
|
||||||
|
if series_agg:
|
||||||
|
write_tv_series_csv(series_agg, output_dir, csv_prefix)
|
||||||
|
csv_files_written.append(str(output_dir / f"{csv_prefix}_series.csv"))
|
||||||
|
|
||||||
|
print_scan_summary(
|
||||||
|
library_type,
|
||||||
|
valid_files,
|
||||||
|
stats,
|
||||||
|
season_agg=season_agg,
|
||||||
|
series_agg=series_agg,
|
||||||
|
db_path=args.db,
|
||||||
|
csv_files=csv_files_written
|
||||||
|
)
|
||||||
|
|
||||||
|
else: # movies
|
||||||
|
movie_agg = aggregate_movies(valid_files)
|
||||||
|
|
||||||
|
if movie_agg:
|
||||||
|
write_movies_csv(movie_agg, output_dir, csv_prefix)
|
||||||
|
csv_files_written.append(str(output_dir / f"{csv_prefix}_movies.csv"))
|
||||||
|
|
||||||
|
print_scan_summary(
|
||||||
|
library_type,
|
||||||
|
valid_files,
|
||||||
|
stats,
|
||||||
|
movie_agg=movie_agg,
|
||||||
|
db_path=args.db,
|
||||||
|
csv_files=csv_files_written
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
39
requirements.txt
Normal file
39
requirements.txt
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# plex_stats.py has ZERO external Python dependencies.
|
||||||
|
#
|
||||||
|
# This tool uses only Python standard library:
|
||||||
|
# - argparse
|
||||||
|
# - csv
|
||||||
|
# - pathlib
|
||||||
|
# - sqlite3
|
||||||
|
# - subprocess
|
||||||
|
# - typing
|
||||||
|
#
|
||||||
|
# SYSTEM REQUIREMENTS (not Python packages):
|
||||||
|
# - Python 3.10 or later
|
||||||
|
# - FFmpeg with ffprobe (install via system package manager)
|
||||||
|
# Ubuntu/Debian: sudo apt install ffmpeg
|
||||||
|
# CentOS/RHEL: sudo yum install ffmpeg
|
||||||
|
# macOS: brew install ffmpeg
|
||||||
|
#
|
||||||
|
# To install FFmpeg on various systems:
|
||||||
|
#
|
||||||
|
# Ubuntu/Debian:
|
||||||
|
# sudo apt update && sudo apt install ffmpeg
|
||||||
|
#
|
||||||
|
# CentOS/RHEL:
|
||||||
|
# sudo yum install ffmpeg
|
||||||
|
#
|
||||||
|
# Fedora:
|
||||||
|
# sudo dnf install ffmpeg
|
||||||
|
#
|
||||||
|
# macOS (Homebrew):
|
||||||
|
# brew install ffmpeg
|
||||||
|
#
|
||||||
|
# Alpine Linux:
|
||||||
|
# apk add ffmpeg
|
||||||
|
#
|
||||||
|
# Verify installation:
|
||||||
|
# ffprobe -version
|
||||||
|
#
|
||||||
|
# This file is provided for reference only.
|
||||||
|
# No Python packages need to be installed via pip.
|
||||||
Loading…
x
Reference in New Issue
Block a user