Add initial project files for Jellyfin-Plex Library Checker
- Create .editorconfig for consistent coding styles. - Add .envrc for direnv integration. - Include .gitignore to exclude environment and build files. - Implement compare_movies.py and analyze_movies.py for movie library comparison and analysis. - Implement compare_series.py and analyze_series.py for TV series library comparison and analysis. - Add configuration example in config.example.txt. - Create README.md with project overview, setup instructions, and usage examples. - Add LICENSE file for MIT License. - Include flake.nix and flake.lock for Nix-based development environment. - Add USAGE.md for quick start guide and common commands.
This commit is contained in:
23
.editorconfig
Normal file
23
.editorconfig
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# EditorConfig helps maintain consistent coding styles
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yml,yaml,json}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.nix]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Reports (generated by scripts)
|
||||||
|
*_comparison_report.json
|
||||||
|
|
||||||
|
# Direnv
|
||||||
|
.direnv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 jawz
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
219
README.md
Normal file
219
README.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Jellyfin-Plex Library Checker
|
||||||
|
|
||||||
|
A comprehensive tool to compare and analyze media libraries between Plex, Jellyfin, and your filesystem. Identifies missing content, mismatches, and provides detailed analysis to help maintain consistency across your media servers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎬 **Movie Library Comparison** - Compare movies across Plex, Jellyfin, and filesystem
|
||||||
|
- 📺 **TV Series Library Comparison** - Compare TV shows with episode counts
|
||||||
|
- 🔍 **Detailed Analysis** - Identify patterns in missing content (codecs, resolution, naming, etc.)
|
||||||
|
- 🚫 **.plexignore Detection** - Automatically checks for exclusion files
|
||||||
|
- 📊 **Comprehensive Reports** - JSON and human-readable reports
|
||||||
|
- 🔧 **Multiple Path Support** - Handle libraries across multiple directories
|
||||||
|
- 🎯 **Smart Matching** - Aggressive normalization handles title variations, years, tags, and language differences
|
||||||
|
|
||||||
|
## The Problem This Solves
|
||||||
|
|
||||||
|
When running both Plex and Jellyfin on the same media library, you might notice:
|
||||||
|
- Some movies show in Jellyfin but not in Plex
|
||||||
|
- Inconsistent episode counts between servers
|
||||||
|
- Difficulty tracking down which files aren't being scanned
|
||||||
|
|
||||||
|
This tool helps you quickly identify and diagnose these issues by:
|
||||||
|
- Comparing what each server sees vs. what's actually on disk
|
||||||
|
- Analyzing codec, resolution, and metadata issues
|
||||||
|
- Detecting `.plexignore` files or other exclusions
|
||||||
|
- Finding orphaned entries in your databases
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Option 1: Using Nix (Recommended)
|
||||||
|
|
||||||
|
If you have Nix with Flakes enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
This will automatically:
|
||||||
|
- Set up a Python environment
|
||||||
|
- Create a virtual environment
|
||||||
|
- Install all dependencies from `requirements.txt`
|
||||||
|
|
||||||
|
### Option 2: Using pip
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Create a `.env` file in the project root (use `config.example.txt` as a template):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plex Configuration
|
||||||
|
PLEX_URL=http://localhost:32400
|
||||||
|
PLEX_TOKEN=your_plex_token_here
|
||||||
|
|
||||||
|
# Jellyfin Configuration
|
||||||
|
JELLYFIN_URL=http://localhost:8096
|
||||||
|
JELLYFIN_API_KEY=your_jellyfin_api_key_here
|
||||||
|
JELLYFIN_USER_ID=your_jellyfin_user_id_here
|
||||||
|
|
||||||
|
# Filesystem Configuration
|
||||||
|
MOVIES_PATH=/path/to/your/movies/directory
|
||||||
|
SERIES_PATH=/path/to/your/tv/series/directory
|
||||||
|
# Optional: Additional series paths
|
||||||
|
SERIES_PATH_2=/path/to/backup/series
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### For Movies
|
||||||
|
|
||||||
|
**1. Compare movie libraries:**
|
||||||
|
```bash
|
||||||
|
python compare_movies.py
|
||||||
|
# Or with Nix:
|
||||||
|
nix run .#compare-movies
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Analyze missing movies:**
|
||||||
|
```bash
|
||||||
|
python analyze_movies.py
|
||||||
|
# Or with Nix:
|
||||||
|
nix run .#analyze-movies
|
||||||
|
```
|
||||||
|
|
||||||
|
The movie analyzer will report:
|
||||||
|
- File extensions and naming patterns
|
||||||
|
- Low resolution or unusual codecs
|
||||||
|
- Missing color metadata
|
||||||
|
- `.plexignore` file detection
|
||||||
|
- Recommendations for fixing issues
|
||||||
|
|
||||||
|
### For TV Series
|
||||||
|
|
||||||
|
**1. Compare series libraries:**
|
||||||
|
```bash
|
||||||
|
python compare_series.py
|
||||||
|
# Or with Nix:
|
||||||
|
nix run .#compare-series
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Analyze missing series:**
|
||||||
|
```bash
|
||||||
|
python analyze_series.py
|
||||||
|
# Or with Nix:
|
||||||
|
nix run .#analyze-series
|
||||||
|
```
|
||||||
|
|
||||||
|
The series analyzer will report:
|
||||||
|
- Episode counts (including empty directories)
|
||||||
|
- Naming patterns and special characters
|
||||||
|
- Title mismatches between servers
|
||||||
|
- `.plexignore` file detection
|
||||||
|
|
||||||
|
## Getting API Credentials
|
||||||
|
|
||||||
|
### Plex Token
|
||||||
|
1. Open Plex Web App in your browser
|
||||||
|
2. Open Developer Tools (F12)
|
||||||
|
3. Go to Network tab
|
||||||
|
4. Click on any request to your Plex server
|
||||||
|
5. Look for `X-Plex-Token` in the request headers
|
||||||
|
6. Copy that token value
|
||||||
|
|
||||||
|
### Jellyfin API Key
|
||||||
|
1. Open Jellyfin Dashboard
|
||||||
|
2. Go to Dashboard → API Keys
|
||||||
|
3. Create a new API key
|
||||||
|
4. Copy the API key
|
||||||
|
|
||||||
|
### Jellyfin User ID
|
||||||
|
1. Open Jellyfin Dashboard
|
||||||
|
2. Go to Dashboard → Users
|
||||||
|
3. Click on your user account
|
||||||
|
4. Look at the URL - it will contain something like `/Users/{userId}`
|
||||||
|
5. Copy the UUID part (format: `a1b2c3d4-5678-90ab-cdef-1234567890ab`)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Matching Logic
|
||||||
|
|
||||||
|
The tool uses multiple strategies to match content across systems:
|
||||||
|
|
||||||
|
1. **Title Normalization** - Removes punctuation, years, articles, and special characters
|
||||||
|
2. **Path Matching** - Compares filesystem paths to handle title variations
|
||||||
|
3. **Flexible Comparison** - Handles:
|
||||||
|
- Different romanizations (e.g., "Haikyu!!" vs "Haikyuu!!")
|
||||||
|
- Language variations (e.g., "La familia P. Luche" vs "The Plush Family")
|
||||||
|
- Tag removal (e.g., `{imdb-tt123456}`, `[tvdbid-12345]`)
|
||||||
|
- Year differences in titles
|
||||||
|
|
||||||
|
### Output Files
|
||||||
|
|
||||||
|
- `movies_comparison_report.json` - Detailed movie comparison data
|
||||||
|
- `series_comparison_report.json` - Detailed TV series comparison data
|
||||||
|
|
||||||
|
## Common Issues Found
|
||||||
|
|
||||||
|
### Movies
|
||||||
|
- **Old codecs** (mpeg2, mpeg4/XviD) - Plex may skip these
|
||||||
|
- **Low resolution** (< 720p) - May be filtered as samples
|
||||||
|
- **Missing/invalid metadata** - Causes scanner issues
|
||||||
|
- **`.plexignore` exclusions** - Files explicitly ignored
|
||||||
|
|
||||||
|
### TV Series
|
||||||
|
- **Empty directories** (0 episodes) - Plex correctly skips these
|
||||||
|
- **Title mismatches** - Different names between servers
|
||||||
|
- **Multiple library paths** - Content spread across locations
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 SUMMARY:
|
||||||
|
Movies missing from Plex (found in filesystem): 12
|
||||||
|
Movies missing from Plex (found in Jellyfin): 12
|
||||||
|
Movies in Plex but not in filesystem: 0
|
||||||
|
|
||||||
|
⚠️ Found .plexignore at: /srv/pool/multimedia/media/Movies/.plexignore
|
||||||
|
→ Check if these movies are listed in it!
|
||||||
|
|
||||||
|
🎯 LIKELY CAUSES:
|
||||||
|
• ALL missing movies are visible in Jellyfin
|
||||||
|
• Low resolution files (< 720p) might be filtered out by Plex
|
||||||
|
• Mixed SD/HD color metadata confuses Plex's codec detection
|
||||||
|
• Unusual/old codecs (mpeg2video, mpeg4) not well supported
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No movies found"
|
||||||
|
- Verify `MOVIES_PATH` is correct and accessible
|
||||||
|
- Check file permissions
|
||||||
|
|
||||||
|
### "Authentication failed"
|
||||||
|
- Verify your Plex token is correct
|
||||||
|
- Check Jellyfin API key and User ID
|
||||||
|
|
||||||
|
### "Series reported as missing but I can see them in Plex"
|
||||||
|
- This is usually due to title variations
|
||||||
|
- The tool uses path matching as a fallback
|
||||||
|
- Check the JSON report for path details
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests are welcome! This tool was created to solve real media server management problems.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - feel free to use and modify as needed.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Built with:
|
||||||
|
- [plexapi](https://github.com/pkkid/python-plexapi) - Plex API client
|
||||||
|
- [requests](https://docs.python-requests.org/) - HTTP library
|
||||||
|
- [python-dotenv](https://github.com/theskumar/python-dotenv) - Environment management
|
||||||
67
USAGE.md
Normal file
67
USAGE.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Quick Start Guide
|
||||||
|
|
||||||
|
## First Time Setup
|
||||||
|
|
||||||
|
1. **Configure your environment:**
|
||||||
|
```bash
|
||||||
|
cp config.example.txt .env
|
||||||
|
# Edit .env with your actual credentials and paths
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Enter development environment (Nix users):**
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or install dependencies (pip users):**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
### Movies
|
||||||
|
```bash
|
||||||
|
# Compare movie libraries
|
||||||
|
python compare_movies.py
|
||||||
|
|
||||||
|
# Analyze what's missing
|
||||||
|
python analyze_movies.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### TV Series
|
||||||
|
```bash
|
||||||
|
# Compare series libraries
|
||||||
|
python compare_series.py
|
||||||
|
|
||||||
|
# Analyze what's missing
|
||||||
|
python analyze_series.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Nix Flakes
|
||||||
|
```bash
|
||||||
|
nix run .#compare-movies
|
||||||
|
nix run .#analyze-movies
|
||||||
|
nix run .#compare-series
|
||||||
|
nix run .#analyze-series
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Files
|
||||||
|
|
||||||
|
- `movies_comparison_report.json` - Detailed movie data
|
||||||
|
- `series_comparison_report.json` - Detailed series data
|
||||||
|
|
||||||
|
## First Steps After Running
|
||||||
|
|
||||||
|
1. Check for `.plexignore` files (the scripts will warn you)
|
||||||
|
2. Review the analysis output for patterns
|
||||||
|
3. Force a Plex library scan if needed
|
||||||
|
4. Check Plex logs for scanner errors
|
||||||
|
|
||||||
|
## Common Fixes
|
||||||
|
|
||||||
|
- **Movies in `.plexignore`** → Remove entries, rescan Plex
|
||||||
|
- **Empty series directories** → Plex correctly ignores these
|
||||||
|
- **Old codecs (mpeg4, mpeg2)** → Re-encode or replace files
|
||||||
|
- **Low resolution** → Replace with HD versions
|
||||||
|
- **Title mismatches** → These are actually in Plex, just different names
|
||||||
229
analyze_movies.py
Executable file
229
analyze_movies.py
Executable file
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Analyze movies missing from Plex to identify common patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
import re
|
||||||
|
|
||||||
|
def analyze_missing_movies(report_file='movies_comparison_report.json'):
|
||||||
|
with open(report_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
missing = data.get('missing_from_plex', {})
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
print("No missing movies found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Analyzing {len(missing)} movies missing from Plex...\n")
|
||||||
|
|
||||||
|
# Analyze various attributes
|
||||||
|
extensions = defaultdict(int)
|
||||||
|
has_special_chars = []
|
||||||
|
has_brackets = []
|
||||||
|
has_edition_tag = []
|
||||||
|
has_imdb_tag = []
|
||||||
|
recently_released = []
|
||||||
|
directory_names = defaultdict(int)
|
||||||
|
path_depth = defaultdict(int)
|
||||||
|
file_sizes = []
|
||||||
|
naming_patterns = defaultdict(int)
|
||||||
|
|
||||||
|
for title, info in missing.items():
|
||||||
|
path = Path(info['path'])
|
||||||
|
|
||||||
|
# File extension
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
extensions[ext] += 1
|
||||||
|
|
||||||
|
# Special characters in filename
|
||||||
|
filename = path.name
|
||||||
|
if re.search(r'[^a-zA-Z0-9\s\-_\.\(\)\[\]\{\}]', filename):
|
||||||
|
has_special_chars.append((title, filename))
|
||||||
|
|
||||||
|
# Brackets/braces patterns
|
||||||
|
if '{' in filename or '}' in filename:
|
||||||
|
has_brackets.append((title, filename))
|
||||||
|
|
||||||
|
# Edition tags
|
||||||
|
if '{edition-' in filename.lower():
|
||||||
|
has_edition_tag.append((title, filename))
|
||||||
|
|
||||||
|
# IMDB tags
|
||||||
|
if '{imdb-' in filename.lower():
|
||||||
|
has_imdb_tag.append((title, filename))
|
||||||
|
|
||||||
|
# Directory structure
|
||||||
|
parent_dir = path.parent.name
|
||||||
|
directory_names[parent_dir] += 1
|
||||||
|
|
||||||
|
# Path depth from Movies folder
|
||||||
|
try:
|
||||||
|
parts = path.parts
|
||||||
|
movies_idx = parts.index('Movies')
|
||||||
|
depth = len(parts) - movies_idx - 1
|
||||||
|
path_depth[depth] += 1
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Year extraction and recent releases
|
||||||
|
year_match = re.search(r'\((\d{4})\)', filename)
|
||||||
|
if year_match:
|
||||||
|
year = int(year_match.group(1))
|
||||||
|
if year >= 2023:
|
||||||
|
recently_released.append((title, year, filename))
|
||||||
|
|
||||||
|
# Naming pattern analysis
|
||||||
|
# Check for common patterns like "Movie (Year) {tags} - [quality]"
|
||||||
|
if re.search(r'\{[^}]+\}', filename):
|
||||||
|
naming_patterns['has_curly_braces'] += 1
|
||||||
|
if re.search(r'\[[^\]]+\]', filename):
|
||||||
|
naming_patterns['has_square_brackets'] += 1
|
||||||
|
if re.search(r'\((\d{4})\)', filename):
|
||||||
|
naming_patterns['has_year'] += 1
|
||||||
|
if re.search(r'(1080p|2160p|720p|4K)', filename, re.I):
|
||||||
|
naming_patterns['has_quality'] += 1
|
||||||
|
|
||||||
|
# Print analysis
|
||||||
|
print("="*80)
|
||||||
|
print("ANALYSIS RESULTS")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
print(f"\n📊 FILE EXTENSIONS:")
|
||||||
|
for ext, count in sorted(extensions.items(), key=lambda x: x[1], reverse=True):
|
||||||
|
pct = (count / len(missing)) * 100
|
||||||
|
print(f" {ext or 'no extension':15} {count:4} ({pct:.1f}%)")
|
||||||
|
|
||||||
|
print(f"\n🔤 NAMING PATTERNS:")
|
||||||
|
for pattern, count in sorted(naming_patterns.items(), key=lambda x: x[1], reverse=True):
|
||||||
|
pct = (count / len(missing)) * 100
|
||||||
|
print(f" {pattern:30} {count:4} ({pct:.1f}%)")
|
||||||
|
|
||||||
|
if has_edition_tag:
|
||||||
|
print(f"\n🏷️ EDITION TAGS: {len(has_edition_tag)}")
|
||||||
|
print(f" Movies with {{edition-...}} tags")
|
||||||
|
for title, filename in has_edition_tag[:5]:
|
||||||
|
print(f" • {title[:60]}")
|
||||||
|
if len(has_edition_tag) > 5:
|
||||||
|
print(f" ... and {len(has_edition_tag) - 5} more")
|
||||||
|
|
||||||
|
if has_imdb_tag:
|
||||||
|
print(f"\n🎬 IMDB TAGS: {len(has_imdb_tag)}")
|
||||||
|
print(f" Movies with {{imdb-...}} tags")
|
||||||
|
|
||||||
|
print(f"\n📁 PATH DEPTH FROM 'Movies' FOLDER:")
|
||||||
|
for depth, count in sorted(path_depth.items()):
|
||||||
|
pct = (count / len(missing)) * 100
|
||||||
|
print(f" Depth {depth}: {count:4} ({pct:.1f}%)")
|
||||||
|
|
||||||
|
if recently_released:
|
||||||
|
print(f"\n📅 RECENT RELEASES (2023+): {len(recently_released)}")
|
||||||
|
recent_sorted = sorted(recently_released, key=lambda x: x[1], reverse=True)
|
||||||
|
for title, year, filename in recent_sorted[:10]:
|
||||||
|
print(f" {year} - {title[:60]}")
|
||||||
|
if len(recently_released) > 10:
|
||||||
|
print(f" ... and {len(recently_released) - 10} more")
|
||||||
|
|
||||||
|
if has_special_chars:
|
||||||
|
print(f"\n⚠️ SPECIAL CHARACTERS: {len(has_special_chars)}")
|
||||||
|
special_chars_found = set()
|
||||||
|
for title, filename in has_special_chars:
|
||||||
|
chars = re.findall(r'[^a-zA-Z0-9\s\-_\.\(\)\[\]\{\}]', filename)
|
||||||
|
special_chars_found.update(chars)
|
||||||
|
print(f" Characters found: {', '.join(repr(c) for c in sorted(special_chars_found))}")
|
||||||
|
print(f" Sample files:")
|
||||||
|
for title, filename in has_special_chars[:5]:
|
||||||
|
print(f" • {filename[:75]}")
|
||||||
|
|
||||||
|
# Check for empty/weird directories
|
||||||
|
print(f"\n📂 UNUSUAL DIRECTORY PATTERNS:")
|
||||||
|
unusual_dirs = []
|
||||||
|
for dirname, count in directory_names.items():
|
||||||
|
if '()' in dirname or dirname.strip() == '' or len(dirname) < 3:
|
||||||
|
unusual_dirs.append((dirname, count))
|
||||||
|
|
||||||
|
if unusual_dirs:
|
||||||
|
for dirname, count in unusual_dirs[:10]:
|
||||||
|
print(f" '{dirname}': {count} movies")
|
||||||
|
else:
|
||||||
|
print(" None found")
|
||||||
|
|
||||||
|
# Find common directory patterns
|
||||||
|
print(f"\n📁 DIRECTORIES WITH MOST MISSING MOVIES:")
|
||||||
|
top_dirs = sorted(directory_names.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||||
|
for dirname, count in top_dirs:
|
||||||
|
if count > 1:
|
||||||
|
print(f" {dirname}: {count} movies")
|
||||||
|
|
||||||
|
# Check if all in Jellyfin
|
||||||
|
all_in_jellyfin = all(info['in_jellyfin'] for info in missing.values())
|
||||||
|
some_in_jellyfin = sum(1 for info in missing.values() if info['in_jellyfin'])
|
||||||
|
|
||||||
|
print(f"\n🎥 JELLYFIN STATUS:")
|
||||||
|
print(f" Movies also in Jellyfin: {some_in_jellyfin}/{len(missing)} ({(some_in_jellyfin/len(missing)*100):.1f}%)")
|
||||||
|
|
||||||
|
if some_in_jellyfin == len(missing):
|
||||||
|
print(" ✓ ALL missing movies are visible in Jellyfin")
|
||||||
|
print(" → This suggests a Plex scanning/indexing issue, not file access")
|
||||||
|
elif some_in_jellyfin > 0:
|
||||||
|
print(f" ⚠ {len(missing) - some_in_jellyfin} movies not in Jellyfin either")
|
||||||
|
print(" → These might have filesystem/permission issues")
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("RECOMMENDATIONS:")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
if naming_patterns['has_curly_braces'] > len(missing) * 0.5:
|
||||||
|
recommendations.append(
|
||||||
|
"• Many files use curly braces {} in filenames\n"
|
||||||
|
" Plex might have trouble with certain special characters in names.\n"
|
||||||
|
" Consider: Plex 'Plex Dance' or manual metadata matching for these."
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_edition_tag:
|
||||||
|
recommendations.append(
|
||||||
|
f"• {len(has_edition_tag)} movies have {{edition-...}} tags\n"
|
||||||
|
" Plex may not recognize edition tags in curly braces.\n"
|
||||||
|
" Consider: Use Plex's edition support or move tags to square brackets."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(unusual_dirs) > 0:
|
||||||
|
recommendations.append(
|
||||||
|
"• Some movies are in directories with unusual names (empty, very short)\n"
|
||||||
|
" Plex scanner might skip these.\n"
|
||||||
|
" Consider: Rename directories to proper movie names."
|
||||||
|
)
|
||||||
|
|
||||||
|
if recently_released:
|
||||||
|
recommendations.append(
|
||||||
|
f"• {len(recently_released)} movies are from 2023 or later\n"
|
||||||
|
" These might need manual metadata matching if too new for databases."
|
||||||
|
)
|
||||||
|
|
||||||
|
if all_in_jellyfin:
|
||||||
|
recommendations.append(
|
||||||
|
"• ALL missing movies are visible in Jellyfin\n"
|
||||||
|
" This strongly suggests the issue is with Plex's scanner/matcher, not file access.\n"
|
||||||
|
" Actions:\n"
|
||||||
|
" 1. Force a full library refresh in Plex\n"
|
||||||
|
" 2. Check Plex's 'Trash' for unmatched items\n"
|
||||||
|
" 3. Try 'Scan Library Files' then 'Refresh All Metadata'\n"
|
||||||
|
" 4. Check Plex server logs for scanner errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, rec in enumerate(recommendations, 1):
|
||||||
|
print(f"\n{i}. {rec}")
|
||||||
|
|
||||||
|
if not recommendations:
|
||||||
|
print("\n• Files appear normal. Try forcing a Plex library refresh.")
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
analyze_missing_movies()
|
||||||
|
|
||||||
216
analyze_series.py
Executable file
216
analyze_series.py
Executable file
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Analyze TV series missing from Plex to identify common patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
import re
|
||||||
|
|
||||||
|
def analyze_missing_series(report_file='series_comparison_report.json'):
|
||||||
|
with open(report_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
missing = data.get('missing_from_plex', {})
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
print("No missing TV series found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Analyzing {len(missing)} TV series missing from Plex...\n")
|
||||||
|
|
||||||
|
# Analyze various attributes
|
||||||
|
has_special_chars = []
|
||||||
|
has_brackets = []
|
||||||
|
has_edition_tag = []
|
||||||
|
has_imdb_tag = []
|
||||||
|
has_tvdb_tag = []
|
||||||
|
recently_released = []
|
||||||
|
directory_patterns = defaultdict(int)
|
||||||
|
episode_counts = []
|
||||||
|
|
||||||
|
for title, info in missing.items():
|
||||||
|
path = Path(info['path'])
|
||||||
|
dirname = path.name
|
||||||
|
episode_count = info.get('episode_count', 0)
|
||||||
|
episode_counts.append(episode_count)
|
||||||
|
|
||||||
|
# Special characters in directory name
|
||||||
|
if re.search(r'[^a-zA-Z0-9\s\-_\.\(\)\[\]\{\}]', dirname):
|
||||||
|
has_special_chars.append((title, dirname))
|
||||||
|
|
||||||
|
# Brackets/braces patterns
|
||||||
|
if '{' in dirname or '}' in dirname:
|
||||||
|
has_brackets.append((title, dirname))
|
||||||
|
|
||||||
|
# Edition tags
|
||||||
|
if '{edition-' in dirname.lower():
|
||||||
|
has_edition_tag.append((title, dirname))
|
||||||
|
|
||||||
|
# IMDB/TVDB tags
|
||||||
|
if '{imdb-' in dirname.lower():
|
||||||
|
has_imdb_tag.append((title, dirname))
|
||||||
|
if '{tvdb-' in dirname.lower():
|
||||||
|
has_tvdb_tag.append((title, dirname))
|
||||||
|
|
||||||
|
# Year extraction
|
||||||
|
year_match = re.search(r'\((\d{4})\)', dirname)
|
||||||
|
if year_match:
|
||||||
|
year = int(year_match.group(1))
|
||||||
|
if year >= 2020:
|
||||||
|
recently_released.append((title, year, dirname))
|
||||||
|
|
||||||
|
# Directory naming patterns
|
||||||
|
# Check for patterns like "Show (Year)" or "Show Name - [Quality]"
|
||||||
|
if re.search(r'\((\d{4})\)', dirname):
|
||||||
|
directory_patterns['has_year'] += 1
|
||||||
|
if re.search(r'\{[^}]+\}', dirname):
|
||||||
|
directory_patterns['has_curly_braces'] += 1
|
||||||
|
if re.search(r'\[[^\]]+\]', dirname):
|
||||||
|
directory_patterns['has_square_brackets'] += 1
|
||||||
|
|
||||||
|
# Print analysis
|
||||||
|
print("="*80)
|
||||||
|
print("ANALYSIS RESULTS")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
print(f"\n📊 EPISODE COUNTS:")
|
||||||
|
if episode_counts:
|
||||||
|
print(f" Total episodes: {sum(episode_counts)}")
|
||||||
|
print(f" Average per series: {sum(episode_counts) / len(episode_counts):.1f}")
|
||||||
|
print(f" Min: {min(episode_counts)}, Max: {max(episode_counts)}")
|
||||||
|
|
||||||
|
# Count series with no episodes
|
||||||
|
no_episodes = sum(1 for c in episode_counts if c == 0)
|
||||||
|
if no_episodes > 0:
|
||||||
|
print(f" ⚠️ Series with 0 episodes: {no_episodes}")
|
||||||
|
|
||||||
|
print(f"\n🔤 NAMING PATTERNS:")
|
||||||
|
for pattern, count in sorted(directory_patterns.items(), key=lambda x: x[1], reverse=True):
|
||||||
|
pct = (count / len(missing)) * 100
|
||||||
|
print(f" {pattern:30} {count:4} ({pct:.1f}%)")
|
||||||
|
|
||||||
|
if has_imdb_tag:
|
||||||
|
print(f"\n🎬 IMDB TAGS: {len(has_imdb_tag)}")
|
||||||
|
print(f" Series with {{imdb-...}} tags")
|
||||||
|
|
||||||
|
if has_tvdb_tag:
|
||||||
|
print(f"\n📺 TVDB TAGS: {len(has_tvdb_tag)}")
|
||||||
|
print(f" Series with {{tvdb-...}} tags")
|
||||||
|
|
||||||
|
if has_edition_tag:
|
||||||
|
print(f"\n🏷️ EDITION TAGS: {len(has_edition_tag)}")
|
||||||
|
print(f" Series with {{edition-...}} tags")
|
||||||
|
|
||||||
|
if recently_released:
|
||||||
|
print(f"\n📅 RECENT SERIES (2020+): {len(recently_released)}")
|
||||||
|
recent_sorted = sorted(recently_released, key=lambda x: x[1], reverse=True)
|
||||||
|
for title, year, dirname in recent_sorted[:10]:
|
||||||
|
print(f" {year} - {title[:60]}")
|
||||||
|
if len(recently_released) > 10:
|
||||||
|
print(f" ... and {len(recently_released) - 10} more")
|
||||||
|
|
||||||
|
if has_special_chars:
|
||||||
|
print(f"\n⚠️ SPECIAL CHARACTERS: {len(has_special_chars)}")
|
||||||
|
special_chars_found = set()
|
||||||
|
for title, dirname in has_special_chars:
|
||||||
|
chars = re.findall(r'[^a-zA-Z0-9\s\-_\.\(\)\[\]\{\}]', dirname)
|
||||||
|
special_chars_found.update(chars)
|
||||||
|
print(f" Characters found: {', '.join(repr(c) for c in sorted(special_chars_found))}")
|
||||||
|
print(f" Sample directories:")
|
||||||
|
for title, dirname in has_special_chars[:5]:
|
||||||
|
print(f" • {dirname[:75]}")
|
||||||
|
|
||||||
|
# Check if all in Jellyfin
|
||||||
|
all_in_jellyfin = all(info['in_jellyfin'] for info in missing.values())
|
||||||
|
some_in_jellyfin = sum(1 for info in missing.values() if info['in_jellyfin'])
|
||||||
|
|
||||||
|
print(f"\n📺 JELLYFIN STATUS:")
|
||||||
|
print(f" Series also in Jellyfin: {some_in_jellyfin}/{len(missing)} ({(some_in_jellyfin/len(missing)*100):.1f}%)")
|
||||||
|
|
||||||
|
if some_in_jellyfin == len(missing):
|
||||||
|
print(" ✓ ALL missing series are visible in Jellyfin")
|
||||||
|
print(" → This suggests a Plex scanning/indexing issue")
|
||||||
|
elif some_in_jellyfin > 0:
|
||||||
|
print(f" ⚠ {len(missing) - some_in_jellyfin} series not in Jellyfin either")
|
||||||
|
print(" → These might have filesystem/permission issues")
|
||||||
|
|
||||||
|
# Check for .plexignore hints
|
||||||
|
print(f"\n🔍 CHECK FOR .PLEXIGNORE:")
|
||||||
|
# Check all unique parent directories
|
||||||
|
parent_dirs = set()
|
||||||
|
for info in missing.values():
|
||||||
|
path = Path(info['path'])
|
||||||
|
parent_dirs.add(path.parent)
|
||||||
|
|
||||||
|
found_plexignore = False
|
||||||
|
for parent_dir in parent_dirs:
|
||||||
|
plexignore_path = parent_dir / '.plexignore'
|
||||||
|
if plexignore_path.exists():
|
||||||
|
print(f" ⚠️ Found .plexignore at: {plexignore_path}")
|
||||||
|
print(f" → Check if these series are listed in it!")
|
||||||
|
found_plexignore = True
|
||||||
|
|
||||||
|
if not found_plexignore:
|
||||||
|
print(f" No .plexignore files found in series directories")
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("RECOMMENDATIONS:")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Check for .plexignore first
|
||||||
|
if plexignore_path.exists():
|
||||||
|
recommendations.append(
|
||||||
|
"• .plexignore file detected!\n"
|
||||||
|
f" Check: {plexignore_path}\n"
|
||||||
|
" These series might be explicitly excluded."
|
||||||
|
)
|
||||||
|
|
||||||
|
if some_in_jellyfin == len(missing):
|
||||||
|
recommendations.append(
|
||||||
|
"• ALL missing series are visible in Jellyfin\n"
|
||||||
|
" Actions:\n"
|
||||||
|
" 1. Force a full library refresh in Plex\n"
|
||||||
|
" 2. Check Plex's TV Shows library scanner settings\n"
|
||||||
|
" 3. Verify series naming follows Plex conventions\n"
|
||||||
|
" 4. Check Plex server logs for scanner errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
no_episodes = sum(1 for c in episode_counts if c == 0)
|
||||||
|
if no_episodes > 0:
|
||||||
|
recommendations.append(
|
||||||
|
f"• {no_episodes} series have 0 episodes detected\n"
|
||||||
|
" These directories might be empty or improperly structured.\n"
|
||||||
|
" Plex requires proper season/episode folder structure."
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_special_chars:
|
||||||
|
recommendations.append(
|
||||||
|
f"• {len(has_special_chars)} series have special characters in names\n"
|
||||||
|
" Some characters might cause issues with Plex scanner."
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, rec in enumerate(recommendations, 1):
|
||||||
|
print(f"\n{i}. {rec}")
|
||||||
|
|
||||||
|
if not recommendations:
|
||||||
|
print("\n• Series appear normal. Try forcing a Plex library refresh.")
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
|
||||||
|
# List all missing series
|
||||||
|
print("\n📋 COMPLETE LIST OF MISSING SERIES:")
|
||||||
|
print("="*80)
|
||||||
|
for i, (title, info) in enumerate(sorted(missing.items()), 1):
|
||||||
|
jf_status = "✓" if info['in_jellyfin'] else "✗"
|
||||||
|
ep_count = info.get('episode_count', 0)
|
||||||
|
print(f"{i:3}. [{jf_status}] {title} ({ep_count} episodes)")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
analyze_missing_series()
|
||||||
|
|
||||||
542
compare_movies.py
Executable file
542
compare_movies.py
Executable file
@@ -0,0 +1,542 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Compare Plex and Jellyfin movie libraries to find discrepancies.
|
||||||
|
Identifies movies that exist in filesystem/Jellyfin but are missing from Plex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Set, Dict, List, Tuple
|
||||||
|
from collections import defaultdict
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
from plexapi.server import PlexServer
|
||||||
|
from plexapi.exceptions import NotFound, Unauthorized
|
||||||
|
except ImportError:
|
||||||
|
print("Error: plexapi not installed. Run: pip install -r requirements.txt")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
class MovieLibraryComparator:
|
||||||
|
def __init__(self, plex_url: str, plex_token: str,
|
||||||
|
jellyfin_url: str, jellyfin_api_key: str, jellyfin_user_id: str,
|
||||||
|
movies_path: str):
|
||||||
|
self.plex_url = plex_url
|
||||||
|
self.plex_token = plex_token
|
||||||
|
self.jellyfin_url = jellyfin_url.rstrip('/')
|
||||||
|
self.jellyfin_api_key = jellyfin_api_key
|
||||||
|
self.jellyfin_user_id = jellyfin_user_id
|
||||||
|
self.movies_path = Path(movies_path)
|
||||||
|
# Normalize the movies path for filtering
|
||||||
|
self.movies_path_normalized = self.normalize_path(str(self.movies_path))
|
||||||
|
|
||||||
|
def get_filesystem_movies(self) -> Dict[str, Dict]:
|
||||||
|
"""Scan filesystem for movie files and return normalized title -> path mapping."""
|
||||||
|
movies = {}
|
||||||
|
video_extensions = {'.mkv', '.avi', '.mp4', '.m4v', '.mov', '.wmv', '.flv', '.webm'}
|
||||||
|
|
||||||
|
print(f"Scanning filesystem at: {self.movies_path}")
|
||||||
|
|
||||||
|
if not self.movies_path.exists():
|
||||||
|
print(f"Warning: Movies path does not exist: {self.movies_path}")
|
||||||
|
return movies
|
||||||
|
|
||||||
|
for video_file in self.movies_path.rglob('*'):
|
||||||
|
if video_file.is_file() and video_file.suffix.lower() in video_extensions:
|
||||||
|
# Use filename without extension as key
|
||||||
|
title = video_file.stem
|
||||||
|
movies[title] = {
|
||||||
|
'path': video_file,
|
||||||
|
'full_path': str(video_file),
|
||||||
|
'filename': video_file.name,
|
||||||
|
'stem': video_file.stem
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Found {len(movies)} video files in filesystem")
|
||||||
|
return movies
|
||||||
|
|
||||||
|
def get_plex_movies(self) -> Dict[str, Dict]:
|
||||||
|
"""Query Plex API to get all movies."""
|
||||||
|
print("\nConnecting to Plex...")
|
||||||
|
try:
|
||||||
|
plex = PlexServer(self.plex_url, self.plex_token)
|
||||||
|
movies_section = None
|
||||||
|
|
||||||
|
# Find the Movies library section
|
||||||
|
for section in plex.library.sections():
|
||||||
|
if section.type == 'movie':
|
||||||
|
movies_section = section
|
||||||
|
break
|
||||||
|
|
||||||
|
if not movies_section:
|
||||||
|
print("Error: No Movies library found in Plex")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
print(f"Found Plex Movies library: {movies_section.title}")
|
||||||
|
movies_section.refresh() # Refresh to get latest data
|
||||||
|
|
||||||
|
plex_movies = {}
|
||||||
|
for movie in movies_section.all():
|
||||||
|
# Normalize title - use filename if available, otherwise title
|
||||||
|
key = movie.title
|
||||||
|
file_path = ''
|
||||||
|
|
||||||
|
if hasattr(movie, 'media') and movie.media:
|
||||||
|
# Try to get the actual filename
|
||||||
|
for media in movie.media:
|
||||||
|
if hasattr(media, 'parts') and media.parts:
|
||||||
|
for part in media.parts:
|
||||||
|
if hasattr(part, 'file'):
|
||||||
|
file_path = part.file
|
||||||
|
filename = Path(part.file).stem
|
||||||
|
key = filename
|
||||||
|
break
|
||||||
|
if file_path:
|
||||||
|
break
|
||||||
|
|
||||||
|
plex_movies[key] = {
|
||||||
|
'title': movie.title,
|
||||||
|
'year': getattr(movie, 'year', None),
|
||||||
|
'file': file_path,
|
||||||
|
'added_at': getattr(movie, 'addedAt', None),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Found {len(plex_movies)} movies in Plex")
|
||||||
|
return plex_movies
|
||||||
|
|
||||||
|
except Unauthorized:
|
||||||
|
print("Error: Plex authentication failed. Check your PLEX_TOKEN")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error connecting to Plex: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_jellyfin_movies(self) -> Dict[str, Dict]:
|
||||||
|
"""Query Jellyfin API to get all movies."""
|
||||||
|
print("\nConnecting to Jellyfin...")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'X-Emby-Token': self.jellyfin_api_key,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all items from the Movies library
|
||||||
|
url = f"{self.jellyfin_url}/Users/{self.jellyfin_user_id}/Items"
|
||||||
|
params = {
|
||||||
|
'Recursive': 'true',
|
||||||
|
'IncludeItemTypes': 'Movie',
|
||||||
|
'Fields': 'Path,MediaSources,DateCreated',
|
||||||
|
'Limit': 1000 # Adjust if you have more than 1000 movies
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_movies = {}
|
||||||
|
start_index = 0
|
||||||
|
movies_without_path = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params['StartIndex'] = start_index
|
||||||
|
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
items = data.get('Items', [])
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_id = item.get('Id', '')
|
||||||
|
title = item.get('Name', '')
|
||||||
|
|
||||||
|
# Try to get path from multiple sources
|
||||||
|
path = item.get('Path', '')
|
||||||
|
|
||||||
|
# If Path is empty or looks like a directory, try MediaSources
|
||||||
|
if not path or (path and not Path(path).suffix):
|
||||||
|
media_sources = item.get('MediaSources', [])
|
||||||
|
if media_sources:
|
||||||
|
# Get the first media source's path
|
||||||
|
for media_source in media_sources:
|
||||||
|
media_path = media_source.get('Path', '')
|
||||||
|
if media_path and Path(media_path).suffix:
|
||||||
|
path = media_path
|
||||||
|
break
|
||||||
|
|
||||||
|
# If still no path, try to get it from the item details endpoint
|
||||||
|
if not path or (path and not Path(path).suffix):
|
||||||
|
try:
|
||||||
|
item_url = f"{self.jellyfin_url}/Users/{self.jellyfin_user_id}/Items/{item_id}"
|
||||||
|
item_response = requests.get(item_url, headers=headers, params={'Fields': 'MediaSources'}, timeout=10)
|
||||||
|
if item_response.status_code == 200:
|
||||||
|
item_data = item_response.json()
|
||||||
|
media_sources = item_data.get('MediaSources', [])
|
||||||
|
if media_sources:
|
||||||
|
for media_source in media_sources:
|
||||||
|
media_path = media_source.get('Path', '')
|
||||||
|
if media_path and Path(media_path).suffix:
|
||||||
|
path = media_path
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass # If we can't get details, continue with what we have
|
||||||
|
|
||||||
|
# Filter: Only include movies from the configured Movies directory
|
||||||
|
if path:
|
||||||
|
path_normalized = self.normalize_path(path)
|
||||||
|
# Skip if not in the movies path
|
||||||
|
if not path_normalized.startswith(self.movies_path_normalized):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Skip movies without paths as we can't determine their location
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine key and store movie info
|
||||||
|
if path and Path(path).suffix:
|
||||||
|
filename = Path(path).stem
|
||||||
|
key = filename
|
||||||
|
else:
|
||||||
|
# Use title as fallback
|
||||||
|
key = title
|
||||||
|
movies_without_path += 1
|
||||||
|
|
||||||
|
all_movies[key] = {
|
||||||
|
'title': title,
|
||||||
|
'year': item.get('ProductionYear'),
|
||||||
|
'path': path,
|
||||||
|
'id': item_id,
|
||||||
|
'date_created': item.get('DateCreated'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if there are more items
|
||||||
|
total_records = data.get('TotalRecordCount', 0)
|
||||||
|
if start_index + len(items) >= total_records:
|
||||||
|
break
|
||||||
|
|
||||||
|
start_index += len(items)
|
||||||
|
|
||||||
|
print(f"Found {len(all_movies)} movies in Jellyfin")
|
||||||
|
if movies_without_path > 0:
|
||||||
|
print(f"Warning: {movies_without_path} movies without file paths (using title as key)")
|
||||||
|
return all_movies
|
||||||
|
|
||||||
|
except RequestException as e:
|
||||||
|
print(f"Error connecting to Jellyfin: {e}")
|
||||||
|
if hasattr(e, 'response') and e.response is not None:
|
||||||
|
print(f"Response: {e.response.text}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def normalize_title(self, title: str) -> str:
|
||||||
|
"""Normalize title for comparison (lowercase, remove special chars)."""
|
||||||
|
return title.lower().strip()
|
||||||
|
|
||||||
|
def normalize_path(self, path: str) -> str:
|
||||||
|
"""Normalize path for comparison."""
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return str(Path(path)).lower().replace('\\', '/').strip()
|
||||||
|
|
||||||
|
def build_jellyfin_lookup(self, jellyfin_movies: Dict) -> Dict[str, Dict]:
|
||||||
|
"""Build a lookup index for Jellyfin movies by normalized paths and stems."""
|
||||||
|
lookup = {}
|
||||||
|
|
||||||
|
for jf_key, jf_data in jellyfin_movies.items():
|
||||||
|
jf_path = jf_data.get('path', '')
|
||||||
|
if jf_path:
|
||||||
|
path_obj = Path(jf_path)
|
||||||
|
|
||||||
|
# Index by normalized full path
|
||||||
|
path_norm = self.normalize_path(jf_path)
|
||||||
|
lookup[path_norm] = jf_data
|
||||||
|
|
||||||
|
# If it's a file (has extension), index by stem and filename
|
||||||
|
if path_obj.suffix:
|
||||||
|
# Index by normalized filename stem
|
||||||
|
stem = path_obj.stem
|
||||||
|
stem_norm = self.normalize_title(stem)
|
||||||
|
lookup[stem_norm] = jf_data
|
||||||
|
|
||||||
|
# Also index by filename (with extension)
|
||||||
|
filename = path_obj.name
|
||||||
|
filename_norm = self.normalize_title(filename)
|
||||||
|
lookup[filename_norm] = jf_data
|
||||||
|
else:
|
||||||
|
# It's a directory path, try to find files in it
|
||||||
|
# This handles cases where Jellyfin returns directory paths
|
||||||
|
try:
|
||||||
|
if path_obj.exists() and path_obj.is_dir():
|
||||||
|
for video_file in path_obj.glob('*'):
|
||||||
|
if video_file.is_file() and video_file.suffix.lower() in {'.mkv', '.avi', '.mp4', '.m4v', '.mov', '.wmv', '.flv', '.webm'}:
|
||||||
|
stem = video_file.stem
|
||||||
|
stem_norm = self.normalize_title(stem)
|
||||||
|
lookup[stem_norm] = jf_data
|
||||||
|
|
||||||
|
filename = video_file.name
|
||||||
|
filename_norm = self.normalize_title(filename)
|
||||||
|
lookup[filename_norm] = jf_data
|
||||||
|
|
||||||
|
full_path_norm = self.normalize_path(str(video_file))
|
||||||
|
lookup[full_path_norm] = jf_data
|
||||||
|
except:
|
||||||
|
pass # If we can't access the path, skip
|
||||||
|
|
||||||
|
# Also index by the key itself (normalized)
|
||||||
|
if jf_key:
|
||||||
|
key_norm = self.normalize_title(jf_key)
|
||||||
|
lookup[key_norm] = jf_data
|
||||||
|
|
||||||
|
# Also index by title (normalized)
|
||||||
|
title = jf_data.get('title', '')
|
||||||
|
if title:
|
||||||
|
title_norm = self.normalize_title(title)
|
||||||
|
lookup[title_norm] = jf_data
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
def find_jellyfin_match(self, fs_path: str, fs_stem: str, jellyfin_lookup: Dict) -> Tuple[bool, str]:
|
||||||
|
"""Find if a filesystem movie exists in Jellyfin using the lookup index."""
|
||||||
|
fs_path_norm = self.normalize_path(fs_path)
|
||||||
|
fs_stem_norm = self.normalize_title(fs_stem)
|
||||||
|
fs_filename = Path(fs_path).name
|
||||||
|
fs_filename_norm = self.normalize_title(fs_filename)
|
||||||
|
|
||||||
|
# Extract just the filename part from the path for matching
|
||||||
|
# This handles cases where paths might be in different formats
|
||||||
|
fs_path_parts = Path(fs_path).parts
|
||||||
|
fs_basename = fs_path_parts[-1] if fs_path_parts else fs_filename
|
||||||
|
fs_basename_norm = self.normalize_title(fs_basename)
|
||||||
|
fs_basename_stem_norm = self.normalize_title(Path(fs_basename).stem)
|
||||||
|
|
||||||
|
# Try multiple matching strategies in order of specificity
|
||||||
|
match_keys = [
|
||||||
|
fs_path_norm, # Full normalized path
|
||||||
|
fs_stem_norm, # Filename stem
|
||||||
|
fs_filename_norm, # Full filename with extension
|
||||||
|
fs_basename_norm, # Just the basename
|
||||||
|
fs_basename_stem_norm, # Basename without extension
|
||||||
|
]
|
||||||
|
|
||||||
|
for norm_key in match_keys:
|
||||||
|
if norm_key and norm_key in jellyfin_lookup:
|
||||||
|
jf_data = jellyfin_lookup[norm_key]
|
||||||
|
return True, jf_data.get('title', '')
|
||||||
|
|
||||||
|
# Also try partial path matching (in case paths differ slightly)
|
||||||
|
# Check if any part of the filesystem path matches any Jellyfin path
|
||||||
|
if fs_path_norm:
|
||||||
|
for jf_norm_key, jf_data in jellyfin_lookup.items():
|
||||||
|
# Check if paths overlap significantly
|
||||||
|
if (fs_path_norm in jf_norm_key or jf_norm_key in fs_path_norm or
|
||||||
|
fs_stem_norm in jf_norm_key or jf_norm_key in fs_stem_norm):
|
||||||
|
# Additional check: make sure it's not just a partial word match
|
||||||
|
if len(fs_stem_norm) > 5 and len(jf_norm_key) > 5:
|
||||||
|
return True, jf_data.get('title', '')
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def compare_libraries(self) -> Tuple[Dict, Dict, Dict]:
|
||||||
|
"""Compare all three sources and return discrepancies."""
|
||||||
|
fs_movies = self.get_filesystem_movies()
|
||||||
|
plex_movies = self.get_plex_movies()
|
||||||
|
jellyfin_movies = self.get_jellyfin_movies()
|
||||||
|
|
||||||
|
# Build Jellyfin lookup index for efficient matching
|
||||||
|
jellyfin_lookup = self.build_jellyfin_lookup(jellyfin_movies)
|
||||||
|
|
||||||
|
# Normalize keys for comparison
|
||||||
|
fs_normalized = {self.normalize_title(k): (k, v) for k, v in fs_movies.items()}
|
||||||
|
plex_normalized = {self.normalize_title(k): (k, v) for k, v in plex_movies.items()}
|
||||||
|
jellyfin_normalized = {self.normalize_title(k): (k, v) for k, v in jellyfin_movies.items()}
|
||||||
|
|
||||||
|
# Find movies in filesystem but not in Plex
|
||||||
|
missing_from_plex = {}
|
||||||
|
debug_samples = [] # Store first few for debugging
|
||||||
|
|
||||||
|
for norm_key, (orig_key, fs_data) in fs_normalized.items():
|
||||||
|
if norm_key not in plex_normalized:
|
||||||
|
# Check if it's in Jellyfin using improved matching
|
||||||
|
fs_path = fs_data['full_path']
|
||||||
|
fs_stem = fs_data['stem']
|
||||||
|
in_jellyfin, jf_title = self.find_jellyfin_match(fs_path, fs_stem, jellyfin_lookup)
|
||||||
|
|
||||||
|
# Store debug info for first few unmatched items
|
||||||
|
if not in_jellyfin and len(debug_samples) < 3:
|
||||||
|
debug_samples.append({
|
||||||
|
'fs_path': fs_path,
|
||||||
|
'fs_stem': fs_stem,
|
||||||
|
'fs_norm_path': self.normalize_path(fs_path),
|
||||||
|
'fs_norm_stem': self.normalize_title(fs_stem),
|
||||||
|
'jellyfin_keys_sample': list(jellyfin_lookup.keys())[:5] if jellyfin_lookup else []
|
||||||
|
})
|
||||||
|
|
||||||
|
missing_from_plex[orig_key] = {
|
||||||
|
'path': fs_path,
|
||||||
|
'in_jellyfin': in_jellyfin,
|
||||||
|
'jellyfin_title': jf_title
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print debug info if we have samples
|
||||||
|
if debug_samples:
|
||||||
|
print("\n🔍 DEBUG: Sample of unmatched files (first 3):")
|
||||||
|
for i, sample in enumerate(debug_samples, 1):
|
||||||
|
print(f"\n Sample {i}:")
|
||||||
|
print(f" FS Path: {sample['fs_path']}")
|
||||||
|
print(f" FS Stem: {sample['fs_stem']}")
|
||||||
|
print(f" Normalized Path: {sample['fs_norm_path']}")
|
||||||
|
print(f" Normalized Stem: {sample['fs_norm_stem']}")
|
||||||
|
print(f" Sample Jellyfin keys: {sample['jellyfin_keys_sample']}")
|
||||||
|
|
||||||
|
# Find movies in Jellyfin but not in Plex
|
||||||
|
missing_from_plex_jellyfin = {}
|
||||||
|
for orig_key, data in jellyfin_movies.items():
|
||||||
|
jf_stem = Path(data['path']).stem if data.get('path') else orig_key
|
||||||
|
jf_norm = self.normalize_title(jf_stem)
|
||||||
|
|
||||||
|
if jf_norm not in plex_normalized:
|
||||||
|
# Check if it's in filesystem using improved matching
|
||||||
|
jf_path = data.get('path', '')
|
||||||
|
in_filesystem = False
|
||||||
|
fs_path = None
|
||||||
|
|
||||||
|
if jf_path:
|
||||||
|
jf_path_norm = self.normalize_path(jf_path)
|
||||||
|
jf_stem_norm = self.normalize_title(jf_stem)
|
||||||
|
jf_filename = Path(jf_path).name
|
||||||
|
jf_filename_norm = self.normalize_title(jf_filename)
|
||||||
|
|
||||||
|
# Try to find matching filesystem movie
|
||||||
|
for fs_orig_key, fs_data in fs_movies.items():
|
||||||
|
fs_path_str = fs_data['full_path']
|
||||||
|
fs_path_norm = self.normalize_path(fs_path_str)
|
||||||
|
fs_stem_norm = self.normalize_title(fs_data['stem'])
|
||||||
|
fs_filename_norm = self.normalize_title(fs_data['filename'])
|
||||||
|
|
||||||
|
# Match by path, stem, or filename
|
||||||
|
if (jf_path_norm == fs_path_norm or
|
||||||
|
jf_stem_norm == fs_stem_norm or
|
||||||
|
jf_filename_norm == fs_filename_norm or
|
||||||
|
jf_path_norm in fs_path_norm or
|
||||||
|
fs_path_norm in jf_path_norm):
|
||||||
|
in_filesystem = True
|
||||||
|
fs_path = fs_path_str
|
||||||
|
break
|
||||||
|
|
||||||
|
missing_from_plex_jellyfin[orig_key] = {
|
||||||
|
'title': data['title'],
|
||||||
|
'path': jf_path,
|
||||||
|
'in_filesystem': in_filesystem,
|
||||||
|
'filesystem_path': fs_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find movies in Plex but not in filesystem (orphaned)
|
||||||
|
orphaned_in_plex = {}
|
||||||
|
for norm_key, (orig_key, data) in plex_normalized.items():
|
||||||
|
if norm_key not in fs_normalized:
|
||||||
|
orphaned_in_plex[orig_key] = data
|
||||||
|
|
||||||
|
return missing_from_plex, missing_from_plex_jellyfin, orphaned_in_plex
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and print a comprehensive comparison report."""
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("LIBRARY COMPARISON REPORT")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
missing_from_plex, missing_from_plex_jellyfin, orphaned_in_plex = self.compare_libraries()
|
||||||
|
|
||||||
|
print(f"\n📊 SUMMARY:")
|
||||||
|
print(f" Movies missing from Plex (found in filesystem): {len(missing_from_plex)}")
|
||||||
|
print(f" Movies missing from Plex (found in Jellyfin): {len(missing_from_plex_jellyfin)}")
|
||||||
|
print(f" Movies in Plex but not in filesystem: {len(orphaned_in_plex)}")
|
||||||
|
|
||||||
|
if missing_from_plex:
|
||||||
|
print(f"\n❌ MOVIES IN FILESYSTEM BUT MISSING FROM PLEX ({len(missing_from_plex)}):")
|
||||||
|
print("-" * 80)
|
||||||
|
for i, (title, info) in enumerate(sorted(missing_from_plex.items()), 1):
|
||||||
|
print(f"\n{i}. {title}")
|
||||||
|
print(f" Path: {info['path']}")
|
||||||
|
print(f" In Jellyfin: {'✓ Yes' if info['in_jellyfin'] else '✗ No'}")
|
||||||
|
if info['jellyfin_title']:
|
||||||
|
print(f" Jellyfin Title: {info['jellyfin_title']}")
|
||||||
|
|
||||||
|
if missing_from_plex_jellyfin and len(missing_from_plex_jellyfin) != len(missing_from_plex):
|
||||||
|
print(f"\n❌ MOVIES IN JELLYFIN BUT MISSING FROM PLEX ({len(missing_from_plex_jellyfin)}):")
|
||||||
|
print("-" * 80)
|
||||||
|
for i, (key, info) in enumerate(sorted(missing_from_plex_jellyfin.items()), 1):
|
||||||
|
print(f"\n{i}. {info['title']}")
|
||||||
|
print(f" Path: {info['path']}")
|
||||||
|
print(f" In Filesystem: {'✓ Yes' if info['in_filesystem'] else '✗ No'}")
|
||||||
|
if info['filesystem_path']:
|
||||||
|
print(f" Filesystem Path: {info['filesystem_path']}")
|
||||||
|
|
||||||
|
if orphaned_in_plex:
|
||||||
|
print(f"\n⚠️ MOVIES IN PLEX BUT NOT IN FILESYSTEM ({len(orphaned_in_plex)}):")
|
||||||
|
print("-" * 80)
|
||||||
|
for i, (title, info) in enumerate(sorted(orphaned_in_plex.items()), 1):
|
||||||
|
print(f"\n{i}. {title}")
|
||||||
|
if info.get('file'):
|
||||||
|
print(f" File: {info['file']}")
|
||||||
|
|
||||||
|
# Save detailed report to JSON
|
||||||
|
report_data = {
|
||||||
|
'missing_from_plex': missing_from_plex,
|
||||||
|
'missing_from_plex_jellyfin': missing_from_plex_jellyfin,
|
||||||
|
'orphaned_in_plex': orphaned_in_plex
|
||||||
|
}
|
||||||
|
|
||||||
|
report_file = Path('movies_comparison_report.json')
|
||||||
|
with open(report_file, 'w') as f:
|
||||||
|
json.dump(report_data, f, indent=2, default=str)
|
||||||
|
|
||||||
|
print(f"\n💾 Detailed report saved to: {report_file}")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Get configuration from environment variables
|
||||||
|
plex_url = os.getenv('PLEX_URL', 'http://localhost:32400')
|
||||||
|
plex_token = os.getenv('PLEX_TOKEN')
|
||||||
|
jellyfin_url = os.getenv('JELLYFIN_URL', 'http://localhost:8096')
|
||||||
|
jellyfin_api_key = os.getenv('JELLYFIN_API_KEY')
|
||||||
|
jellyfin_user_id = os.getenv('JELLYFIN_USER_ID')
|
||||||
|
movies_path = os.getenv('MOVIES_PATH')
|
||||||
|
|
||||||
|
# Validate required configuration
|
||||||
|
if not plex_token:
|
||||||
|
print("Error: PLEX_TOKEN not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not jellyfin_api_key:
|
||||||
|
print("Error: JELLYFIN_API_KEY not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not jellyfin_user_id:
|
||||||
|
print("Error: JELLYFIN_USER_ID not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not movies_path:
|
||||||
|
print("Error: MOVIES_PATH not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
comparator = MovieLibraryComparator(
|
||||||
|
plex_url=plex_url,
|
||||||
|
plex_token=plex_token,
|
||||||
|
jellyfin_url=jellyfin_url,
|
||||||
|
jellyfin_api_key=jellyfin_api_key,
|
||||||
|
jellyfin_user_id=jellyfin_user_id,
|
||||||
|
movies_path=movies_path
|
||||||
|
)
|
||||||
|
|
||||||
|
comparator.generate_report()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
||||||
539
compare_series.py
Executable file
539
compare_series.py
Executable file
@@ -0,0 +1,539 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Compare Plex and Jellyfin TV series libraries to find discrepancies.
|
||||||
|
Identifies series that exist in filesystem/Jellyfin but are missing from Plex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Set, Dict, List, Tuple
|
||||||
|
from collections import defaultdict
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
from plexapi.server import PlexServer
|
||||||
|
from plexapi.exceptions import NotFound, Unauthorized
|
||||||
|
except ImportError:
|
||||||
|
print("Error: plexapi not installed. Run: pip install -r requirements.txt")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesLibraryComparator:
|
||||||
|
def __init__(self, plex_url: str, plex_token: str,
|
||||||
|
jellyfin_url: str, jellyfin_api_key: str, jellyfin_user_id: str,
|
||||||
|
series_paths: List[str]):
|
||||||
|
self.plex_url = plex_url
|
||||||
|
self.plex_token = plex_token
|
||||||
|
self.jellyfin_url = jellyfin_url.rstrip('/')
|
||||||
|
self.jellyfin_api_key = jellyfin_api_key
|
||||||
|
self.jellyfin_user_id = jellyfin_user_id
|
||||||
|
# Support multiple series paths
|
||||||
|
if isinstance(series_paths, str):
|
||||||
|
series_paths = [series_paths]
|
||||||
|
self.series_paths = [Path(p) for p in series_paths]
|
||||||
|
self.series_paths_normalized = [self.normalize_path(str(p)) for p in self.series_paths]
|
||||||
|
|
||||||
|
def normalize_path(self, path: str) -> str:
|
||||||
|
"""Normalize path for comparison."""
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return str(Path(path)).lower().replace('\\', '/').strip()
|
||||||
|
|
||||||
|
def normalize_title(self, title: str) -> str:
|
||||||
|
"""Normalize title for comparison (lowercase, normalize punctuation)."""
|
||||||
|
import re
|
||||||
|
normalized = title.lower().strip()
|
||||||
|
|
||||||
|
# Remove year in parentheses
|
||||||
|
normalized = re.sub(r'\s*\(\d{4}\)\s*', ' ', normalized)
|
||||||
|
|
||||||
|
# Remove all punctuation and special characters (keep alphanumeric and spaces only)
|
||||||
|
normalized = re.sub(r'[^a-z0-9\s]', ' ', normalized)
|
||||||
|
|
||||||
|
# Normalize multiple spaces
|
||||||
|
normalized = re.sub(r'\s+', ' ', normalized)
|
||||||
|
|
||||||
|
# Remove common articles
|
||||||
|
normalized = re.sub(r'\b(the|a|an)\b', ' ', normalized)
|
||||||
|
|
||||||
|
# Normalize multiple spaces again after removals
|
||||||
|
normalized = re.sub(r'\s+', ' ', normalized)
|
||||||
|
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
def extract_base_title(self, dirname: str) -> str:
|
||||||
|
"""Extract base title from directory name, removing year, imdb tags, etc."""
|
||||||
|
import re
|
||||||
|
# Remove patterns like (2021), [imdbid-tt123456], [tvdbid-123456], {imdb-tt123456}
|
||||||
|
# Note: Use * instead of + to match zero or more characters (handles empty IDs like [imdbid-])
|
||||||
|
cleaned = re.sub(r'\s*\(\d{4}\)\s*', ' ', dirname) # Remove (year)
|
||||||
|
cleaned = re.sub(r'\s*\[imdbid-[^\]]*\]\s*', ' ', cleaned) # Remove [imdbid-...] or [imdbid-]
|
||||||
|
cleaned = re.sub(r'\s*\[tvdbid-[^\]]*\]\s*', ' ', cleaned) # Remove [tvdbid-...] or [tvdbid-]
|
||||||
|
cleaned = re.sub(r'\s*\{imdb-[^\}]*\}\s*', ' ', cleaned) # Remove {imdb-...} or {imdb-}
|
||||||
|
cleaned = re.sub(r'\s*\{tvdb-[^\}]*\}\s*', ' ', cleaned) # Remove {tvdb-...} or {tvdb-}
|
||||||
|
# Remove trailing dash/hyphen with spaces
|
||||||
|
cleaned = re.sub(r'\s*-\s*$', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s+', ' ', cleaned) # Normalize whitespace
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
def get_filesystem_series(self) -> Dict[str, Dict]:
|
||||||
|
"""Scan filesystem for TV series directories."""
|
||||||
|
series = {}
|
||||||
|
|
||||||
|
for series_path in self.series_paths:
|
||||||
|
print(f"Scanning filesystem at: {series_path}")
|
||||||
|
|
||||||
|
if not series_path.exists():
|
||||||
|
print(f"Warning: Series path does not exist: {series_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Each subdirectory of the series path is assumed to be a TV show
|
||||||
|
for series_dir in series_path.iterdir():
|
||||||
|
if series_dir.is_dir() and not series_dir.name.startswith('.'):
|
||||||
|
# Count episodes
|
||||||
|
episode_count = 0
|
||||||
|
video_extensions = {'.mkv', '.avi', '.mp4', '.m4v', '.mov', '.wmv', '.flv', '.webm'}
|
||||||
|
|
||||||
|
for video_file in series_dir.rglob('*'):
|
||||||
|
if video_file.is_file() and video_file.suffix.lower() in video_extensions:
|
||||||
|
episode_count += 1
|
||||||
|
|
||||||
|
# Use full path as key to handle duplicates across paths
|
||||||
|
key = f"{series_dir.name}||{series_path.name}"
|
||||||
|
|
||||||
|
series[key] = {
|
||||||
|
'path': series_dir,
|
||||||
|
'full_path': str(series_dir),
|
||||||
|
'name': series_dir.name,
|
||||||
|
'episode_count': episode_count,
|
||||||
|
'base_path': str(series_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Found {len(series)} TV series directories in filesystem")
|
||||||
|
return series
|
||||||
|
|
||||||
|
def get_plex_series(self) -> Dict[str, Dict]:
|
||||||
|
"""Query Plex API to get all TV series."""
|
||||||
|
print("\nConnecting to Plex...")
|
||||||
|
try:
|
||||||
|
plex = PlexServer(self.plex_url, self.plex_token)
|
||||||
|
series_section = None
|
||||||
|
|
||||||
|
# Find the TV Shows library section
|
||||||
|
for section in plex.library.sections():
|
||||||
|
if section.type == 'show':
|
||||||
|
series_section = section
|
||||||
|
break
|
||||||
|
|
||||||
|
if not series_section:
|
||||||
|
print("Error: No TV Shows library found in Plex")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
print(f"Found Plex TV Shows library: {series_section.title}")
|
||||||
|
|
||||||
|
plex_series = {}
|
||||||
|
for show in series_section.all():
|
||||||
|
episode_count = 0
|
||||||
|
try:
|
||||||
|
episode_count = show.leafCount # Total episodes
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try to get the directory path
|
||||||
|
show_path = ''
|
||||||
|
try:
|
||||||
|
if hasattr(show, 'locations') and show.locations:
|
||||||
|
show_path = show.locations[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
plex_series[show.title] = {
|
||||||
|
'title': show.title,
|
||||||
|
'year': getattr(show, 'year', None),
|
||||||
|
'episode_count': episode_count,
|
||||||
|
'path': show_path,
|
||||||
|
'added_at': getattr(show, 'addedAt', None),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Found {len(plex_series)} TV series in Plex")
|
||||||
|
return plex_series
|
||||||
|
|
||||||
|
except Unauthorized:
|
||||||
|
print("Error: Plex authentication failed. Check your PLEX_TOKEN")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error connecting to Plex: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_jellyfin_series(self) -> Dict[str, Dict]:
|
||||||
|
"""Query Jellyfin API to get all TV series."""
|
||||||
|
print("\nConnecting to Jellyfin...")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'X-Emby-Token': self.jellyfin_api_key,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all TV series from the library
|
||||||
|
url = f"{self.jellyfin_url}/Users/{self.jellyfin_user_id}/Items"
|
||||||
|
params = {
|
||||||
|
'Recursive': 'false',
|
||||||
|
'IncludeItemTypes': 'Series',
|
||||||
|
'Fields': 'Path,DateCreated',
|
||||||
|
'Limit': 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_series = {}
|
||||||
|
start_index = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params['StartIndex'] = start_index
|
||||||
|
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
items = data.get('Items', [])
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
series_id = item.get('Id', '')
|
||||||
|
title = item.get('Name', '')
|
||||||
|
path = item.get('Path', '')
|
||||||
|
|
||||||
|
# Filter: Only include series from the configured Series directories
|
||||||
|
if path:
|
||||||
|
path_normalized = self.normalize_path(path)
|
||||||
|
# Check if path starts with any of the configured series paths
|
||||||
|
if not any(path_normalized.startswith(sp) for sp in self.series_paths_normalized):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get episode count
|
||||||
|
episode_count = 0
|
||||||
|
try:
|
||||||
|
# Query for episode count
|
||||||
|
episodes_url = f"{self.jellyfin_url}/Users/{self.jellyfin_user_id}/Items"
|
||||||
|
episodes_params = {
|
||||||
|
'ParentId': series_id,
|
||||||
|
'Recursive': 'true',
|
||||||
|
'IncludeItemTypes': 'Episode',
|
||||||
|
'Limit': 1
|
||||||
|
}
|
||||||
|
ep_response = requests.get(episodes_url, headers=headers, params=episodes_params, timeout=10)
|
||||||
|
if ep_response.status_code == 200:
|
||||||
|
ep_data = ep_response.json()
|
||||||
|
episode_count = ep_data.get('TotalRecordCount', 0)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
all_series[title] = {
|
||||||
|
'title': title,
|
||||||
|
'year': item.get('ProductionYear'),
|
||||||
|
'path': path,
|
||||||
|
'id': series_id,
|
||||||
|
'episode_count': episode_count,
|
||||||
|
'date_created': item.get('DateCreated'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if there are more items
|
||||||
|
total_records = data.get('TotalRecordCount', 0)
|
||||||
|
if start_index + len(items) >= total_records:
|
||||||
|
break
|
||||||
|
|
||||||
|
start_index += len(items)
|
||||||
|
|
||||||
|
print(f"Found {len(all_series)} TV series in Jellyfin")
|
||||||
|
return all_series
|
||||||
|
|
||||||
|
except RequestException as e:
|
||||||
|
print(f"Error connecting to Jellyfin: {e}")
|
||||||
|
if hasattr(e, 'response') and e.response is not None:
|
||||||
|
print(f"Response: {e.response.text}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def build_jellyfin_lookup(self, jellyfin_series: Dict) -> Dict[str, Dict]:
|
||||||
|
"""Build a lookup index for Jellyfin series by normalized names and paths."""
|
||||||
|
lookup = {}
|
||||||
|
|
||||||
|
for jf_key, jf_data in jellyfin_series.items():
|
||||||
|
# Index by normalized title
|
||||||
|
title_norm = self.normalize_title(jf_data['title'])
|
||||||
|
lookup[title_norm] = jf_data
|
||||||
|
|
||||||
|
# Index by normalized path directory name (with and without year/tags)
|
||||||
|
jf_path = jf_data.get('path', '')
|
||||||
|
if jf_path:
|
||||||
|
path_obj = Path(jf_path)
|
||||||
|
dirname = path_obj.name
|
||||||
|
dirname_norm = self.normalize_title(dirname)
|
||||||
|
lookup[dirname_norm] = jf_data
|
||||||
|
|
||||||
|
# Also index by base title (without year/tags)
|
||||||
|
base_title = self.extract_base_title(dirname)
|
||||||
|
base_title_norm = self.normalize_title(base_title)
|
||||||
|
lookup[base_title_norm] = jf_data
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
def find_jellyfin_match(self, fs_name: str, fs_path: str, jellyfin_lookup: Dict) -> Tuple[bool, str, int]:
|
||||||
|
"""Find if a filesystem series exists in Jellyfin."""
|
||||||
|
fs_name_norm = self.normalize_title(fs_name)
|
||||||
|
fs_path_norm = self.normalize_path(fs_path)
|
||||||
|
|
||||||
|
# Extract base title from filesystem name
|
||||||
|
fs_base_title = self.extract_base_title(fs_name)
|
||||||
|
fs_base_norm = self.normalize_title(fs_base_title)
|
||||||
|
|
||||||
|
# Try matching by normalized name (full and base)
|
||||||
|
for norm_key in [fs_name_norm, fs_base_norm]:
|
||||||
|
if norm_key in jellyfin_lookup:
|
||||||
|
jf_data = jellyfin_lookup[norm_key]
|
||||||
|
return True, jf_data.get('title', ''), jf_data.get('episode_count', 0)
|
||||||
|
|
||||||
|
# Try matching by path
|
||||||
|
for jf_norm_key, jf_data in jellyfin_lookup.items():
|
||||||
|
jf_path = jf_data.get('path', '')
|
||||||
|
if jf_path:
|
||||||
|
jf_path_norm = self.normalize_path(jf_path)
|
||||||
|
if fs_path_norm == jf_path_norm or fs_path_norm in jf_path_norm:
|
||||||
|
return True, jf_data.get('title', ''), jf_data.get('episode_count', 0)
|
||||||
|
|
||||||
|
return False, None, 0
|
||||||
|
|
||||||
|
def compare_libraries(self) -> Tuple[Dict, Dict, Dict]:
|
||||||
|
"""Compare all three sources and return discrepancies."""
|
||||||
|
fs_series = self.get_filesystem_series()
|
||||||
|
plex_series = self.get_plex_series()
|
||||||
|
jellyfin_series = self.get_jellyfin_series()
|
||||||
|
|
||||||
|
# Build Jellyfin lookup index
|
||||||
|
jellyfin_lookup = self.build_jellyfin_lookup(jellyfin_series)
|
||||||
|
|
||||||
|
# Normalize keys for comparison - extract base title from filesystem
|
||||||
|
fs_normalized = {}
|
||||||
|
for k, v in fs_series.items():
|
||||||
|
# Extract base title from directory name (remove year, imdb tags)
|
||||||
|
base_title = self.extract_base_title(v['name'])
|
||||||
|
normalized = self.normalize_title(base_title)
|
||||||
|
fs_normalized[normalized] = (k, v)
|
||||||
|
|
||||||
|
plex_normalized = {self.normalize_title(k): (k, v) for k, v in plex_series.items()}
|
||||||
|
|
||||||
|
# Find series in filesystem but not in Plex
|
||||||
|
missing_from_plex = {}
|
||||||
|
for norm_key, (orig_key, fs_data) in fs_normalized.items():
|
||||||
|
if norm_key not in plex_normalized:
|
||||||
|
# Double-check by path - maybe the title doesn't match but path does
|
||||||
|
fs_path = fs_data['full_path']
|
||||||
|
fs_path_norm = self.normalize_path(fs_path)
|
||||||
|
found_in_plex = False
|
||||||
|
|
||||||
|
# Check if any Plex series has a matching path
|
||||||
|
for plex_title, plex_data in plex_series.items():
|
||||||
|
plex_path = plex_data.get('path', '')
|
||||||
|
if plex_path:
|
||||||
|
plex_path_norm = self.normalize_path(plex_path)
|
||||||
|
if fs_path_norm == plex_path_norm or fs_path_norm in plex_path_norm or plex_path_norm in fs_path_norm:
|
||||||
|
found_in_plex = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_in_plex:
|
||||||
|
fs_name = fs_data['name']
|
||||||
|
in_jellyfin, jf_title, jf_ep_count = self.find_jellyfin_match(fs_name, fs_path, jellyfin_lookup)
|
||||||
|
|
||||||
|
missing_from_plex[orig_key] = {
|
||||||
|
'path': fs_path,
|
||||||
|
'episode_count': fs_data['episode_count'],
|
||||||
|
'in_jellyfin': in_jellyfin,
|
||||||
|
'jellyfin_title': jf_title,
|
||||||
|
'jellyfin_episode_count': jf_ep_count
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find series in Jellyfin but not in Plex
|
||||||
|
missing_from_plex_jellyfin = {}
|
||||||
|
for orig_key, data in jellyfin_series.items():
|
||||||
|
jf_title_norm = self.normalize_title(data['title'])
|
||||||
|
|
||||||
|
if jf_title_norm not in plex_normalized:
|
||||||
|
jf_path = data.get('path', '')
|
||||||
|
in_filesystem = False
|
||||||
|
fs_path = None
|
||||||
|
fs_ep_count = 0
|
||||||
|
|
||||||
|
if jf_path:
|
||||||
|
jf_path_norm = self.normalize_path(jf_path)
|
||||||
|
dirname = Path(jf_path).name
|
||||||
|
dirname_norm = self.normalize_title(dirname)
|
||||||
|
jf_title_norm_alt = self.normalize_title(data['title'])
|
||||||
|
|
||||||
|
for fs_orig_key, fs_data in fs_series.items():
|
||||||
|
fs_path_str = fs_data['full_path']
|
||||||
|
fs_path_norm = self.normalize_path(fs_path_str)
|
||||||
|
fs_name_norm = self.normalize_title(fs_data['name'])
|
||||||
|
|
||||||
|
# Match by path, directory name, or title
|
||||||
|
if (jf_path_norm == fs_path_norm or
|
||||||
|
dirname_norm == fs_name_norm or
|
||||||
|
jf_title_norm_alt == fs_name_norm):
|
||||||
|
in_filesystem = True
|
||||||
|
fs_path = fs_path_str
|
||||||
|
fs_ep_count = fs_data['episode_count']
|
||||||
|
break
|
||||||
|
|
||||||
|
missing_from_plex_jellyfin[orig_key] = {
|
||||||
|
'title': data['title'],
|
||||||
|
'path': jf_path,
|
||||||
|
'episode_count': data['episode_count'],
|
||||||
|
'in_filesystem': in_filesystem,
|
||||||
|
'filesystem_path': fs_path,
|
||||||
|
'filesystem_episode_count': fs_ep_count
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find series in Plex but not in filesystem (orphaned)
|
||||||
|
orphaned_in_plex = {}
|
||||||
|
for norm_key, (orig_key, data) in plex_normalized.items():
|
||||||
|
if norm_key not in fs_normalized:
|
||||||
|
# Double-check by looking at the actual path if available
|
||||||
|
plex_path = data.get('path', '')
|
||||||
|
found_in_fs = False
|
||||||
|
|
||||||
|
if plex_path:
|
||||||
|
plex_path_norm = self.normalize_path(plex_path)
|
||||||
|
# Check if this path matches any filesystem series
|
||||||
|
for fs_key, fs_data in fs_series.items():
|
||||||
|
fs_path_norm = self.normalize_path(fs_data['full_path'])
|
||||||
|
if plex_path_norm == fs_path_norm or plex_path_norm in fs_path_norm or fs_path_norm in plex_path_norm:
|
||||||
|
found_in_fs = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_in_fs:
|
||||||
|
orphaned_in_plex[orig_key] = data
|
||||||
|
|
||||||
|
return missing_from_plex, missing_from_plex_jellyfin, orphaned_in_plex
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and print a comprehensive comparison report."""
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("TV SERIES COMPARISON REPORT")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
missing_from_plex, missing_from_plex_jellyfin, orphaned_in_plex = self.compare_libraries()
|
||||||
|
|
||||||
|
print(f"\n📊 SUMMARY:")
|
||||||
|
print(f" Series missing from Plex (found in filesystem): {len(missing_from_plex)}")
|
||||||
|
print(f" Series missing from Plex (found in Jellyfin): {len(missing_from_plex_jellyfin)}")
|
||||||
|
print(f" Series in Plex but not in filesystem: {len(orphaned_in_plex)}")
|
||||||
|
|
||||||
|
if missing_from_plex:
|
||||||
|
print(f"\n❌ TV SERIES IN FILESYSTEM BUT MISSING FROM PLEX ({len(missing_from_plex)}):")
|
||||||
|
print("-" * 80)
|
||||||
|
for i, (title, info) in enumerate(sorted(missing_from_plex.items()), 1):
|
||||||
|
print(f"\n{i}. {title}")
|
||||||
|
print(f" Path: {info['path']}")
|
||||||
|
print(f" Episodes: {info['episode_count']}")
|
||||||
|
print(f" In Jellyfin: {'✓ Yes' if info['in_jellyfin'] else '✗ No'}")
|
||||||
|
if info['jellyfin_title']:
|
||||||
|
print(f" Jellyfin Title: {info['jellyfin_title']}")
|
||||||
|
print(f" Jellyfin Episodes: {info['jellyfin_episode_count']}")
|
||||||
|
|
||||||
|
if missing_from_plex_jellyfin and len(missing_from_plex_jellyfin) != len(missing_from_plex):
|
||||||
|
print(f"\n❌ TV SERIES IN JELLYFIN BUT MISSING FROM PLEX ({len(missing_from_plex_jellyfin)}):")
|
||||||
|
print("-" * 80)
|
||||||
|
for i, (key, info) in enumerate(sorted(missing_from_plex_jellyfin.items()), 1):
|
||||||
|
print(f"\n{i}. {info['title']}")
|
||||||
|
print(f" Path: {info['path']}")
|
||||||
|
print(f" Episodes: {info['episode_count']}")
|
||||||
|
print(f" In Filesystem: {'✓ Yes' if info['in_filesystem'] else '✗ No'}")
|
||||||
|
if info['filesystem_path']:
|
||||||
|
print(f" Filesystem Path: {info['filesystem_path']}")
|
||||||
|
print(f" Filesystem Episodes: {info['filesystem_episode_count']}")
|
||||||
|
|
||||||
|
if orphaned_in_plex:
|
||||||
|
print(f"\n⚠️ TV SERIES IN PLEX BUT NOT IN FILESYSTEM ({len(orphaned_in_plex)}):")
|
||||||
|
print("-" * 80)
|
||||||
|
for i, (title, info) in enumerate(sorted(orphaned_in_plex.items()), 1):
|
||||||
|
print(f"\n{i}. {title}")
|
||||||
|
if info.get('path'):
|
||||||
|
print(f" Path: {info['path']}")
|
||||||
|
print(f" Episodes: {info.get('episode_count', 0)}")
|
||||||
|
|
||||||
|
# Save detailed report to JSON
|
||||||
|
report_data = {
|
||||||
|
'missing_from_plex': missing_from_plex,
|
||||||
|
'missing_from_plex_jellyfin': missing_from_plex_jellyfin,
|
||||||
|
'orphaned_in_plex': orphaned_in_plex
|
||||||
|
}
|
||||||
|
|
||||||
|
report_file = Path('series_comparison_report.json')
|
||||||
|
with open(report_file, 'w') as f:
|
||||||
|
json.dump(report_data, f, indent=2, default=str)
|
||||||
|
|
||||||
|
print(f"\n💾 Detailed report saved to: {report_file}")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Get configuration from environment variables
|
||||||
|
plex_url = os.getenv('PLEX_URL', 'http://localhost:32400')
|
||||||
|
plex_token = os.getenv('PLEX_TOKEN')
|
||||||
|
jellyfin_url = os.getenv('JELLYFIN_URL', 'http://localhost:8096')
|
||||||
|
jellyfin_api_key = os.getenv('JELLYFIN_API_KEY')
|
||||||
|
jellyfin_user_id = os.getenv('JELLYFIN_USER_ID')
|
||||||
|
|
||||||
|
# Support multiple series paths
|
||||||
|
series_paths = []
|
||||||
|
series_path_primary = os.getenv('SERIES_PATH', os.getenv('TV_PATH'))
|
||||||
|
if series_path_primary:
|
||||||
|
series_paths.append(series_path_primary)
|
||||||
|
|
||||||
|
# Check for additional paths (SERIES_PATH_2, SERIES_PATH_3, etc.)
|
||||||
|
for i in range(2, 10):
|
||||||
|
extra_path = os.getenv(f'SERIES_PATH_{i}')
|
||||||
|
if extra_path:
|
||||||
|
series_paths.append(extra_path)
|
||||||
|
|
||||||
|
if not series_paths:
|
||||||
|
print("Error: No SERIES_PATH or TV_PATH set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Validate required configuration
|
||||||
|
if not plex_token:
|
||||||
|
print("Error: PLEX_TOKEN not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not jellyfin_api_key:
|
||||||
|
print("Error: JELLYFIN_API_KEY not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not jellyfin_user_id:
|
||||||
|
print("Error: JELLYFIN_USER_ID not set in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Configured series paths: {series_paths}")
|
||||||
|
|
||||||
|
comparator = SeriesLibraryComparator(
|
||||||
|
plex_url=plex_url,
|
||||||
|
plex_token=plex_token,
|
||||||
|
jellyfin_url=jellyfin_url,
|
||||||
|
jellyfin_api_key=jellyfin_api_key,
|
||||||
|
jellyfin_user_id=jellyfin_user_id,
|
||||||
|
series_paths=series_paths
|
||||||
|
)
|
||||||
|
|
||||||
|
comparator.generate_report()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
||||||
22
config.example.txt
Normal file
22
config.example.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Jellyfin-Plex Library Checker Configuration
|
||||||
|
# Copy this to .env and fill in your actual values
|
||||||
|
|
||||||
|
# Plex Configuration
|
||||||
|
PLEX_URL=http://localhost:32400
|
||||||
|
PLEX_TOKEN=your_plex_token_here
|
||||||
|
|
||||||
|
# Jellyfin Configuration
|
||||||
|
JELLYFIN_URL=http://localhost:8096
|
||||||
|
JELLYFIN_API_KEY=your_jellyfin_api_key_here
|
||||||
|
# NOTE: JELLYFIN_USER_ID must be a UUID, not a username!
|
||||||
|
# Get it from: Dashboard → Users → Click your user → Copy ID from URL
|
||||||
|
JELLYFIN_USER_ID=a1b2c3d4-5678-90ab-cdef-1234567890ab
|
||||||
|
|
||||||
|
# Filesystem Configuration
|
||||||
|
MOVIES_PATH=/srv/pool/multimedia/media/Movies
|
||||||
|
SERIES_PATH=/srv/pool/multimedia/media/Series
|
||||||
|
|
||||||
|
# Optional: Additional series paths (supports up to SERIES_PATH_9)
|
||||||
|
# SERIES_PATH_2=/srv/backups/Series
|
||||||
|
# SERIES_PATH_3=/mnt/external/TV
|
||||||
|
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1764667669,
|
||||||
|
"narHash": "sha256-7WUCZfmqLAssbDqwg9cUDAXrSoXN79eEEq17qhTNM/Y=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "418468ac9527e799809c900eda37cbff999199b6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
97
flake.nix
Normal file
97
flake.nix
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
description = "Jellyfin-Plex Library Comparator";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Python environment with pip and build tools
|
||||||
|
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||||
|
pip
|
||||||
|
setuptools
|
||||||
|
wheel
|
||||||
|
virtualenv
|
||||||
|
]);
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
apps = {
|
||||||
|
compare-movies = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pkgs.writeShellScript "compare-movies" ''
|
||||||
|
cd ${./.}
|
||||||
|
${pythonEnv}/bin/python compare_movies.py
|
||||||
|
''}";
|
||||||
|
};
|
||||||
|
|
||||||
|
analyze-movies = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pkgs.writeShellScript "analyze-movies" ''
|
||||||
|
cd ${./.}
|
||||||
|
${pythonEnv}/bin/python analyze_movies.py
|
||||||
|
''}";
|
||||||
|
};
|
||||||
|
|
||||||
|
compare-series = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pkgs.writeShellScript "compare-series" ''
|
||||||
|
cd ${./.}
|
||||||
|
${pythonEnv}/bin/python compare_series.py
|
||||||
|
''}";
|
||||||
|
};
|
||||||
|
|
||||||
|
analyze-series = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pkgs.writeShellScript "analyze-series" ''
|
||||||
|
cd ${./.}
|
||||||
|
${pythonEnv}/bin/python analyze_series.py
|
||||||
|
''}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
pythonEnv
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "🐍 Setting up Python environment..."
|
||||||
|
|
||||||
|
# Create virtual environment if it doesn't exist
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
${pythonEnv}/bin/python -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install/upgrade dependencies
|
||||||
|
echo "Installing Python dependencies from requirements.txt..."
|
||||||
|
pip install --upgrade pip setuptools wheel --quiet
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Python environment ready!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Available commands:"
|
||||||
|
echo " Movies: python compare_movies.py | nix run .#compare-movies"
|
||||||
|
echo " python analyze_movies.py | nix run .#analyze-movies"
|
||||||
|
echo ""
|
||||||
|
echo " Series: python compare_series.py | nix run .#compare-series"
|
||||||
|
echo " python analyze_series.py | nix run .#analyze-series"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user