From f8c8442a6e8974a8c1941380e423c95b2b16d9b1 Mon Sep 17 00:00:00 2001 From: TylerCG <117808427+TylerCG@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:38:30 -0400 Subject: [PATCH] initial commit --- README.md | 651 +++++++++++++++++++++++++++++++++++ plex_stats.db | Bin 0 -> 32768 bytes plex_stats.py | 863 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 39 +++ 4 files changed, 1553 insertions(+) create mode 100644 README.md create mode 100644 plex_stats.db create mode 100644 plex_stats.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..41d851f --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/plex_stats.db b/plex_stats.db new file mode 100644 index 0000000000000000000000000000000000000000..a7a3121988dab2fed2b39b7556223218966a70c1 GIT binary patch literal 32768 zcmeI%&ui0Q9LMowThlRDwu6v^2#@5luqlXmU)3PRF5PMh8y-TsW-hQMTaxK2cn}1^ zKSTc#{~&K3Jo+@-vaE5=Q*wC+O7k?|=kpD({D6nP-suLZFAk!3?4{y~vZ|=6vMGe3 zC=K~pl%LCOI$pRO$Zxfk_EI#jBZO0NF`-QbDu1ZAD z7KO64^NHpgP18+7om>STM(^Em^e*rV)y;BfURDaukC>f?rr$SIFAqQV(!)ZmR*Ib# z%mz+>GlvUJ{h^_LQuC~%U>JMx#EqjUEo3%InZ?4{?BX$VdtTGm*VVn$8%oHNBe}Yg zz9*-CxIa5wxai`vpLH!UQ@a*=V}C==G(X={o4R4BUp{7;h0`qT&RxqY91v?uVrC0Q z^1?dSORF!o`<<dj08+ z00IagfB*srAb 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0b3f4f0 --- /dev/null +++ b/requirements.txt @@ -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.