diff --git a/.env.template b/.env.template index a781099..b7070a9 100644 --- a/.env.template +++ b/.env.template @@ -90,6 +90,14 @@ ALLOW_FILE_DATE_FALLBACK=false # digital = VOD/streaming release, physical = DVD/Blu-ray, theatrical = cinema release RELEASE_DATE_PRIORITY=digital,physical,theatrical +# Smart date validation: automatically prefer theatrical over unreasonably late digital/physical dates +# Example: "The Craft (1996)" theatrical vs "The Craft (2022)" physical โ†’ chooses 1996 theatrical +ENABLE_SMART_DATE_VALIDATION=true + +# Maximum reasonable gap between theatrical and digital/physical release (in years) +# If digital/physical is more than this many years after theatrical, prefer theatrical instead +MAX_RELEASE_DATE_GAP_YEARS=10 + # When to query APIs: always, if_missing, never MOVIE_POLL_MODE=always diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md deleted file mode 100644 index 31d8e41..0000000 --- a/API_ENDPOINTS.md +++ /dev/null @@ -1,218 +0,0 @@ -# NFOGuard API Endpoints - -## Health & Status Endpoints - -### GET `/health` -Health check endpoint with comprehensive status information. - -**Example:** -```bash -curl http://localhost:8080/health -``` - -**Response:** -```json -{ - "status": "healthy", - "version": "0.5.0", - "uptime": "0:15:23.456789", - "database_status": "healthy", - "radarr_database": { - "status": "healthy", - "database_type": "postgresql", - "connection": "readable", - "readable": true, - "tables_exist": true, - "sample_data": true, - "functional": true, - "movie_count": 1250, - "history_count": 5847, - "movies_with_imdb": 1248, - "connection_info": { - "type": "postgresql", - "host": "postgres", - "port": 5432, - "database": "radarr-main" - }, - "tested_at": "2025-09-08T17:30:15+00:00" - } -} -``` - -### GET `/stats` -Database statistics for NFOGuard's internal database. - -**Example:** -```bash -curl http://localhost:8080/stats -``` - -### GET `/batch/status` -Current batch processing queue status. - -**Example:** -```bash -curl http://localhost:8080/batch/status -``` - -## Debug & Testing Endpoints - -### GET `/debug/movie/{imdb_id}` -**Primary testing endpoint** - Debug movie import date detection for a specific IMDb ID. - -**Examples:** -```bash -# Test with your existing command -curl http://localhost:8080/debug/movie/tt1674782 - -# Test other movies -curl http://localhost:8080/debug/movie/tt1596343 # Fast & Furious 6 -curl http://localhost:8080/debug/movie/tt0468569 # The Dark Knight -``` - -**Response includes:** -- Movie lookup results (database vs API) -- Import date detection with performance metrics -- Source information (radarr:db.history.import vs radarr:api.history.import) -- Fallback chain details - -### GET `/debug/movie/{imdb_id}/history` -Detailed history analysis for a movie (if available in your version). - -## Webhook Endpoints - -### POST `/webhook/radarr` -Radarr webhook endpoint for real-time processing. - -**Test webhook manually:** -```bash -# Simulate a Radarr Download event -curl -X POST http://localhost:8080/webhook/radarr \ - -H "Content-Type: application/json" \ - -d '{ - "eventType": "Download", - "movie": { - "id": 123, - "imdbId": "tt1674782", - "title": "The Hobbit: An Unexpected Journey", - "year": 2012 - }, - "movieFile": { - "path": "/movies/The Hobbit An Unexpected Journey (2012) [imdb-tt1674782]/The Hobbit An Unexpected Journey (2012).mkv" - } - }' -``` - -### POST `/webhook/sonarr` -Sonarr webhook endpoint for TV shows. - -**Test webhook manually:** -```bash -# Simulate a Sonarr Download event -curl -X POST http://localhost:8080/webhook/sonarr \ - -H "Content-Type: application/json" \ - -d '{ - "eventType": "Download", - "series": { - "imdbId": "tt0944947", - "title": "Game of Thrones" - }, - "episodes": [{ - "seasonNumber": 1, - "episodeNumber": 1 - }] - }' -``` - -## Manual Processing Endpoints - -### POST `/manual/scan` -Trigger manual library scans. - -**Examples:** -```bash -# Scan everything -curl -X POST "http://localhost:8080/manual/scan?scan_type=both" - -# Scan only movies -curl -X POST "http://localhost:8080/manual/scan?scan_type=movies" - -# Scan only TV shows -curl -X POST "http://localhost:8080/manual/scan?scan_type=tv" - -# Scan specific path -curl -X POST "http://localhost:8080/manual/scan?path=/media/movies" -``` - -## Testing Database Performance - -### Run Performance Tests -Use the included test script to compare database vs API performance: - -```bash -# Inside NFOGuard container or with proper environment -python test_db_performance.py -``` - -### Environment Setup for Testing - -**For PostgreSQL (Recommended):** -```env -RADARR_DB_TYPE=postgresql -RADARR_DB_HOST=postgres_host -RADARR_DB_PORT=5432 -RADARR_DB_NAME=radarr-main -RADARR_DB_USER=postgres -RADARR_DB_PASSWORD=your_password -``` - -**For SQLite:** -```env -RADARR_DB_TYPE=sqlite -RADARR_DB_PATH=/config/radarr.db -``` - -## Performance Testing Workflow - -1. **Test your existing command** (should now use database if configured): - ```bash - curl http://localhost:8080/debug/movie/tt1674782 - ``` - -2. **Check health** to verify database connectivity: - ```bash - curl http://localhost:8080/health - ``` - -3. **Run performance comparison**: - ```bash - python test_db_performance.py - ``` - -4. **Test webhook simulation** with a movie in your library: - ```bash - curl -X POST http://localhost:8080/webhook/radarr \ - -H "Content-Type: application/json" \ - -d '{ - "eventType": "Download", - "movie": {"imdbId": "tt1674782", "title": "Test Movie"}, - "movieFile": {"path": "/path/to/movie.mkv"} - }' - ``` - -## Expected Performance Improvements - -With database access enabled, you should see: -- **Movie lookups**: 5-10x faster -- **Import date detection**: 10-20x faster -- **Bulk operations**: 50x+ faster -- **Reduced API calls**: 90%+ reduction -- **Log output**: Much cleaner, fewer "pages and pages" messages - -## Troubleshooting - -If database access fails, NFOGuard automatically falls back to API methods. Check the logs for: -- `"Enhanced performance mode: Direct database access enabled"` (success) -- `"Failed to initialize database client, using API fallback"` (fallback) - -The `/health` endpoint will show detailed database status and any connection issues. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c527b1..b3e765c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ All notable changes to this project will be documented in this file. - **Automatic Loading**: Both files loaded at startup with python-dotenv - **Log Security**: API keys automatically masked in all log output - **Git Protection**: Updated .gitignore prevents accidental commits of sensitive data +- **๐Ÿง  Smart Date Validation**: Intelligent release date selection with configurable gap detection + - **Automatic Detection**: Prefers theatrical dates when digital/physical are unreasonably late + - **Configurable**: `MAX_RELEASE_DATE_GAP_YEARS=10` (default: 10 years maximum gap) + - **Example**: "The Craft (1996)" prefers 1996 theatrical over 2022 physical release + - **Toggle**: `ENABLE_SMART_DATE_VALIDATION=true` to enable/disable feature +- **๐Ÿงน Repository Cleanup**: Removed legacy and duplicate files + - **Removed**: `media_date_cache.py`, `webhook_server.py` (replaced by nfoguard.py) + - **Removed**: Outdated documentation files (`API_ENDPOINTS.md`, `MIGRATION.md`, `STRUCTURE.md`) + - **Streamlined**: Cleaner codebase with only essential files - **๐ŸŽฏ Configurable Release Date Priority System**: Revolutionary smart fallback for manual imports - **Configurable Priority Order**: `RELEASE_DATE_PRIORITY=digital,physical,theatrical` (customizable) - **Digital Releases**: VOD/streaming dates (Netflix, iTunes, etc.) - TMDB type 4 diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 6c3f28b..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,163 +0,0 @@ -# NFOGuard v0.2.0 - Migration Guide - -## ๐Ÿšจ **BREAKING CHANGES** - Complete Refactor - -### **What Changed** -- **Complete modular rewrite** addressing path mapping and Radarr performance issues -- **Single consolidated application** (`nfoguard.py`) replaces `webhook_server.py` + `media_date_cache.py` -- **Enhanced path mapping** between container and Radarr/Sonarr paths -- **Optimized Radarr client** with early termination and better import detection -- **Updated environment variables** for better configuration - ---- - -## ๐Ÿ“‹ **Required Actions for Deployment** - -### **1. Update Environment Variables** -Add these **CRITICAL** new variables to your `.env`: - -```bash -# Path mapping - REQUIRED for proper operation -RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6 -SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6 - -# Download detection - helps identify real imports vs renames -DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/sabnzbd/,/completed/,/torrents/ - -# Movie processing strategy - NEW options -MOVIE_PRIORITY=import_then_digital # or digital_then_import -MOVIE_POLL_MODE=always # always, if_missing, never -MOVIE_DATE_UPDATE_MODE=backfill_only # or overwrite -``` - -### **2. Update Docker Configuration** -Your build will automatically use the new structure, but verify: -- โœ… Dockerfile now copies `/clients/`, `/core/`, `/scripts/` directories -- โœ… Main command changed to `python nfoguard.py` -- โœ… Version management system in place - -### **3. Git Runner Build** -Your existing CI/CD should work with these changes: -- All new modular files will be included -- Version automatically incremented to `0.2.0` -- Container will start with `nfoguard.py` instead of `webhook_server.py` - ---- - -## ๐Ÿ”ง **Key Problem Fixes** - -### **Path Mapping Issue** โœ… **SOLVED** -**Problem:** Container sees `/media/movies` but Radarr sees `/mnt/unionfs/Media/Movies/movies` - -**Solution:** New `PathMapper` class handles translation: -```python -# Automatically converts between: -Container: /media/movies/Movie [imdb-tt1234567] -Radarr: /mnt/unionfs/Media/Movies/movies/Movie [imdb-tt1234567] -``` - -### **Radarr History Performance** โœ… **SOLVED** -**Problem:** "Pages and pages of Radarr imports" - -**Solution:** New `RadarrClient` with: -- **Early termination** - stops at first real import (80-90% fewer API calls) -- **Better event detection** - distinguishes downloads from renames -- **Smart path analysis** - identifies nzbget/download sources vs media paths - -### **Import Date Accuracy** โœ… **SOLVED** -**Problem:** Hard to find the "right date" - -**Solution:** Enhanced detection chain: -1. **Real import events** from downloads (nzbget โ†’ Radarr) -2. **Digital release dates** from TMDB/Jellyseerr/OMDb -3. **Grab events** as fallback -4. **File mtime** as last resort - ---- - -## ๐Ÿ—‚๏ธ **File Structure Changes** - -### **NEW Structure:** -``` -NFOguard/ -โ”œโ”€โ”€ nfoguard.py # โ† NEW main application -โ”œโ”€โ”€ clients/ # โ† NEW API clients -โ”‚ โ”œโ”€โ”€ radarr_client.py # โ† Enhanced Radarr client -โ”‚ โ”œโ”€โ”€ sonarr_client.py # โ† Extracted Sonarr client -โ”‚ โ””โ”€โ”€ external_clients.py # โ† TMDB/OMDb/Jellyseerr -โ”œโ”€โ”€ core/ # โ† NEW business logic -โ”‚ โ”œโ”€โ”€ database.py # โ† Database operations -โ”‚ โ”œโ”€โ”€ nfo_manager.py # โ† NFO file handling -โ”‚ โ””โ”€โ”€ path_mapper.py # โ† Path translation -โ”œโ”€โ”€ VERSION # โ† Version file -โ”œโ”€โ”€ Dockerfile # โ† Updated for new structure -โ””โ”€โ”€ .env.example # โ† Updated variables -``` - -### **OLD Files (can remove after successful deployment):** -- ~~`webhook_server.py`~~ โ†’ Replaced by `nfoguard.py` -- ~~`media_date_cache.py`~~ โ†’ Split into `/clients/` and `/core/` - ---- - -## ๐Ÿงช **Testing Checklist** - -### **Before Deployment:** -- [ ] Update `.env` with new variables (especially `RADARR_ROOT_FOLDERS`, `SONARR_ROOT_FOLDERS`) -- [ ] Commit all new files to git -- [ ] Verify git runner builds successfully -- [ ] Check container logs for startup - -### **After Deployment:** -- [ ] Test webhook endpoints: `/health`, `/stats` -- [ ] Send test webhook from Radarr/Sonarr -- [ ] Check logs for path mapping success -- [ ] Verify movie/TV processing works -- [ ] Monitor Radarr API call reduction - -### **Rollback Plan (if needed):** -1. Revert to previous git commit -2. Rebuild container with old `webhook_server.py` -3. Restore old environment variables - ---- - -## ๐Ÿ“Š **Expected Improvements** - -### **Performance:** -- **80-90% reduction** in Radarr API calls (early termination) -- **Faster webhook processing** (better caching and event detection) -- **Reduced container memory usage** (modular architecture) - -### **Reliability:** -- **Proper path mapping** (no more path mismatch issues) -- **Better import detection** (distinguishes real downloads from renames) -- **Enhanced error handling** (modular components with clear interfaces) - -### **Maintainability:** -- **Smaller, focused modules** (easier debugging) -- **Clear separation of concerns** (API clients, business logic, database) -- **Comprehensive logging** (better troubleshooting) - ---- - -## ๐Ÿ†˜ **Support** - -### **Common Issues:** -1. **"Movie directory not found"** โ†’ Check `RADARR_ROOT_FOLDERS` mapping -2. **"No real import events found"** โ†’ Check `DOWNLOAD_PATH_INDICATORS` -3. **"Series directory not found"** โ†’ Check `SONARR_ROOT_FOLDERS` mapping - -### **Debug Commands:** -```bash -# Check version -curl http://localhost:8080/health - -# Check path mapping -docker logs nfoguard_container | grep "Mapped.*path" - -# Check API connectivity -docker logs nfoguard_container | grep "Radarr\|Sonarr" -``` - -This refactor addresses all your core issues while maintaining compatibility with your git-based deployment workflow. \ No newline at end of file diff --git a/STRUCTURE.md b/STRUCTURE.md deleted file mode 100644 index 165c542..0000000 --- a/STRUCTURE.md +++ /dev/null @@ -1,137 +0,0 @@ -# NFOGuard Code Organization - -## New Modular Structure - -### `/clients/` - API Clients -- **`radarr_client.py`** - Enhanced Radarr API client with optimized history parsing - - Improved event type detection for real imports vs upgrades/renames - - Early termination when first import found (stops scanning unnecessary history) - - Better path analysis for download source detection - - Fallback strategies for missing data - -- **`sonarr_client.py`** - Sonarr API client extracted from media_date_cache.py - - Series lookup by IMDb ID or title - - Episode import history analysis - - Enhanced error handling and retries - -### `/core/` - Core Functionality -- **`nfo_manager.py`** - NFO file creation and management - - Movie and TV episode NFO creation - - XML parsing and pretty-printing - - IMDb ID extraction from paths and NFO files - - File/directory timestamp management - - Orphaned NFO cleanup - -- **`database.py`** - Database operations - - TV series and episode management - - Movie metadata storage - - Statistics and health checks - - Orphaned record cleanup - - Schema management and migrations - -### Current Files (to be refactored) -- **`webhook_server.py`** - Main FastAPI application (1977+ lines) - - Should be split into smaller modules - - Contains duplicate client code that can now use `/clients/` - - Mix of concerns that should use `/core/` modules - -- **`media_date_cache.py`** - TV processing logic - - Can be simplified to use new client modules - - Focus on coordination rather than API calls - -## Benefits of New Structure - -### 1. **Separation of Concerns** -- API clients handle only API communication -- Core modules handle only business logic -- Database operations isolated and testable -- NFO management centralized - -### 2. **Improved Radarr Data Extraction** -- **Early Termination**: Stops querying when first real import found -- **Better Event Detection**: Distinguishes real downloads from renames/upgrades -- **Path Analysis**: Improved detection of download vs existing file operations -- **Reduced API Calls**: Optimized pagination and caching - -### 3. **Enhanced Testing & Debugging** -- Each module can be tested independently -- Clear interfaces between components -- Easier to debug specific API issues -- Modular replacement of components - -### 4. **Better Maintainability** -- Smaller, focused files -- Clear responsibilities -- Easier to add new features -- Simpler code reviews - -## Migration Complete โœ… - -### **OLD FILES (can be removed):** -- `webhook_server.py` โ†’ Replaced by `nfoguard.py` -- `media_date_cache.py` โ†’ Functionality distributed to `/clients/` and `/core/` - -### **NEW MAIN APPLICATION:** -- **`nfoguard.py`** - Single consolidated application - - Uses all new modular components - - Handles both TV and movie processing - - Improved path mapping and date detection - - Cleaner webhook handling - -### **BREAKING CHANGES in v0.2.0:** -1. **Environment Variables Changed:** - - Added `RADARR_ROOT_FOLDERS` and `SONARR_ROOT_FOLDERS` - - Added `DOWNLOAD_PATH_INDICATORS` - - Updated `MOVIE_PRIORITY`, `MOVIE_POLL_MODE`, `MOVIE_DATE_UPDATE_MODE` - -2. **Docker Command Changed:** - - OLD: `CMD ["python", "webhook_server.py"]` - - NEW: `CMD ["python", "nfoguard.py"]` - -3. **File Structure:** - - Must copy `/clients/` and `/core/` directories - - Must copy `VERSION` file - -## Key Improvements for Radarr Issues - -### **Problem**: "Pages and pages of Radarr history" -**Solution**: `RadarrClient.earliest_import_event_optimized()` -- Processes events chronologically (oldest first) -- Stops immediately when first real import found -- Uses smaller page sizes (50 vs 1000) -- Early termination reduces API calls by 80-90% - -### **Problem**: "Determining the right date" -**Solution**: Enhanced event analysis -- Distinguishes download imports from renames/upgrades -- Path-based detection of download sources -- Fallback chain: real import โ†’ grab event โ†’ file dateAdded - -### **Problem**: "Matching right movieID to right file on disk" -**Solution**: Multiple lookup strategies -- Direct IMDb lookup with validation -- All-movies scan with filtering -- Lookup endpoint with verification -- Prevents wrong movie matching - -## Usage Examples - -```python -# New Radarr client -from clients.radarr_client import RadarrClient -client = RadarrClient(base_url, api_key) -movie = client.movie_by_imdb("tt1596343") -import_date, source = client.get_movie_import_date(movie_id) - -# NFO management -from core.nfo_manager import NFOManager -nfo = NFOManager("NFOGuard") -nfo.create_movie_nfo(movie_dir, imdb_id, dateadded, released, source) - -# Database operations -from core.database import NFOGuardDatabase -db = NFOGuardDatabase(db_path) -db.upsert_movie_dates(imdb_id, released, dateadded, source, has_video=True) -``` - -This modular structure addresses the core issues while making the codebase more maintainable and testable. \ No newline at end of file diff --git a/TEST_PRIORITY_SYSTEM.md b/TEST_PRIORITY_SYSTEM.md deleted file mode 100644 index cb19055..0000000 --- a/TEST_PRIORITY_SYSTEM.md +++ /dev/null @@ -1,137 +0,0 @@ -# NFOGuard Release Date Priority System - Testing Guide - -## ๐Ÿงช Test Your Configured Priority System - -### 1. **Test "The Craft (1996)" - Theatrical Fallback** -```bash -# Test step-by-step TMDB lookup -curl "http://localhost:8080/debug/tmdb/tt0115963" - -# Expected: digital=0, physical=multiple, theatrical=1996-05-03 -# Should show: no digital releases, DVD from 2000, theatrical from 1996 - -# Test priority logic -curl "http://localhost:8080/debug/movie/tt0115963/priority" - -# Expected result with your config (digital,physical,theatrical): -# - digital: not available -# - physical: 2000-09-12 (too late - DVD release) -# - theatrical: 1996-05-03 โ† SELECTED (best option) -``` - -### 2. **Test Modern Movie - Digital Priority** -```bash -# Test a recent movie like Top Gun Maverick -curl "http://localhost:8080/debug/tmdb/tt1745960" - -# Expected: digital, physical, and theatrical dates all available -# Should show your priority order preference - -curl "http://localhost:8080/debug/movie/tt1745960/priority" - -# Expected with your config (digital,physical,theatrical): -# - digital: 2022-08-23 โ† SELECTED (first in priority) -# - physical: 2022-10-31 (skipped) -# - theatrical: 2022-05-27 (skipped) -``` - -### 3. **Test Different Priority Orders** - -**Your Current Config**: `RELEASE_DATE_PRIORITY=digital,physical,theatrical` -```bash -curl "http://localhost:8080/debug/movie/tt1745960/priority" -# Should select: digital date -``` - -**Alternative Config** (edit .env temporarily): `RELEASE_DATE_PRIORITY=theatrical,digital,physical` -```bash -# Restart container after changing .env -docker-compose restart - -curl "http://localhost:8080/debug/movie/tt1745960/priority" -# Should now select: theatrical date (earliest option) -``` - -### 4. **Test File Date Fallback Logic** - -**Movies with manual imports** (showing `radarr:db.file.dateAdded`): -```bash -curl "http://localhost:8080/debug/movie/tt0115963/priority" - -# Expected response: -# { -# "release_date_priority": ["digital", "physical", "theatrical"], -# "date_sources": { -# "radarr_import": {"date": "2025-08-29...", "source": "radarr:db.file.dateAdded"}, -# "digital_release": {"date": "1996-05-03...", "source": "tmdb:theatrical"} -# }, -# "file_date_detected": true, -# "would_prefer_digital": true, -# "selected_date": "1996-05-03T00:00:00+00:00", -# "selected_source": "tmdb:theatrical (preferred over file date)" -# } -``` - -### 5. **Verify NFO Source Annotations** - -After processing a movie: -```bash -# Process the movie -curl -X POST "http://localhost:8080/manual/scan?scan_type=movies" - -# Check the created NFO file -cat "/media/Movies/movies/The Craft (1996) [imdb-tt0115963]/movie.nfo" - -# Should contain at the end: -# -# -``` - -## ๐ŸŽฏ Expected Behavior by Movie Type - -### **Pre-Digital Era Movies (1990s-early 2000s)** -- **Digital**: โŒ Not available -- **Physical**: โš ๏ธ Often years later (DVD era) -- **Theatrical**: โœ… **Most accurate** (selected) -- **Example**: The Craft, Titanic, The Matrix - -### **Transition Era Movies (2000s-2010s)** -- **Digital**: โš ๏ธ Limited early VOD -- **Physical**: โœ… **Often most relevant** (DVD/Blu-ray boom) -- **Theatrical**: โœ… Available -- **Example**: Dark Knight, Avatar, Iron Man - -### **Modern Movies (2010s+)** -- **Digital**: โœ… **Most relevant** (streaming era) -- **Physical**: โœ… Available but later -- **Theatrical**: โœ… Available -- **Example**: Top Gun Maverick, Avengers, Everything Everywhere - -## ๐Ÿ”ง Troubleshooting Priority System - -### No Release Dates Found -```bash -curl "http://localhost:8080/debug/tmdb/tt0123456" -# Check if movie exists in TMDB and what release data is available -``` - -### Unexpected Date Selection -```bash -curl "http://localhost:8080/debug/movie/tt0123456/priority" -# Shows exactly which dates were found and why one was selected -``` - -### Priority Order Not Working -1. Check your `.env` configuration -2. Restart container: `docker-compose restart` -3. Verify with debug endpoint: shows `"release_date_priority"` array - -## ๐ŸŽ‰ Success Indicators - -- โœ… Old movies use theatrical dates instead of file dates -- โœ… New movies use digital dates per your preference -- โœ… NFO files show source in comments -- โœ… Priority order is respected and configurable -- โœ… Smart fallbacks prevent unrealistic dates - -Your NFOGuard now has **intelligent, configurable date selection** that adapts to each movie's era and available release data! ๐ŸŽฏ \ No newline at end of file diff --git a/VERSION b/VERSION index 4b9fcbe..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.6.0 diff --git a/clients/external_clients.py b/clients/external_clients.py index c796c53..cfcb38c 100644 --- a/clients/external_clients.py +++ b/clients/external_clients.py @@ -287,8 +287,8 @@ class ExternalClientManager: self.omdb = OMDbClient() self.jellyseerr = JellyseerrClient() - def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str]) -> Optional[Tuple[str, str]]: - """Get release date using configurable priority order""" + def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str], enable_smart_validation: bool = True) -> Optional[Tuple[str, str]]: + """Get release date using configurable priority order with smart date validation""" # Get all possible release dates release_options = {} @@ -326,13 +326,66 @@ class ExternalClientManager: earliest_jellyseerr = min(jellyseerr_dates) release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital") - # Return first available option according to priority + # Smart date validation: Check if priority order makes sense given the actual dates + if enable_smart_validation and len(release_options) > 1: + validated_choice = self._validate_date_choice(release_options, priority_order) + if validated_choice: + return validated_choice + + # Return first available option according to priority (fallback behavior) for priority in priority_order: if priority in release_options: return release_options[priority] return None + def _validate_date_choice(self, release_options: Dict[str, Tuple[str, str]], priority_order: List[str]) -> Optional[Tuple[str, str]]: + """Validate date choice and prefer theatrical if digital/physical are unreasonably late""" + from datetime import datetime, timezone + import os + + # Get configuration for maximum gap (default: 10 years) + max_reasonable_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) + + # Parse all available dates + parsed_dates = {} + for release_type, (date_str, source) in release_options.items(): + try: + parsed_dates[release_type] = (datetime.fromisoformat(date_str.replace('Z', '+00:00')), source) + except Exception: + continue + + if not parsed_dates or "theatrical" not in parsed_dates: + return None # No smart validation possible without theatrical date + + theatrical_date, theatrical_source = parsed_dates["theatrical"] + + # Check each priority option against theatrical date + for priority in priority_order: + if priority == "theatrical": + continue # Skip theatrical in this validation + + if priority in parsed_dates: + priority_date, priority_source = parsed_dates[priority] + + # Calculate the gap in years + gap = (priority_date - theatrical_date).days / 365.25 + + # If the gap is too large, skip this priority and continue + if gap > max_reasonable_gap_years: + print(f"[SMART VALIDATION] {priority} date {priority_date.strftime('%Y-%m-%d')} is {gap:.1f} years after theatrical {theatrical_date.strftime('%Y-%m-%d')}, preferring theatrical") + continue + + # This priority option is reasonable, use it + return (priority_date.isoformat(timespec="seconds"), f"{priority_source} (validated)") + + # If all priority options are unreasonable, fall back to theatrical + if "theatrical" in release_options: + theatrical_date_str, theatrical_source = release_options["theatrical"] + return (theatrical_date_str, f"{theatrical_source} (smart fallback)") + + return None + def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]: """Get digital release date candidates from all sources (legacy method)""" candidates = [] diff --git a/media_date_cache.py b/media_date_cache.py deleted file mode 100644 index 3ea74ae..0000000 --- a/media_date_cache.py +++ /dev/null @@ -1,1270 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import os, sys, re, json, sqlite3, argparse, time -from pathlib import Path -from datetime import datetime, timezone -import urllib.request, urllib.parse - -# ========================= -# tiny .env loader + --env anywhere -# ========================= -def _iso_now(): - return datetime.now(timezone.utc).isoformat(timespec="seconds") - -def _log(level, msg): - # Only show DEBUG messages if debug mode is enabled - if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): - return - print(f"[{_iso_now()}] {level}: {msg}") - -def _load_env_file(path: str) -> bool: - p = Path(path) - if not p.exists(): - _log("WARNING", f".env not found path={path}") - return False - loaded = 0 - for raw in p.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - k = k.strip() - v = v.strip().strip('"').strip("'") - os.environ[k] = v - loaded += 1 - # normalize variants - if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: - os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] - if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: - os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] - _log("INFO", f".env loaded from {path} ({loaded} keys)") - return True - -def _preparse_env(argv): - env_path = None - new_argv = [] - i = 0 - while i < len(argv): - a = argv[i] - if a == "--env" and i + 1 < len(argv): - env_path = argv[i + 1] - i += 2 - continue - elif a.startswith("--env="): - env_path = a.split("=", 1)[1] - i += 1 - continue - new_argv.append(a) - i += 1 - if env_path: - if not _load_env_file(env_path): - _log("WARNING", f".env not loaded path={env_path}") - return new_argv - -sys.argv = _preparse_env(sys.argv) - -# ========================= -# DB schema / connect -# ========================= -SCHEMA = """ -PRAGMA journal_mode=WAL; - -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT -); - -CREATE TABLE IF NOT EXISTS series ( - imdb_id TEXT PRIMARY KEY, - series_path TEXT NOT NULL, - last_applied TEXT -); - -CREATE TABLE IF NOT EXISTS episode_dates ( - imdb_id TEXT NOT NULL, - season INTEGER NOT NULL, - episode INTEGER NOT NULL, - aired TEXT, - dateadded TEXT, - source TEXT, - has_video_file INTEGER DEFAULT 0, - PRIMARY KEY (imdb_id, season, episode) -); -""" - -def db_connect(db_path: Path) -> sqlite3.Connection: - db_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.Connection(str(db_path)) - conn.execute("PRAGMA foreign_keys=ON;") - - # Check if tables exist and have correct schema - try: - # Test if episode_dates table exists with correct columns - result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() - columns = [row[1] for row in result] # row[1] is column name - required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] - - if not all(col in columns for col in required_columns): - _log("WARNING", "Database schema outdated, recreating tables...") - conn.execute("DROP TABLE IF EXISTS episode_dates;") - conn.execute("DROP TABLE IF EXISTS series;") - conn.execute("DROP TABLE IF EXISTS meta;") - elif 'has_video_file' not in columns: - _log("INFO", "Adding has_video_file column to episode_dates table") - conn.execute("ALTER TABLE episode_dates ADD COLUMN has_video_file INTEGER DEFAULT 0;") - - except sqlite3.OperationalError: - # Tables don't exist, which is fine - pass - - # Create/recreate schema - conn.executescript(SCHEMA) - - # Verify schema was created correctly - try: - conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") - _log("DEBUG", "Database schema verified") - except sqlite3.OperationalError as e: - _log("ERROR", f"Database schema verification failed: {e}") - raise - - return conn - -# ========================= -# HTTP helpers -# ========================= -def http_get_json(url, headers=None, timeout=45): - req = urllib.request.Request(url, headers=headers or {}) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - data = resp.read() - if not data: - return None - try: - return json.loads(data.decode("utf-8")) - except Exception as e: - _log("WARNING", f"Failed to parse JSON from {url}: {e}") - return None - except urllib.error.HTTPError as e: - _log("ERROR", f"HTTP {e.code} error for {url}: {e.reason}") - if e.code == 401: - _log("ERROR", "Authentication failed - check your API keys") - elif e.code == 429: - _log("WARNING", "Rate limited - waiting before retry") - time.sleep(2) - raise # Re-raise so caller can handle - except urllib.error.URLError as e: - _log("ERROR", f"Network error for {url}: {e}") - return None - except Exception as e: - _log("ERROR", f"Unexpected error for {url}: {e}") - return None - -def to_utc_iso(s): - # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' - if not s: - return None - try: - if len(s) == 10 and s[4] == "-" and s[7] == "-": - # date only -> midnight UTC - return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") - # normalize Z to +00:00 - s2 = s.replace("Z", "+00:00") - dt = datetime.fromisoformat(s2) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - else: - dt = dt.astimezone(timezone.utc) - return dt.isoformat(timespec="seconds") - except Exception: - return None - -def iso_from_epoch(epoch): - try: - return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") - except Exception: - return None - -def file_mtime_iso(p: Path): - try: - return iso_from_epoch(p.stat().st_mtime) - except Exception: - return None - -# ========================= -# Source clients -# ========================= -class SonarrClient: - def __init__(self): - self.base = os.environ.get("SONARR_URL", "").rstrip("/") - self.key = os.environ.get("SONARR_API_KEY", "") - self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") - self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") - self.enabled = bool(self.base and self.key) - - def _get(self, path, params=None): - if not self.enabled: return None - q = f"{self.base}/api/v3{path}" - if params: - qs = urllib.parse.urlencode(params) - q = f"{q}?{qs}" - headers = {"X-Api-Key": self.key} - - for attempt in range(self.retries): - try: - j = http_get_json(q, headers=headers, timeout=self.timeout) - if j is not None: - return j - except urllib.error.HTTPError as e: - if e.code == 401: - _log("ERROR", f"Sonarr authentication failed - check SONARR_API_KEY") - return None # Don't retry auth failures - elif e.code == 429: - wait_time = (attempt + 1) * 2 - _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") - time.sleep(wait_time) - else: - _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") - except Exception as e: - _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") - - if attempt < self.retries - 1: - time.sleep(0.5 * (attempt + 1)) # Progressive backoff - - _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {q}") - return None - - def series_by_imdb(self, imdb_id): - # Sonarr supports /series/lookup?term=imdbid:tt1234567 - search_term = f"imdbid:{imdb_id}" - _log("DEBUG", f"Searching Sonarr with term: {search_term}") - - j = self._get("/series/lookup", {"term": search_term}) - if not j: - _log("WARNING", f"No results returned from Sonarr lookup for: {search_term}") - return None - - _log("DEBUG", f"Sonarr lookup returned {len(j)} results") - - # Log all results for debugging - for i, s in enumerate(j): - series_imdb = s.get("imdbId", "") - series_title = s.get("title", "") - series_id = s.get("id", "") - _log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}") - - # pick exact imdb id match (case insensitive) - target_imdb = imdb_id.lower() - for s in j: - series_imdb = (s.get("imdbId") or "").lower() - if series_imdb == target_imdb: - _log("INFO", f"Found exact IMDb match: {s.get('title')} (ID: {s.get('id')})") - return s - - # If no exact match, try partial match (in case of formatting differences) - for s in j: - series_imdb = (s.get("imdbId") or "").lower() - if target_imdb in series_imdb or series_imdb in target_imdb: - _log("WARNING", f"Found partial IMDb match: {s.get('title')} (Expected: {imdb_id}, Found: {s.get('imdbId')})") - return s - - _log("WARNING", f"No IMDb match found in {len(j)} results for {imdb_id}") - return None - - def series_by_title(self, title): - """Search for series by title as fallback when IMDb lookup fails""" - _log("DEBUG", f"Searching Sonarr by title: {title}") - - # Try direct search first - j = self._get("/series/lookup", {"term": title}) - if not j: - _log("WARNING", f"No results returned from Sonarr title search for: {title}") - return None - - _log("DEBUG", f"Sonarr title search returned {len(j)} results") - - # Look for exact or close title match - title_lower = title.lower() - for s in j: - series_title = (s.get("title") or "").lower() - if series_title == title_lower: - _log("INFO", f"Found exact title match: {s.get('title')} (ID: {s.get('id')})") - return s - - # If no exact match, try partial match - for s in j: - series_title = (s.get("title") or "").lower() - if title_lower in series_title or series_title in title_lower: - _log("INFO", f"Found partial title match: '{s.get('title')}' for search '{title}' (ID: {s.get('id')})") - return s - - _log("WARNING", f"No title match found for: {title}") - return None - - def get_all_series(self): - """Get all series from Sonarr to find by IMDb ID directly""" - return self._get("/series") or [] - - def series_by_imdb_direct(self, imdb_id): - """Find series by scanning all series for IMDb match (slower but more reliable)""" - _log("DEBUG", f"Trying direct series lookup for IMDb: {imdb_id}") - all_series = self.get_all_series() - - target_imdb = imdb_id.lower() - for series in all_series: - series_imdb = (series.get("imdbId") or "").lower() - if series_imdb == target_imdb: - _log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})") - return series - - _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") - return None - - def episodes_for_series(self, series_id): - return self._get("/episode", {"seriesId": series_id}) or [] - - def episode_file(self, episode_file_id): - return self._get(f"/episodefile/{episode_file_id}") - - def get_episode_import_history(self, episode_id): - """Get the original import date from history, not just the latest""" - # Get ALL history for this episode by fetching multiple pages - all_records = [] - page = 1 - page_size = 100 - - while True: - history = self._get("/history", { - "episodeId": episode_id, - "sortKey": "date", - "sortDir": "asc", - "page": page, - "pageSize": page_size - }) or {} - - records = history.get("records", []) - if not records: - break - - all_records.extend(records) - - # Check if we got all records (if we got less than pageSize, we're done) - if len(records) < page_size: - break - - page += 1 - if page > 10: # Safety valve - don't go crazy - break - - _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") - - # Look for ACTUAL import events (not just renames) - import_events = [] - grabbed_events = [] - - for event in all_records: - event_type = event.get("eventType") - date = event.get("date") - - _log("DEBUG", f"History event: {event_type} at {date}") - - # Primary import event - this is what we want - if event_type == "downloadFolderImported" and date: - import_events.append({ - "date": date, - "sourceTitle": event.get("sourceTitle", ""), - "eventType": event_type - }) - - # Track grab events as secondary indicators - elif event_type == "grabbed" and date: - grabbed_events.append({ - "date": date, - "sourceTitle": event.get("sourceTitle", ""), - "eventType": event_type - }) - - # Use downloadFolderImported events if we have them - GET THE EARLIEST ONE - if import_events: - earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") - import_date = earliest["date"] - _log("INFO", f"Found original import date: {import_date} (downloadFolderImported) for episode {episode_id}") - - # ADDITIONAL CHECK: If this import is very recent compared to rename events, - # and we have rename events from much earlier, this might be an upgrade - _log("DEBUG", f"Starting upgrade detection check for episode {episode_id}") - if import_date: - _log("DEBUG", f"Import date exists: {import_date}") - # Look for rename events that are much earlier than this import - all_renames = [e for e in all_records - if e.get("eventType") == "episodeFileRenamed" - and e.get("date")] - - _log("DEBUG", f"Found {len(all_renames)} rename events for episode {episode_id}") - - if all_renames: - earliest_rename = min(all_renames, key=lambda x: x.get("date", "9999-12-31")) - rename_date = earliest_rename.get("date") - - _log("DEBUG", f"Earliest rename: {rename_date}, Latest import: {import_date}") - - # If rename is significantly earlier (>30 days), prefer it - try: - import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00")) - rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00")) - days_diff = (import_dt - rename_dt).days - - _log("DEBUG", f"Time difference: {days_diff} days (import after rename)") - - if days_diff > 30: - _log("WARNING", f"Import date {import_date} is {days_diff} days after earliest rename {rename_date} - likely upgrade, using rename date") - return rename_date - else: - _log("DEBUG", f"Time difference ({days_diff} days) is acceptable, using import date") - except Exception as e: - _log("DEBUG", f"Error comparing dates: {e}") - else: - _log("DEBUG", f"No rename events found for upgrade detection") - else: - _log("DEBUG", f"No import date for upgrade detection") - - return import_date - - # If no import events but we have grabs, use the earliest grab + some buffer time - # This handles cases where history is incomplete but we know when it was grabbed - if grabbed_events: - earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") - _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") - return earliest_grab["date"] - - _log("WARNING", f"No reliable import events found for episode {episode_id}") - return None - -class TMDBClient: - def __init__(self): - self.api_key = os.environ.get("TMDB_API_KEY", "") - self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") - self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") - self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") - self.enabled = bool(self.api_key) - - def _get(self, path, params=None): - if not self.enabled: return None - base = "https://api.themoviedb.org/3" - params = params or {} - params["api_key"] = self.api_key - url = f"{base}{path}?{urllib.parse.urlencode(params)}" - - for attempt in range(self.retries): - try: - j = http_get_json(url, timeout=self.timeout) - if j is not None: - return j - except urllib.error.HTTPError as e: - if e.code == 401: - _log("ERROR", f"TMDB authentication failed - check TMDB_API_KEY") - return None - elif e.code == 429: - wait_time = (attempt + 1) * 3 - _log("WARNING", f"TMDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") - time.sleep(wait_time) - else: - _log("WARNING", f"TMDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") - except Exception as e: - _log("WARNING", f"TMDB API attempt {attempt+1}/{self.retries} failed: {e}") - - if attempt < self.retries - 1: - time.sleep(1.0 * (attempt + 1)) - - return None - - def tv_id_from_imdb(self, imdb_id): - j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) - if not j: return None - tv = (j.get("tv_results") or []) - if tv: - return tv[0].get("id") - # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? - return None - - def season_airdates(self, tv_id, season_number): - # returns {episode_number: air_date or None} - j = self._get(f"/tv/{tv_id}/season/{season_number}") - res = {} - if not j: return res - for ep in (j.get("episodes") or []): - num = ep.get("episode_number") - air = ep.get("air_date") - if isinstance(num, int) and air: - res[num] = air - return res - -class TVDBClient: - def __init__(self): - self.api_key = os.environ.get("TVDB_API_KEY", "") - self.timeout = int(os.environ.get("TVDB_TIMEOUT", "45") or "45") - self.retries = int(os.environ.get("TVDB_RETRIES", "3") or "3") - self.enabled = bool(self.api_key) - self.token = None - self.token_expires = 0 - - def _get_token(self): - """Get JWT token for TVDB API""" - if self.token and time.time() < self.token_expires: - return self.token - - url = "https://api4.thetvdb.com/v4/login" - headers = {"Content-Type": "application/json"} - data = json.dumps({"apikey": self.api_key}).encode('utf-8') - - try: - req = urllib.request.Request(url, data=data, headers=headers, method='POST') - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - result = json.loads(resp.read().decode('utf-8')) - self.token = result["data"]["token"] - # Token expires in 24 hours, refresh after 23 hours - self.token_expires = time.time() + (23 * 60 * 60) - return self.token - except Exception as e: - _log("ERROR", f"TVDB authentication failed: {e}") - return None - - def _get(self, path, params=None): - if not self.enabled: - return None - - token = self._get_token() - if not token: - return None - - url = f"https://api4.thetvdb.com/v4{path}" - if params: - url += "?" + urllib.parse.urlencode(params) - - headers = {"Authorization": f"Bearer {token}"} - - for attempt in range(self.retries): - try: - j = http_get_json(url, headers=headers, timeout=self.timeout) - if j is not None: - return j - except urllib.error.HTTPError as e: - if e.code == 401: - # Token might be expired, clear it and retry once - if attempt == 0: - self.token = None - self.token_expires = 0 - continue - _log("ERROR", f"TVDB authentication failed - check TVDB_API_KEY") - return None - elif e.code == 429: - wait_time = (attempt + 1) * 2 - _log("WARNING", f"TVDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") - time.sleep(wait_time) - else: - _log("WARNING", f"TVDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") - except Exception as e: - _log("WARNING", f"TVDB API attempt {attempt+1}/{self.retries} failed: {e}") - - if attempt < self.retries - 1: - time.sleep(1.0 * (attempt + 1)) - - return None - - def series_by_imdb(self, imdb_id): - """Find series by IMDb ID""" - j = self._get(f"/search", {"query": imdb_id, "type": "series"}) - if not j or not j.get("data"): - return None - - # Look for exact IMDb match - for series in j["data"]: - remote_ids = series.get("remoteIds") or [] - for remote in remote_ids: - if remote.get("sourceName") == "IMDB" and remote.get("id") == imdb_id: - return series - return None - - def season_episodes(self, series_id, season_number): - """Get episodes for a specific season""" - j = self._get(f"/series/{series_id}/episodes/default", { - "season": season_number, - "page": 0 - }) - - episodes = {} - if not j or not j.get("data"): - return episodes - - for ep in j["data"]["episodes"]: - ep_num = ep.get("number") - aired = ep.get("aired") - if isinstance(ep_num, int) and aired: - episodes[ep_num] = aired - - return episodes - -class OMDbClient: - def __init__(self): - self.api_key = os.environ.get("OMDB_API_KEY", "") - self.enabled = bool(self.api_key) - - def season_airdates(self, imdb_id, season_number): - # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY - if not self.enabled: return {} - params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} - url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) - j = http_get_json(url, timeout=45) - res = {} - if not j or j.get("Response") != "True": - return res - for ep in j.get("Episodes", []): - try: - num = int(ep.get("Episode")) - except Exception: - continue - rel = ep.get("Released") # '2011-12-04' etc - if rel and rel != "N/A": - res[num] = rel - return res - -# ========================= -# NFO helpers -# ========================= -LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) -SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) -IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) - -def remove_long_nfos(season_dir: Path) -> int: - count = 0 - for f in season_dir.glob("*.nfo"): - if LONG_NFO_RE.match(f.name): - try: - f.unlink() - count += 1 - except Exception: - pass - return count - -def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str, lock_metadata: bool = True): - # Ensure the season directory exists - try: - season_dir.mkdir(parents=True, exist_ok=True) - except Exception as e: - _log("ERROR", f"Failed creating season directory {season_dir}: {e}") - return - - name = f"S{season_num:02d}E{ep_num:02d}.nfo" - dst = season_dir / name - xml = [ - '', - "", - f" {season_num}", - f" {ep_num}", - f" {(aired or '').split('T')[0]}", - ] - if dateadded: - xml.append(f" {dateadded}") - # Add premiered tag with same value as dateadded for broader compatibility - xml.append(f" {dateadded.split('T')[0]}") - - # Add lock flag if requested (prevents Emby/Plex from overwriting metadata) - if lock_metadata: - xml.append(" true") - - # Add source comment for debugging - if source: - xml.append(f" ") - xml.append(f" ") - - xml.append("\n") - content = "\n".join(xml) - try: - dst.write_text(content, encoding="utf-8") - except Exception as e: - _log("ERROR", f"Failed writing {dst}: {e}") - -def ensure_season_nfo(season_dir: Path, season_num: int): - # Small, safe season.nfo; don't overwrite if present - p = season_dir / "season.nfo" - if p.exists(): - return - xml = f""" - - {season_num} - -""" - try: - p.write_text(xml, encoding="utf-8") - except Exception as e: - _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") - -def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): - # Don't clobber; only create minimal file if missing - p = series_dir / "tvshow.nfo" - if p.exists(): - return - xml = f""" - - {imdb_id} - {imdb_id} - -""" - try: - p.write_text(xml, encoding="utf-8") - except Exception as e: - _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") - -def set_dir_mtime(path: Path, iso_timestamp: str): - try: - ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() - os.utime(path, (ts, ts), follow_symlinks=False) - except Exception as e: - _log("WARNING", f"Failed to set mtime on {path}: {e}") - -def set_episode_file_mtimes(video_files, dateadded_iso: str): - """Set individual episode file modification times to match the dateadded""" - try: - ts = datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp() - for video_file in video_files: - try: - os.utime(video_file, (ts, ts), follow_symlinks=False) - _log("DEBUG", f"Updated file mtime: {video_file} -> {dateadded_iso}") - except Exception as e: - _log("WARNING", f"Failed to update mtime on {video_file}: {e}") - except Exception as e: - _log("WARNING", f"Bad timestamp for file mtimes: {dateadded_iso}: {e}") - -# ========================= -# Core logic -# ========================= -def parse_imdb_from_path(p: Path): - m = IMDB_TAG_RE.search(p.name) - return m.group(1).lower() if m else None - -def is_series_dir(p: Path) -> bool: - return p.is_dir() and parse_imdb_from_path(p) is not None - -def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection, disk_episodes: dict): - sonarr = SonarrClient() - tmdb = TMDBClient() - omdb = OMDbClient() - - # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) - out = {} - - # If using cache, first load existing data from DB (only for episodes we have on disk) - if args.use_cache: - _log("INFO", f"Checking cache for existing episode data: {imdb_id}") - cached = conn.execute( - "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ? AND has_video_file = 1", - (imdb_id,) - ).fetchall() - - for season, episode, aired, dateadded, source in cached: - key = (season, episode) - if key in disk_episodes: # Only load cached data for episodes we have - out[key] = (aired, dateadded, source) - - _log("INFO", f"Found {len(out)} cached episodes with video files for {imdb_id}") - - # Find which disk episodes we don't have cached yet - cached_episodes = set(out.keys()) - missing_episodes = set(disk_episodes.keys()) - cached_episodes - - if missing_episodes: - _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") - else: - _log("INFO", f"All disk episodes are cached, skipping API queries") - return out - - # Only query APIs if not using cache or if we have missing episodes - need_api_query = not args.use_cache or (args.use_cache and missing_episodes) - - if need_api_query: - # Prefer Sonarr - if sonarr.enabled: - _log("INFO", f"Querying Sonarr for series: {imdb_id}") - s = sonarr.series_by_imdb(imdb_id) - - # If no match by IMDb, try title search - if not s: - series_title = series_dir.name.split('[imdb-')[0].strip() - if series_title: - _log("INFO", f"IMDb lookup failed, trying title search: {series_title}") - s = sonarr.series_by_title(series_title) - - # If still no match, try direct lookup - if not s: - _log("INFO", f"Title search failed, trying direct series scan") - s = sonarr.series_by_imdb_direct(imdb_id) - - if s: - series_id = s.get("id") - if series_id: # Check that series_id is not None - _log("INFO", f"Found Sonarr series ID: {series_id}") - eps = sonarr.episodes_for_series(series_id) - if isinstance(eps, list): - _log("INFO", f"Found {len(eps)} episodes in Sonarr") - for ep in eps: - season_num = ep.get("seasonNumber") - ep_num = ep.get("episodeNumber") - episode_id = ep.get("id") - - if not isinstance(season_num, int) or not isinstance(ep_num, int): - continue - - # Skip episodes we don't have on disk - key = (season_num, ep_num) - if key not in disk_episodes: - continue - - # If using cache, skip episodes we already have unless forced - if args.use_cache and key in out and not args.force_refresh: - continue - - _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") - - air_utc = to_utc_iso(ep.get("airDateUtc")) - date_add = None - src = None - - # Try to get original import date from history first - if episode_id: - import_date = sonarr.get_episode_import_history(episode_id) - if import_date: - date_add = to_utc_iso(import_date) - src = "sonarr:history.downloadFolderImported" - _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") - - # Fallback to episode file dateAdded (but validate it's reasonable) - if not date_add: - ep_file_id = ep.get("episodeFileId") - if ep_file_id: - fobj = sonarr.episode_file(ep_file_id) - if fobj and fobj.get("dateAdded"): - file_date_added = to_utc_iso(fobj.get("dateAdded")) - - # Validate: if dateAdded is way after air date, it's probably a re-import - if air_utc and file_date_added: - try: - air_dt = datetime.fromisoformat(air_utc.replace("Z", "+00:00")) - file_dt = datetime.fromisoformat(file_date_added.replace("Z", "+00:00")) - days_diff = (file_dt - air_dt).days - - # If file was added more than 180 days after air date, treat as suspicious - if days_diff > 180: - _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: dateAdded ({file_date_added}) is {days_diff} days after air date - likely re-import, using air date instead") - date_add = air_utc - src = "sonarr:episode.airDateUtc" - else: - date_add = file_date_added - src = "sonarr:episodeFile.dateAdded" - _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using file dateAdded {date_add} ({days_diff} days after air)") - except Exception: - # If we can't parse dates, just use what we have - date_add = file_date_added - src = "sonarr:episodeFile.dateAdded" - _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: Could not validate dateAdded, using as-is {date_add}") - else: - # No air date to compare against, use dateAdded - date_add = file_date_added - src = "sonarr:episodeFile.dateAdded" - _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No air date for comparison, using file dateAdded {date_add}") - - # Final fallback to air date - if not date_add and air_utc: - date_add = air_utc - src = "sonarr:episode.airDateUtc" - _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No reliable dateAdded found, using air date {date_add}") - - if air_utc or date_add: - out[key] = (air_utc, date_add, src) - else: - _log("WARNING", f"Failed to get episodes for Sonarr series ID: {series_id}") - else: - _log("WARNING", f"Sonarr series found but has no ID for IMDb: {imdb_id}") - else: - _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") - - # Fill gaps with TMDB (only for episodes we have on disk) - if tmdb.enabled: - tv_id = tmdb.tv_id_from_imdb(imdb_id) - if tv_id: - # Only check seasons that have video files - seasons_with_files = {sn for sn, en in disk_episodes.keys()} - for sn in sorted(seasons_with_files): - m = tmdb.season_airdates(tv_id, sn) - for ep_num, air in m.items(): - key = (sn, ep_num) - if key in disk_episodes and (key not in out or not out[key][1]): # Only for episodes we have on disk - air_iso = to_utc_iso(air) # date โ†’ midnight UTC - out[key] = (air_iso, air_iso, "tmdb:air_date") - - # Fill remaining with OMDb (only for episodes we have on disk) - if omdb.enabled: - seasons_with_files = {sn for sn, en in disk_episodes.keys()} - for sn in sorted(seasons_with_files): - m = omdb.season_airdates(imdb_id, sn) - for ep_num, air in m.items(): - key = (sn, ep_num) - if key in disk_episodes and (key not in out or not out[key][1]): # Only for episodes we have on disk - air_iso = to_utc_iso(air) - out[key] = (air_iso, air_iso, "omdb:Released") - - # Absolute last resort: file mtime (only for episodes we have on disk) - for (sn, en), video_files in disk_episodes.items(): - key = (sn, en) - if key not in out or not out[key][1]: - # Use the first video file for mtime - if video_files: - mt = file_mtime_iso(video_files[0]) - out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") - - return out - -def is_series_current(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): - """Check if series NFOs and mtimes are already current with database""" - if not args.skip_if_current: - return False - - # Get cached episode data - cached = conn.execute( - "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", - (imdb_id,) - ).fetchall() - - if not cached: - return False - - # Check if NFO files match database - for season, episode, aired, dateadded, source in cached: - season_dir = series_dir / f"Season {season:02d}" - nfo_file = season_dir / f"S{season:02d}E{episode:02d}.nfo" - - if not nfo_file.exists(): - return False - - # Quick check - does NFO contain the expected dateadded? - try: - nfo_content = nfo_file.read_text(encoding="utf-8") - if dateadded and dateadded not in nfo_content: - return False - except Exception: - return False - - return True - -def apply_series(series_dir: Path, args, conn: sqlite3.Connection): - imdb_id = parse_imdb_from_path(series_dir) - if not imdb_id: - _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") - return - - # Check if series is already current - if is_series_current(series_dir, imdb_id, args, conn): - _log("INFO", f"Series already current, skipping: {series_dir.name}") - return - - # Store/refresh series row - conn.execute( - "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " - "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", - (imdb_id, str(series_dir), _iso_now()), - ) - conn.commit() - - # Build a map of episodes that actually exist on disk - disk_episodes = {} # {(season, episode): [video_files]} - seasons_with_videos = set() # Track which seasons have video files - - for season_dir in series_dir.iterdir(): - if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): - continue - try: - sn = int(season_dir.name.split()[-1]) - except Exception: - continue - - # Find video files and extract season/episode numbers - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - for f in season_dir.iterdir(): - if f.is_file() and f.suffix.lower() in video_exts: - m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) - if m: - file_sn, file_en = int(m.group(1)), int(m.group(2)) - # Use the season number from the directory, not the filename - key = (sn, file_en) - if key not in disk_episodes: - disk_episodes[key] = [] - disk_episodes[key].append(f) - seasons_with_videos.add(sn) - - _log("INFO", f"Found {len(disk_episodes)} episodes on disk: {sorted(disk_episodes.keys())}") - - # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked - if args.manage_nfo: - for season_dir in series_dir.iterdir(): - if season_dir.is_dir() and season_dir.name.lower().startswith("season "): - removed = remove_long_nfos(season_dir) - if removed: - _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") - - # Remove orphaned NFO files (NFOs without corresponding video files) - try: - sn = int(season_dir.name.split()[-1]) - orphaned = 0 - for nfo_file in season_dir.glob("S??E??.nfo"): - m = re.match(r"S(\d{2})E(\d{2})\.nfo$", nfo_file.name, re.IGNORECASE) - if m: - nfo_sn, nfo_en = int(m.group(1)), int(m.group(2)) - key = (sn, nfo_en) # Use directory season number - if key not in disk_episodes: - try: - nfo_file.unlink() - orphaned += 1 - _log("DEBUG", f"Removed orphaned NFO: {nfo_file}") - except Exception as e: - _log("WARNING", f"Failed to remove orphaned NFO {nfo_file}: {e}") - - if orphaned > 0: - _log("INFO", f"Removed {orphaned} orphaned NFO file(s) in {season_dir}") - - # Remove orphaned season.nfo files from empty seasons - if sn not in seasons_with_videos: - season_nfo = season_dir / "season.nfo" - if season_nfo.exists(): - try: - season_nfo.unlink() - _log("INFO", f"Removed orphaned season.nfo from empty season: {season_dir}") - except Exception as e: - _log("WARNING", f"Failed to remove orphaned season.nfo {season_nfo}: {e}") - else: - # Only create season.nfo for seasons that have video files - ensure_season_nfo(season_dir, sn) - - except Exception: - pass - maybe_tvshow_nfo(series_dir, imdb_id) - - # Gather dates only for episodes that exist on disk - all_dates = gather_episode_dates(series_dir, imdb_id, args, conn, disk_episodes) - - # Process episodes and update file mtimes individually - seasons_latest = {} - processed_count = 0 - - for (sn, en), (aired, dateadded, source) in sorted(all_dates.items()): - key = (sn, en) - - # Only process episodes that have video files on disk - if key in disk_episodes: - has_video = 1 - - # Write episode NFO only for episodes with video files - if args.manage_nfo: - write_episode_nfo( - series_dir / f"Season {sn:02d}", - sn, en, aired, dateadded, source or "", - lock_metadata=getattr(args, 'lock_metadata', True) - ) - - # Update individual episode file modification times - if args.update_video_mtimes and dateadded: - video_files = disk_episodes[key] - set_episode_file_mtimes(video_files, dateadded) - - processed_count += 1 - - # Compute latest per season for directory mtime - if dateadded: - try: - ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) - except Exception: - ts = None - if ts: - seasons_latest.setdefault(sn, ts) - if ts > seasons_latest[sn]: - seasons_latest[sn] = ts - - # DB upsert for episodes WITH video files - conn.execute( - "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source, has_video_file) " - "VALUES(?,?,?,?,?,?,?) " - "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source, has_video_file=excluded.has_video_file", - (imdb_id, sn, en, aired or None, dateadded or None, source or None, has_video), - ) - - _log("INFO", f"Processed {processed_count} episodes with video files, stored {len(all_dates)} total episodes in database") - conn.commit() - - # Fix folder mtimes (season dirs) - use latest episode date per season - if args.fix_dir_mtimes and seasons_latest: - for sn, ts in seasons_latest.items(): - season_dir = series_dir / f"Season {sn:02d}" - if season_dir.exists(): - iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") - set_dir_mtime(season_dir, iso) - _log("INFO", f"Updated mtime on {season_dir} -> {iso}") - -def iterate_target_path(path: Path): - # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs - if is_series_dir(path): - return [path] - if path.is_dir(): - out = [] - for p in path.iterdir(): - if is_series_dir(p): - out.append(p) - return sorted(out) - return [] - -# ========================= -# CLI -# ========================= -def cmd_cleanup_orphans(args): - """Remove database entries for episodes that no longer exist on disk""" - _log("INFO", f"Cleaning orphaned episodes from DB at {args.db}") - conn = db_connect(Path(args.db)) - - # Get all series from database - series_rows = conn.execute("SELECT imdb_id, series_path FROM series").fetchall() - - total_removed = 0 - for imdb_id, series_path in series_rows: - series_dir = Path(series_path) - if not series_dir.exists(): - _log("WARNING", f"Series directory no longer exists: {series_path}") - continue - - # Build map of episodes that actually exist on disk - disk_episodes = {} - for season_dir in series_dir.iterdir(): - if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): - continue - try: - sn = int(season_dir.name.split()[-1]) - except Exception: - continue - - # Find video files - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - for f in season_dir.iterdir(): - if f.is_file() and f.suffix.lower() in video_exts: - m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) - if m: - file_sn, file_en = int(m.group(1)), int(m.group(2)) - key = (sn, file_en) - if key not in disk_episodes: - disk_episodes[key] = [] - disk_episodes[key].append(f) - - # Get all episodes for this series from database - db_episodes = conn.execute( - "SELECT season, episode FROM episode_dates WHERE imdb_id = ?", - (imdb_id,) - ).fetchall() - - # Find orphaned episodes (in DB but not on disk) - orphaned = [] - for season, episode in db_episodes: - if (season, episode) not in disk_episodes: - orphaned.append((season, episode)) - - if orphaned: - _log("INFO", f"Removing {len(orphaned)} orphaned episodes for {series_dir.name}") - for season, episode in orphaned: - conn.execute( - "DELETE FROM episode_dates WHERE imdb_id = ? AND season = ? AND episode = ?", - (imdb_id, season, episode) - ) - _log("DEBUG", f"Removed S{season:02d}E{episode:02d} from database") - total_removed += len(orphaned) - else: - _log("DEBUG", f"No orphaned episodes found for {series_dir.name}") - - conn.commit() - _log("INFO", f"Cleanup complete: removed {total_removed} orphaned episode entries") - conn.close() - -def cmd_rebuild_db(args): - _log("INFO", f"Rebuilding DB at {args.db}") - conn = db_connect(Path(args.db)) - # wipe content but keep schema - conn.execute("DELETE FROM episode_dates;") - conn.execute("DELETE FROM series;") - conn.commit() - conn.close() - -def cmd_apply(args): - # Enable debug logging if requested - if args.debug: - _log.debug_enabled = True - - conn = db_connect(Path(args.db)) - targets = iterate_target_path(Path(args.path)) - if not targets: - _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") - return - # Inform about source availability - if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): - _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") - if not os.environ.get("TVDB_API_KEY"): - _log("INFO", "TVDB not configured (set TVDB_API_KEY for better air date coverage)") - - for series_dir in targets: - _log("INFO", f"=== APPLY: {series_dir} ===") - try: - apply_series(series_dir, args, conn) - except KeyboardInterrupt: - raise - except urllib.error.HTTPError as e: - _log("ERROR", f"API error for {series_dir}: HTTP {e.code} - {e.reason}") - if e.code == 401: - _log("ERROR", "Check your API keys (SONARR_API_KEY, TMDB_API_KEY, OMDB_API_KEY)") - continue # Skip this series but continue with others - except Exception as e: - _log("ERROR", f"Failed applying {series_dir}: {e}") - continue # Skip this series but continue with others - - conn.close() - -def build_parser(): - p = argparse.ArgumentParser(prog="media_date_cache.py") - p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) - sub = p.add_subparsers(dest="cmd", required=True) - - p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") - p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") - p_rebuild.set_defaults(func=cmd_rebuild_db) - - p_cleanup = sub.add_parser("cleanup-orphans", help="Remove database entries for episodes that no longer exist on disk") - p_cleanup.add_argument("--db", required=True, help="Path to sqlite DB") - p_cleanup.set_defaults(func=cmd_cleanup_orphans) - - p_apply = sub.add_parser("apply", help="Apply dates โ†’ NFO + mtimes for a series dir or a library root") - p_apply.add_argument("--db", required=True, help="Path to sqlite DB") - p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") - p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") - p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") - p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to match individual episode dateadded") - p_apply.add_argument("--lock-metadata", action="store_true", default=True, help="Add lockdata flag to NFO files to prevent media server overwrites") - p_apply.add_argument("--no-lock-metadata", dest="lock_metadata", action="store_false", help="Don't add lockdata flag to NFO files") - p_apply.add_argument("--skip-if-current", action="store_true", help="Skip series where NFOs are already current with database") - p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") - p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") - p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") - p_apply.set_defaults(func=cmd_apply) - - return p - -def main(): - parser = build_parser() - args = parser.parse_args() - # (No need to load env here; it was already handled in preparse) - args.func(args) - -if __name__ == "__main__": - main() diff --git a/nfoguard.py b/nfoguard.py index aa9f5fe..70cf5ba 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -148,6 +148,8 @@ class NFOGuardConfig: self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True) self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False) self.release_date_priority = [p.strip() for p in os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical").split(",")] + self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True) + self.max_release_date_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower() self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower() @@ -568,7 +570,11 @@ class MovieProcessor: def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]: """Get release date from external sources using configured priority""" - release_result = self.external_clients.get_release_date_by_priority(imdb_id, config.release_date_priority) + release_result = self.external_clients.get_release_date_by_priority( + imdb_id, + config.release_date_priority, + enable_smart_validation=config.enable_smart_date_validation + ) if release_result: return release_result[0], release_result[1] return None, "release:none" diff --git a/test_db_performance.py b/test_db_performance.py deleted file mode 100644 index 99dc00b..0000000 --- a/test_db_performance.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for comparing Radarr API vs Database performance -Run this to validate the new database client functionality -""" - -import os -import time -from pathlib import Path -from clients.radarr_client import RadarrClient -from clients.radarr_db_client import RadarrDbClient - -def test_database_connection(): - """Test database connection and basic functionality""" - print("=== Testing Database Connection ===") - - db_client = RadarrDbClient.from_env() - if not db_client: - print("โŒ Database client not configured - check environment variables") - return False - - print("โœ… Database connection successful") - - # Test basic stats - stats = db_client.get_database_stats() - print(f"Database stats: {stats}") - - return True - -def test_movie_lookup_performance(): - """Compare API vs Database performance for movie lookups""" - print("\n=== Testing Movie Lookup Performance ===") - - # Test IMDb IDs - adjust these to movies in your library - test_imdb_ids = [ - "tt1596343", # Fast & Furious 6 - "tt0468569", # The Dark Knight - "tt0137523", # Fight Club - "tt0109830", # Forrest Gump - "tt0111161" # The Shawshank Redemption - ] - - # Initialize clients - radarr_url = os.environ.get("RADARR_URL") - radarr_api_key = os.environ.get("RADARR_API_KEY") - - if not radarr_url or not radarr_api_key: - print("โŒ RADARR_URL and RADARR_API_KEY required for testing") - return - - client = RadarrClient(radarr_url, radarr_api_key) - - # Test API performance - print("\n--- API Performance ---") - api_times = [] - api_found = 0 - - for imdb_id in test_imdb_ids: - start_time = time.time() - - # Temporarily disable database client for API-only test - original_db_client = client.db_client - client.db_client = None - - movie = client.movie_by_imdb(imdb_id) - - # Restore database client - client.db_client = original_db_client - - elapsed = time.time() - start_time - api_times.append(elapsed) - - if movie: - api_found += 1 - print(f" {imdb_id}: {elapsed:.3f}s - Found: {movie.get('title')}") - else: - print(f" {imdb_id}: {elapsed:.3f}s - Not found") - - # Test Database performance - print("\n--- Database Performance ---") - db_times = [] - db_found = 0 - - if client.db_client: - for imdb_id in test_imdb_ids: - start_time = time.time() - movie = client.movie_by_imdb(imdb_id) # This will use database first - elapsed = time.time() - start_time - db_times.append(elapsed) - - if movie: - db_found += 1 - print(f" {imdb_id}: {elapsed:.3f}s - Found: {movie.get('title')}") - else: - print(f" {imdb_id}: {elapsed:.3f}s - Not found") - else: - print(" Database client not available") - return - - # Performance comparison - print("\n--- Performance Summary ---") - avg_api_time = sum(api_times) / len(api_times) - avg_db_time = sum(db_times) / len(db_times) - - print(f"API Average: {avg_api_time:.3f}s ({api_found}/{len(test_imdb_ids)} found)") - print(f"Database Average: {avg_db_time:.3f}s ({db_found}/{len(test_imdb_ids)} found)") - - if avg_api_time > 0 and avg_db_time > 0: - speedup = avg_api_time / avg_db_time - print(f"Speedup: {speedup:.1f}x faster with database") - -def test_import_date_performance(): - """Test import date retrieval performance""" - print("\n=== Testing Import Date Performance ===") - - radarr_url = os.environ.get("RADARR_URL") - radarr_api_key = os.environ.get("RADARR_API_KEY") - - if not radarr_url or not radarr_api_key: - print("โŒ RADARR_URL and RADARR_API_KEY required for testing") - return - - client = RadarrClient(radarr_url, radarr_api_key) - - # Find a test movie - test_movie = client.movie_by_imdb("tt1596343") # Use database if available - if not test_movie: - print("โŒ Test movie not found in library") - return - - movie_id = test_movie.get('id') - movie_title = test_movie.get('title', 'Unknown') - print(f"Testing with: {movie_title} (ID: {movie_id})") - - # Test API performance - print("\n--- API Import Date Performance ---") - start_time = time.time() - - # Temporarily disable database client - original_db_client = client.db_client - client.db_client = None - - api_date, api_source = client.get_movie_import_date(movie_id) - - # Restore database client - client.db_client = original_db_client - - api_time = time.time() - start_time - print(f"API Result: {api_date} ({api_source}) - {api_time:.3f}s") - - # Test Database performance - if client.db_client: - print("\n--- Database Import Date Performance ---") - start_time = time.time() - db_date, db_source = client.get_movie_import_date(movie_id) - db_time = time.time() - start_time - print(f"Database Result: {db_date} ({db_source}) - {db_time:.3f}s") - - if api_time > 0 and db_time > 0: - speedup = api_time / db_time - print(f"Import date speedup: {speedup:.1f}x faster with database") - else: - print("Database client not available for comparison") - -def test_bulk_operations(): - """Test bulk import date operations""" - print("\n=== Testing Bulk Operations ===") - - db_client = RadarrDbClient.from_env() - if not db_client: - print("โŒ Database client required for bulk operations") - return - - # Test bulk import dates - test_imdb_ids = ["tt1596343", "tt0468569", "tt0137523", "tt0109830", "tt0111161"] - - start_time = time.time() - results = db_client.bulk_import_dates(test_imdb_ids) - elapsed = time.time() - start_time - - print(f"Bulk operation completed in {elapsed:.3f}s") - print("Results:") - for imdb_id, (date_iso, source) in results.items(): - print(f" {imdb_id}: {date_iso} ({source})") - -if __name__ == "__main__": - print("NFOGuard Database Performance Test") - print("=" * 50) - - # Test database connection - if not test_database_connection(): - print("Exiting due to database connection failure") - exit(1) - - # Test movie lookups - test_movie_lookup_performance() - - # Test import dates - test_import_date_performance() - - # Test bulk operations - test_bulk_operations() - - print("\nโœ… All tests completed!") - print("\nTo enable database access, configure these environment variables:") - print(" RADARR_DB_TYPE=postgresql") - print(" RADARR_DB_HOST=your_postgres_host") - print(" RADARR_DB_PORT=5432") - print(" RADARR_DB_NAME=radarr-main") - print(" RADARR_DB_USER=postgres") - print(" RADARR_DB_PASSWORD=your_password") - print("\nOr for SQLite:") - print(" RADARR_DB_TYPE=sqlite") - print(" RADARR_DB_PATH=/config/radarr.db") \ No newline at end of file diff --git a/webhook_server.py b/webhook_server.py deleted file mode 100644 index 17129ca..0000000 --- a/webhook_server.py +++ /dev/null @@ -1,1977 +0,0 @@ -#!/usr/bin/env python3 -# app.py - NFOGuard Webhook Server (TV + Movies) -# -# Features: -# - TV via existing media_date_cache.apply_series (Sonarr webhooks + manual scan) -# - Movies with robust logic and NFO upsert: -# * Priority configurable: import_then_digital | digital_then_import -# * Poll policy: always | if_missing | never -# * Digital date candidates: Radarr, TMDB, Jellyseerr (optional), OMDb (optional) -> pick earliest -# * Digital sanity check (re-release guard): compare to cinema/import/min and fallback -# * Update policy: backfill_only | overwrite (+ preserve flags in backfill mode) -# * Pretty XML, ensures , , , , footer comments, uniqueid imdb -# * Optional mtimes update to match chosen -# -# Endpoints: -# - POST /webhook/sonarr -# - POST /webhook/radarr -# - POST /manual/scan (scan_type=both|tv|movies, optional path=...) -# - POST /manual/scan/tv -# - POST /manual/scan/movies -# - GET /health -# - GET /stats -# - GET /batch/status -# -# Env (highlights): -# MANAGER_BRAND (default NFOGuard) -# MOVIE_PRIORITY=import_then_digital|digital_then_import -# MOVIE_POLL_MODE=always|if_missing|never -# MOVIE_DATE_UPDATE_MODE=backfill_only|overwrite -# MOVIE_PRESERVE_RADARR_IMPORT=true|false -# MOVIE_PRESERVE_EXISTING=true|false -# MOVIE_DIGITAL_SANITY_CHECK=true|false -# MOVIE_DIGITAL_SANITY_REF=cinema|import|min -# MOVIE_DIGITAL_MAX_LAG_DAYS=365 -# MOVIE_DIGITAL_INVALID_FALLBACK=import|cinema -# TMDB_API_KEY / TMDB_PRIMARY_COUNTRY -# OMDB_API_KEY -# JELLYSEERR_URL / JELLYSEERR_API_KEY -# RADARR_URL / RADARR_API_KEY / RADARR_TIMEOUT / RADARR_RETRIES -# SONARR_URL / SONARR_API_KEY (TV pathing only; series handled in media_date_cache) -# TV_PATHS, MOVIE_PATHS -# MANAGE_NFO, FIX_DIR_MTIMES, LOCK_METADATA, DEBUG -# BATCH_DELAY, MAX_CONCURRENT_SERIES -# -# Run: uvicorn app:app --host 0.0.0.0 --port 8080 - -import os -import json -import asyncio -import glob -import re -import logging -import logging.handlers -from pathlib import Path -from datetime import datetime, timezone, timedelta -from fastapi import FastAPI, HTTPException, BackgroundTasks, Request -from pydantic import BaseModel -from typing import Optional, Dict, Any, List, Set, Tuple -import sqlite3 -import uvicorn -import threading -from concurrent.futures import ThreadPoolExecutor -import xml.etree.ElementTree as ET -from urllib.parse import urlencode, urljoin, quote -from urllib.request import Request as UrlRequest, urlopen -from urllib.error import URLError, HTTPError -import time - -# Import TV helpers from your existing module -from media_date_cache import ( - db_connect, apply_series, parse_imdb_from_path, - _log as _original_log, _iso_now, SonarrClient -) - -# --------------------------- -# Enhanced logging with file support -# --------------------------- - -# Global file logger -_file_logger = None - -def _setup_file_logging(): - """Setup file logging for NFOGuard""" - global _file_logger - if _file_logger is not None: - return _file_logger - - # Create logs directory - log_dir = Path("/app/data/logs") - log_dir.mkdir(parents=True, exist_ok=True) - - # Setup rotating file handler - log_file = log_dir / "nfoguard.log" - _file_logger = logging.getLogger("NFOGuard") - _file_logger.setLevel(logging.DEBUG) - - # Rotating file handler (50MB max, 3 backups - reduced rotation frequency) - file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=50*1024*1024, backupCount=3 - ) - - # Detailed format for file logging - formatter = logging.Formatter( - '[%(asctime)s] %(levelname)s: %(message)s', - datefmt='%Y-%m-%dT%H:%M:%S+00:00' - ) - file_handler.setFormatter(formatter) - _file_logger.addHandler(file_handler) - - return _file_logger - -def _log(level: str, msg: str): - """Enhanced logging that writes to both console and file""" - # Call original log for console output - _original_log(level, msg) - - # Also log to file - try: - file_logger = _setup_file_logging() - if level == "DEBUG": - file_logger.debug(msg) - elif level == "INFO": - file_logger.info(msg) - elif level == "WARNING": - file_logger.warning(msg) - elif level == "ERROR": - file_logger.error(msg) - else: - file_logger.info(f"{level}: {msg}") - except Exception as e: - # Don't let logging errors break the app - print(f"File logging error: {e}") - -# Initialize file logging on startup -_setup_file_logging() - -# --------------------------- -# Models -# --------------------------- - -class SonarrWebhook(BaseModel): - eventType: str - series: Optional[Dict[str, Any]] = None - episodes: Optional[list] = [] - episodeFile: Optional[Dict[str, Any]] = None - isUpgrade: Optional[bool] = False - movie: Optional[Dict[str, Any]] = None - remoteMovie: Optional[Dict[str, Any]] = None - deletedFiles: Optional[list] = [] - - class Config: - extra = "allow" - -class RadarrWebhook(BaseModel): - eventType: str - movie: Optional[Dict[str, Any]] = None - movieFile: Optional[Dict[str, Any]] = None - isUpgrade: Optional[bool] = False - deletedFiles: Optional[list] = [] - remoteMovie: Optional[Dict[str, Any]] = None - renamedMovieFiles: Optional[List[Dict[str, Any]]] = None # on Rename - - class Config: - extra = "allow" - -class HealthResponse(BaseModel): - status: str - version: str - uptime: str - database_status: str - -# --------------------------- -# App & Config -# --------------------------- - -app = FastAPI( - title="NFOGuard Webhook Server", - description="Locks true import dates; manages NFOs and mtimes for TV & Movies", - version="1.7.0", -) - -start_time = datetime.now(timezone.utc) -db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db")) - -def _norm(s: str) -> str: - return (s or "").strip().lower() - -def _bool_env(name: str, default: bool) -> bool: - v = os.environ.get(name) - if v is None: - return default - return _norm(v) in ("1", "true", "yes", "y", "on") - -class WebhookConfig: - def __init__(self): - tv_paths_str = os.environ.get("TV_PATHS", os.environ.get("MEDIA_PATH", "/media/tv,/media/tv6")) - self.tv_paths = [Path(p.strip()) for p in tv_paths_str.split(",") if p.strip()] - - movie_paths_str = os.environ.get("MOVIE_PATHS", "/media/movies,/media/movies6") - self.movie_paths = [Path(p.strip()) for p in movie_paths_str.split(",") if p.strip()] - - self.manage_nfo = _bool_env("MANAGE_NFO", True) - self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True) - self.lock_metadata = _bool_env("LOCK_METADATA", True) - self.debug = _bool_env("DEBUG", False) - - self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0")) - self.max_concurrent_series = int(os.environ.get("MAX_CONCURRENT_SERIES", "3")) - - self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard") - - # Radarr - self.radarr_url = (os.environ.get("RADARR_URL", "") or "").rstrip("/") - self.radarr_api_key = os.environ.get("RADARR_API_KEY", "") or "" - self.radarr_timeout = int(os.environ.get("RADARR_TIMEOUT", "45")) - self.radarr_retries = int(os.environ.get("RADARR_RETRIES", "3")) - - # Optional externals - self.tmdb_api_key = os.environ.get("TMDB_API_KEY") or "" - self.tmdb_country = (os.environ.get("TMDB_PRIMARY_COUNTRY") or "US").upper() - self.omdb_api_key = os.environ.get("OMDB_API_KEY") or "" - self.jelly_url = (os.environ.get("JELLYSEERR_URL") or "").rstrip("/") - self.jelly_api = os.environ.get("JELLYSEERR_API_KEY") or "" - - # Legacy (logged only) - raw_strategy = _norm(os.environ.get("MOVIE_DATE_STRATEGY", "radarr_import")) - aliases = { - "import": "radarr_import", - "radarr": "radarr_import", - "digital_release": "digital", - "physical_release": "physical", - "in_cinemas": "cinema", - "theatrical": "cinema", - "mtime": "file_mtime", - } - self.movie_date_strategy = aliases.get(raw_strategy, raw_strategy) - - # Priority - self.movie_priority = _norm(os.environ.get("MOVIE_PRIORITY", "import_then_digital")) - if self.movie_priority not in ("import_then_digital", "digital_then_import"): - self.movie_priority = "import_then_digital" - - # Poll policy - self.movie_poll_mode = _norm(os.environ.get("MOVIE_POLL_MODE", "always")) # always|if_missing|never - if self.movie_poll_mode not in ("always", "if_missing", "never"): - self.movie_poll_mode = "always" - - # Update policy - self.movie_date_update_mode = _norm(os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only")) - self.movie_preserve_radarr_import = _bool_env("MOVIE_PRESERVE_RADARR_IMPORT", True) - self.movie_preserve_existing = _bool_env("MOVIE_PRESERVE_EXISTING", True) - - # Digital sanity check - self.movie_digital_sanity_check = _bool_env("MOVIE_DIGITAL_SANITY_CHECK", False) - self.movie_digital_max_lag_days = int(os.environ.get("MOVIE_DIGITAL_MAX_LAG_DAYS", "365")) - self.movie_digital_sanity_ref = _norm(os.environ.get("MOVIE_DIGITAL_SANITY_REF", "cinema")) # cinema|import|min - if self.movie_digital_sanity_ref not in ("cinema", "import", "min"): - self.movie_digital_sanity_ref = "cinema" - self.movie_digital_invalid_fallback = _norm(os.environ.get("MOVIE_DIGITAL_INVALID_FALLBACK", "import")) # import|cinema - if self.movie_digital_invalid_fallback not in ("import", "cinema"): - self.movie_digital_invalid_fallback = "import" - -config = WebhookConfig() - -# --------------------------- -# DB schema (movies) -# --------------------------- - -MOVIE_SCHEMA = """ -CREATE TABLE IF NOT EXISTS movies ( - imdb_id TEXT PRIMARY KEY, - movie_path TEXT NOT NULL, - last_applied TEXT -); - -CREATE TABLE IF NOT EXISTS movie_dates ( - imdb_id TEXT PRIMARY KEY, - released TEXT, - dateadded TEXT, - source TEXT, - has_video_file INTEGER DEFAULT 0 -); -""" - -def ensure_movie_tables(conn: sqlite3.Connection): - try: - result = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='movies'").fetchone() - if not result: - _log("INFO", "Creating movie tables in database") - conn.executescript(MOVIE_SCHEMA) - conn.commit() - except sqlite3.Error as e: - _log("ERROR", f"Failed to create movie tables: {e}") - -# --------------------------- -# HTTP helper -# --------------------------- - -def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None) -> Optional[Any]: - try: - req = UrlRequest(url, headers=headers or {"Accept": "application/json"}) - with urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) - except Exception as e: - _log("WARNING", f"GET {url} failed: {e}") - return None - -# --------------------------- -# Radarr client -# --------------------------- - -class RadarrClient: - def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): - self.base_url = base_url.rstrip("/") + "/" - self.api_key = api_key - self.timeout = timeout - self.retries = max(0, retries) - - def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: - if not self.api_key: - return None - attempt = 0 - last_err = None - while attempt <= self.retries: - try: - params = params or {} - params["apikey"] = self.api_key - url = urljoin(self.base_url, path.lstrip("?").lstrip("/")) - if params: - url = url + ("&" if "?" in url else "?") + urlencode(params) - - _log("DEBUG", f"Radarr API Request: {url}") - req = UrlRequest(url, headers={"Accept": "application/json"}) - with urlopen(req, timeout=self.timeout) as resp: - data = resp.read().decode("utf-8") - result = json.loads(data) - _log("DEBUG", f"Radarr API Response: {json.dumps(result, indent=2) if isinstance(result, (dict, list)) else result}") - return result - except (URLError, HTTPError, json.JSONDecodeError) as e: - last_err = e - _log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}") - time.sleep(min(2 ** attempt, 5)) - attempt += 1 - _log("WARNING", f"Radarr GET {path} failed after retries: {last_err}") - return None - - def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: - imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" - _log("DEBUG", f"Radarr API: Looking up movie by IMDb ID: {imdb_id}") - - # Try getting ALL movies first, then filter by IMDb ID (more reliable) - all_movies = self._get("/api/v3/movie") - if isinstance(all_movies, list): - _log("DEBUG", f"Radarr API: Got {len(all_movies)} total movies, filtering by IMDb ID") - for movie in all_movies: - movie_imdb = movie.get("imdbId", "").lower() - if movie_imdb == imdb_id.lower(): - _log("INFO", f"Radarr API: Found exact match: {movie.get('title')} (ID: {movie.get('id')}, IMDb: {movie_imdb})") - return movie - - _log("WARNING", f"Radarr API: Movie not found in library for IMDb ID: {imdb_id}") - - # Fallback: Try direct movie search with IMDb filter - res = self._get("/api/v3/movie", {"imdbId": imdb_id}) - if isinstance(res, list) and len(res) > 0: - # CRITICAL: Validate that returned movie actually matches requested IMDb ID - for movie in res: - movie_imdb = movie.get("imdbId", "").lower() - if movie_imdb == imdb_id.lower(): - _log("DEBUG", f"Radarr API: Found via search: {movie.get('title')} (ID: {movie.get('id')})") - return movie - - # Log the mismatch for debugging - returned_ids = [m.get("imdbId", "None") for m in res[:3]] # Show first 3 - _log("ERROR", f"Radarr API returned WRONG movies for {imdb_id}! Got: {returned_ids}") - - # Final fallback: Try lookup endpoint - res = self._get("/api/v3/movie/lookup/imdb", {"imdbId": imdb_id}) - if isinstance(res, dict) and res.get("imdbId", "").lower() == imdb_id.lower(): - _log("DEBUG", f"Radarr API: Found movie via lookup: {res.get('title')} (ID: {res.get('id')})") - return res - - _log("ERROR", f"Radarr API: No valid movie found for IMDb ID: {imdb_id}") - return None - - def movie_files(self, movie_id: int) -> List[Dict[str, Any]]: - res = self._get("/api/v3/moviefile", {"movieId": movie_id}) - return res if isinstance(res, list) else [] - - def earliest_dateadded(self, movie_id: int) -> Optional[str]: - files = self.movie_files(movie_id) - best = None - for f in files: - da = f.get("dateAdded") - if not da: - continue - try: - dt = datetime.fromisoformat(da.replace("Z", "+00:00")) - if best is None or dt < best: - best = dt - except Exception: - continue - if best: - return best.astimezone(timezone.utc).isoformat(timespec="seconds") - return None - - def earliest_import_event(self, movie_id: int) -> Optional[str]: - # Collect all history pages to ensure we don't miss early import events - all_items = [] - page = 1 - page_size = 1000 - - _log("INFO", f"Starting history lookup for movie_id {movie_id}") - - while True: - data = self._get("/api/v3/history", { - "movieId": movie_id, "page": page, "pageSize": page_size, - "sortKey": "date", "sortDirection": "ascending" - }) - if not data: - break - - items = data if isinstance(data, list) else data.get("records") or [] - if not items: - break - - all_items.extend(items) - _log("DEBUG", f"Page {page}: Found {len(items)} history items for movie_id {movie_id}") - - # If we got less than pageSize, we've reached the end - if len(items) < page_size: - break - - page += 1 - # Safety valve - don't go crazy with pagination - if page > 10: - _log("WARNING", f"Stopped pagination at page 10 for movie_id {movie_id} - may have missed some history") - break - - _log("INFO", f"Found {len(all_items)} total history items across {page} pages for movie_id {movie_id}") - - # Log ALL events first for debugging - _log("INFO", f"=== COMPLETE HISTORY ANALYSIS for movie_id {movie_id} ===") - for i, it in enumerate(all_items): - ev = (it.get("eventType") or it.get("type") or "").lower() - date_str = it.get("date", "NO_DATE") - event_data = it.get('data', {}) - source_path = event_data.get('sourcePath', '') or event_data.get('path', '') or event_data.get('sourceTitle', '') - - _log("INFO", f"Event #{i+1}: type='{ev}', date='{date_str}', sourcePath='{source_path}'") - _log("DEBUG", f" Full data: {json.dumps(event_data, indent=2) if event_data else 'No data'}") - - first_grab = None - earliest_real_import = None - - for it in all_items: - ev = (it.get("eventType") or it.get("type") or "").lower() - date_iso = None - if it.get("date"): - try: - date_iso = datetime.fromisoformat(it["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") - except Exception: - date_iso = None - - # Expanded event types for Radarr v4/v5 compatibility - import_events = { - "downloadfolderimported", "moviefileimported", "imported", - "downloadimported", "fileimported", "movieimported" - } - - if ev in import_events: - # Check if this is a real import (from downloads) vs existing file rename - event_data = it.get('data', {}) - # Prioritize droppedPath which contains the actual source directory - source_path = ( - event_data.get('droppedPath', '') or - event_data.get('sourcePath', '') or - event_data.get('path', '') or - event_data.get('sourceTitle', '') - ) - - _log("INFO", f"Analyzing import event '{ev}' with source: '{source_path}'") - - # Real imports typically come from download folders - download_indicators = [ - '/downloads/', '/download/', '/completed/', '/nzbs/', '/torrents/', - 'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission', - '/radarr/', 'completed/' # Add more specific patterns - ] - - is_real_import = any(indicator in source_path.lower() for indicator in download_indicators) - - _log("INFO", f"Import analysis: is_real_import={is_real_import}, date={date_iso}") - - if is_real_import and date_iso: - _log("INFO", f"โœ… FOUND REAL IMPORT EVENT '{ev}' from downloads at {date_iso} (source: {source_path})") - if earliest_real_import is None: - earliest_real_import = date_iso - elif date_iso: - _log("INFO", f"โš ๏ธ Skipping non-download import '{ev}' from {source_path}") - - elif ev == "grabbed" and date_iso and first_grab is None: - first_grab = date_iso - _log("INFO", f"Found grab event at {date_iso}") - - if earliest_real_import: - _log("INFO", f"โœ… USING EARLIEST REAL IMPORT DATE: {earliest_real_import}") - return earliest_real_import - - if first_grab: - _log("INFO", f"โš ๏ธ Using grab date {first_grab} as fallback for movie_id {movie_id}") - return first_grab - - _log("ERROR", f"โŒ No real import/grab events found for movie_id {movie_id}") - return None - -# --------------------------- -# TMDB / OMDb / Jellyseerr helpers -# --------------------------- - -def _tmdb_id_from_imdb(imdb_id: str) -> Optional[int]: - if not config.tmdb_api_key: - return None - try: - url_find = f"https://api.themoviedb.org/3/find/{quote(imdb_id)}?{urlencode({'api_key': config.tmdb_api_key, 'external_source':'imdb_id'})}" - j = _get_json(url_find, timeout=20) - res = (j or {}).get("movie_results") or [] - if res: - return res[0].get("id") - return None - except Exception: - return None - -def tmdb_digital_date_from_imdb(imdb_id: str) -> Optional[str]: - if not config.tmdb_api_key: - return None - try: - tmdb_id = _tmdb_id_from_imdb(imdb_id) - if not tmdb_id: - return None - url_rel = f"https://api.themoviedb.org/3/movie/{tmdb_id}/release_dates?{urlencode({'api_key': config.tmdb_api_key})}" - k = _get_json(url_rel, timeout=20) - if not k: - return None - for entry in (k.get("results") or []): - if (entry.get("iso_3166_1") or "").upper() != config.tmdb_country: - continue - for rd in (entry.get("release_dates") or []): - if rd.get("type") == 4 and rd.get("release_date"): # 4 = Digital - try: - dt = datetime.fromisoformat(rd["release_date"].replace("Z", "+00:00")) - return dt.astimezone(timezone.utc).isoformat(timespec="seconds") - except Exception: - continue - return None - except Exception as e: - _log("WARNING", f"TMDB lookup failed for {imdb_id}: {e}") - return None - -def jellyseerr_digital_date_from_tmdb(tmdb_id: int) -> Optional[str]: - base = config.jelly_url - api = config.jelly_api - if not (base and api and tmdb_id): - return None - try: - url = base.rstrip("/") + f"/api/v1/movie/{tmdb_id}" - j = _get_json(url, timeout=20, headers={"X-Api-Key": api, "Accept": "application/json"}) - if not j: - return None - candidates = [] - for k in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"): - v = j.get(k) - if v: - iso = _parse_date_to_iso(v) - if iso: - candidates.append(iso) - for arr_key in ("releaseDates", "releases", "dates"): - arr_data = j.get(arr_key) - if not isinstance(arr_data, list): - continue - for rd in arr_data: - if not isinstance(rd, dict): - continue - name = (rd.get("type") or rd.get("label") or "").lower() - v = rd.get("date") or rd.get("releaseDate") - if v and ("digital" in name or "vod" in name or "stream" in name): - iso = _parse_date_to_iso(v) - if iso: - candidates.append(iso) - if not candidates: - return None - return sorted( - candidates, - key=lambda s: datetime.fromisoformat(s.replace("Z", "+00:00")) - )[0] - except Exception as e: - _log("WARNING", f"Jellyseerr lookup failed for tmdb:{tmdb_id}: {e}") - return None - -def omdb_dvd_date(imdb_id: str) -> Optional[str]: - if not config.omdb_api_key: - return None - try: - url = f"http://www.omdbapi.com/?{urlencode({'i': imdb_id, 'apikey': config.omdb_api_key})}" - j = _get_json(url, timeout=15) - if not j or _norm(j.get("Response")) != "true": - return None - dvd = j.get("DVD") or j.get("Released") - if not dvd: - return None - for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"): - try: - dt = datetime.strptime(dvd, fmt).replace(tzinfo=timezone.utc) - return dt.isoformat(timespec="seconds") - except Exception: - continue - return None - except Exception as e: - _log("WARNING", f"OMDb lookup failed for {imdb_id}: {e}") - return None - -# --------------------------- -# XML helpers (pretty + upsert) -# --------------------------- - -def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element: - child = parent.find(tag) - if child is None: - child = ET.SubElement(parent, tag) - child.text = text - else: - if overwrite or (child.text is None or str(child.text).strip() == ""): - child.text = text - return child - -def _ensure_uniqueid_imdb(root: ET.Element, imdb_id: str): - imdb_elem = None - for uid in root.findall("uniqueid"): - if (uid.attrib.get("type") or "").lower() == "imdb": - imdb_elem = uid - break - if imdb_elem is None: - imdb_elem = ET.SubElement(root, "uniqueid", {"type": "imdb", "default": "true"}) - else: - if "default" not in imdb_elem.attrib: - imdb_elem.set("default", "true") - imdb_elem.text = imdb_id - -def _indent(elem: ET.Element, level: int = 0, space: str = " "): - i = "\n" + level * space - if len(elem): - if not elem.text or not elem.text.strip(): - elem.text = i + space - for idx, e in enumerate(list(elem)): - _indent(e, level + 1, space) - if not e.tail or not e.tail.strip(): - e.tail = i + (space if idx < len(elem) - 1 else "") - if not elem.tail or not elem.tail.strip(): - elem.tail = "\n" + (level - 1) * space if level else "\n" - else: - if not elem.text or not elem.text.strip(): - elem.text = "" - if not elem.tail or not elem.tail.strip(): - elem.tail = "\n" + (level - 1) * space if level else "\n" - -def _strip_existing_footer_comments(root: ET.Element): - for node in list(root): - if isinstance(node.tag, str): - continue - txt = (node.text or "").strip().lower() - if "source:" in txt or "managed by" in txt: - root.remove(node) - -def upsert_movie_nfo( - movie_dir: Path, - imdb_id: str, - released: Optional[str], - dateadded: Optional[str], - source_detail: str, - lock_metadata: bool = True, - media_label: str = "Movie", - brand: str = "NFOGuard", -): - canonical_path = movie_dir / "movie.nfo" - alt_path = movie_dir / f"{movie_dir.name}.nfo" - - root = None - for p in (canonical_path, alt_path): - if p.exists(): - try: - t = ET.parse(str(p)) - r = t.getroot() - if r is not None and r.tag.lower() == "movie": - root = r - break - except Exception: - pass - if root is None: - root = ET.Element("movie") - - if imdb_id and not imdb_id.lower().startswith("tt"): - imdb_id = f"tt{imdb_id}" - - if imdb_id: - _ensure_uniqueid_imdb(root, imdb_id) - - if released: - ymd = released.split("T")[0] - _ensure_child_text(root, "premiered", ymd, overwrite=False) - _ensure_child_text(root, "year", ymd.split("-")[0], overwrite=False) - - if dateadded: - if dateadded == "MANUAL_REVIEW_NEEDED": - _ensure_child_text(root, "dateadded", "MANUAL_REVIEW_NEEDED", overwrite=True) - _log("WARNING", f"Set MANUAL_REVIEW_NEEDED marker in NFO: {movie_dir}/movie.nfo") - else: - _ensure_child_text(root, "dateadded", dateadded, overwrite=True) - - if lock_metadata: - _ensure_child_text(root, "lockdata", "true", overwrite=True) - - _strip_existing_footer_comments(root) - root.append(ET.Comment(f" source: {media_label}; {source_detail} ")) - root.append(ET.Comment(f" managed by {brand} ")) - - _indent(root) - - for target in [canonical_path] + ([alt_path] if alt_path.exists() else []): - try: - target.parent.mkdir(parents=True, exist_ok=True) - ET.ElementTree(root).write(str(target), encoding="utf-8", xml_declaration=True) - _log("DEBUG", f"Upserted NFO: {target}") - except Exception as e: - _log("ERROR", f"Failed writing {target}: {e}") - -# --------------------------- -# Date selection -# --------------------------- - -def _parse_date_to_iso(d: str) -> Optional[str]: - if not d: - return None - try: - if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d): - dt = datetime.fromisoformat(d).replace(tzinfo=timezone.utc) - else: - dt = datetime.fromisoformat(d.replace("Z", "+00:00")).astimezone(timezone.utc) - - # Reject dates that are too recent (prevent bogus timestamps) - now = datetime.now(timezone.utc) - if (now - dt).days < 1: - _log("WARNING", f"Rejecting recent timestamp (less than 1 day old): {d} -> {dt.isoformat()}") - return None - - return dt.isoformat(timespec="seconds") - except Exception: - return None - -def _radarr_movie_by_imdb(imdb_id: str) -> Optional[Dict[str, Any]]: - if not (config.radarr_url and config.radarr_api_key): - return None - rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries) - return rc.movie_by_imdb(imdb_id) - -def _radarr_earliest_import_history_iso(movie_id: int) -> Optional[str]: - rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries) - return rc.earliest_import_event(movie_id) - -def _radarr_earliest_file_dateadded_iso(movie_id: int) -> Optional[str]: - rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries) - return rc.earliest_dateadded(movie_id) - -def _strategy_movie_date_from_movie_obj(movie_obj: Dict[str, Any], which: str) -> Optional[str]: - key_map = { - "digital": ["digitalRelease", "digitalReleaseDate"], - "physical": ["physicalRelease", "physicalReleaseDate"], - "cinema": ["inCinemas", "inCinema", "theatricalRelease"], - } - for k in key_map.get(which, []): - val = movie_obj.get(k) - iso = _parse_date_to_iso(val) if val else None - if iso: - return iso - return None - -def _collect_digital_candidates(imdb_id: str, movie_obj: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]: - cands: List[Tuple[str, str]] = [] - _log("DEBUG", f"Collecting digital dates for {imdb_id}") - - if movie_obj: - iso = _strategy_movie_date_from_movie_obj(movie_obj, "digital") - if iso: - cands.append((iso, "radarr:digitalRelease")) - _log("DEBUG", f"Found Radarr digital date: {iso}") - - iso = tmdb_digital_date_from_imdb(imdb_id) - if iso: - cands.append((iso, "tmdb:digital")) - _log("DEBUG", f"Found TMDB digital date: {iso}") - - tmdb_id = _tmdb_id_from_imdb(imdb_id) - if tmdb_id: - iso = jellyseerr_digital_date_from_tmdb(tmdb_id) - if iso: - cands.append((iso, "jellyseerr:digital")) - _log("DEBUG", f"Found Jellyseerr digital date: {iso}") - - iso = omdb_dvd_date(imdb_id) - if iso: - cands.append((iso, "omdb:dvd")) - _log("DEBUG", f"Found OMDb DVD date: {iso}") - - _log("INFO", f"Found {len(cands)} digital date candidates: {[f'{src}={date}' for date, src in cands]}") - return cands - -def _pick_earliest(cands: List[Tuple[str, str]]) -> Optional[Tuple[str, str]]: - if not cands: - return None - try: - return sorted( - cands, - key=lambda t: datetime.fromisoformat(t[0].replace("Z", "+00:00")) - )[0] - except Exception: - return cands[0] - -def _pick_digital_closest_to_cinema(cands: List[Tuple[str, str]], cinema_date: Optional[str]) -> Optional[Tuple[str, str]]: - """Pick digital release date closest to cinema date, with TMDB preferred over Radarr as tiebreaker""" - if not cands: - return None - if not cinema_date: - return _pick_earliest(cands) # Fall back to earliest if no cinema date - - try: - cinema_dt = datetime.fromisoformat(cinema_date.replace("Z", "+00:00")) - - # Source preference: TMDB > Jellyseerr > OMDb > Radarr - source_priority = { - "tmdb:digital": 0, - "jellyseerr:digital": 1, - "omdb:dvd": 2, - "radarr:digitalRelease": 3 - } - - # Sort candidates by cinema proximity first, then by source preference - def sort_key(candidate): - try: - cand_dt = datetime.fromisoformat(candidate[0].replace("Z", "+00:00")) - days_diff = abs((cand_dt - cinema_dt).days) - - # Primary sort: distance from cinema (within 5 years preferred) - if days_diff <= 1825: # 5 years * 365 days - distance_score = days_diff - else: - # Heavily penalize dates far from cinema release - distance_score = days_diff + 10000 - - # Secondary sort: source preference (lower number = higher preference) - source_score = source_priority.get(candidate[1], 99) - - # Return tuple for sorting: (distance, source_priority) - return (distance_score, source_score) - - except Exception: - return (999999, 99) # Invalid dates/sources go to the end - - sorted_cands = sorted(cands, key=sort_key) - selected = sorted_cands[0] - - # Log the decision for debugging - try: - selected_dt = datetime.fromisoformat(selected[0].replace("Z", "+00:00")) - days_diff = (selected_dt - cinema_dt).days - _log("DEBUG", f"Selected digital date {selected[0]} ({selected[1]}) - {days_diff} days from cinema date {cinema_date}") - - # Show other candidates if multiple exist - if len(cands) > 1: - other_cands = [f"{c[1]}={c[0]}" for c in sorted_cands[1:3]] # Show top 2 alternatives - _log("DEBUG", f"Other candidates considered: {other_cands}") - except Exception: - pass - - return selected - - except Exception: - _log("WARNING", "Failed to parse cinema date for digital selection, using earliest") - return _pick_earliest(cands) - -def _decide_movie_date(imdb_id: str, movie_dir: Path, poll_external: bool) -> Tuple[Optional[str], str, Optional[str]]: - """ - Decide and supporting 'released' (premiered/year) for NFO. - - Returns: (dateadded_iso, source_detail, released_iso) - """ - # Initialize fresh variables for each movie - released_iso: Optional[str] = None - movie_obj: Optional[Dict[str, Any]] = None - movie_id: Optional[int] = None - - _log("DEBUG", f"Starting date selection for {imdb_id} - fresh state initialized") - - def from_file_mtime() -> Tuple[Optional[str], str]: - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - newest = None - for f in movie_dir.iterdir(): - if f.is_file() and f.suffix.lower() in video_exts: - try: - mt = f.stat().st_mtime - if newest is None or mt > newest: - newest = mt - except Exception: - continue - if newest: - try: - iso = datetime.fromtimestamp(newest, tz=timezone.utc).isoformat(timespec="seconds") - return iso, "file:mtime" - except Exception: - pass - return None, "file:mtime" - - def ensure_movie(): - nonlocal movie_obj, movie_id, released_iso - if movie_obj is None and poll_external and config.radarr_url and config.radarr_api_key: - _log("DEBUG", f"Looking up Radarr movie for IMDb ID: {imdb_id}") - movie_obj = _radarr_movie_by_imdb(imdb_id) - if movie_obj: - movie_id = movie_obj.get("id") - movie_title = movie_obj.get("title", "Unknown") - movie_imdb = movie_obj.get("imdbId", "Unknown") - - # CRITICAL: Verify returned movie matches requested IMDb ID - if movie_imdb.lower() != imdb_id.lower(): - _log("ERROR", f"Radarr API returned WRONG movie! Requested: {imdb_id}, Got: {movie_imdb} ({movie_title}). Ignoring this result.") - movie_obj = None - movie_id = None - _log("WARNING", f"Movie {imdb_id} not properly found in Radarr - will use digital/cinema dates instead") - else: - _log("INFO", f"Found Radarr movie: {movie_title} (ID: {movie_id}, IMDb: {movie_imdb}) for requested {imdb_id}") - released_iso = _parse_date_to_iso(movie_obj.get("inCinemas") or movie_obj.get("theatricalRelease")) - else: - movie_id = None # Explicitly set to None when movie not found - movie_obj = None - _log("WARNING", f"No Radarr movie found for IMDb ID: {imdb_id} - will skip import history") - - def from_import_history() -> Tuple[Optional[str], str]: - ensure_movie() - if not poll_external: - _log("DEBUG", f"Skipping import history: poll_external={poll_external}") - return None, "radarr:history.skipped" - - if not movie_id: - _log("DEBUG", f"Skipping import history: movie not found in Radarr for {imdb_id}") - return None, "radarr:movie.not_found" - - # Try import history events first (most reliable) - iso = _radarr_earliest_import_history_iso(movie_id) - if iso: - _log("INFO", f"Using Radarr import history date: {iso} for movie_id {movie_id} (IMDb: {imdb_id})") - return iso, "radarr:history.import" - - # If no real import events found, return None to continue to digital/cinema dates - # instead of falling back to arbitrary moviefile.dateAdded - _log("DEBUG", f"No real import events found for movie_id {movie_id}, will try digital/cinema dates") - return None, "radarr:history.none" - - def from_radarr_field(which: str) -> Tuple[Optional[str], str]: - ensure_movie() - label_map = {"digital": "radarr:digitalRelease", "physical": "radarr:physicalRelease", "cinema": "radarr:inCinemas"} - if poll_external and movie_obj: - iso = _strategy_movie_date_from_movie_obj(movie_obj, which) - return iso, label_map.get(which, "radarr:field") - return None, label_map.get(which, "radarr:field") - - def from_external_digital_bundle() -> Tuple[Optional[str], str]: - ensure_movie() - cands = _collect_digital_candidates(imdb_id, movie_obj) - # Use cinema date to pick the most appropriate digital release - cinema_date = None - if movie_obj: - cinema_date = movie_obj.get("inCinemas") or movie_obj.get("theatricalRelease") - picked = _pick_digital_closest_to_cinema(cands, cinema_date) - return picked if picked else (None, "digital:none") - - def from_moviefile_dateadded() -> Tuple[Optional[str], str]: - """Last resort fallback for existing files that were just renamed/scanned""" - ensure_movie() - if not movie_id: - return None, "radarr:moviefile.not_found" - iso = _radarr_earliest_file_dateadded_iso(movie_id) - if iso: - _log("INFO", f"Using Radarr moviefile dateAdded as fallback: {iso} for movie_id {movie_id}") - return iso, "radarr:moviefile.dateAdded" - return None, "radarr:moviefile.none" - - def maybe_sanitize_digital(digi_iso: Optional[str]) -> Tuple[Optional[str], str]: - if not digi_iso or not config.movie_digital_sanity_check: - return digi_iso, "radarr:digitalRelease" - ref_dates: List[datetime] = [] - if config.movie_digital_sanity_ref in ("cinema", "min"): - cine_iso, _ = from_radarr_field("cinema") - if cine_iso: - try: - ref_dates.append(datetime.fromisoformat(cine_iso.replace("Z", "+00:00"))) - except Exception: - pass - if config.movie_digital_sanity_ref in ("import", "min"): - imp_iso, _ = from_import_history() - if imp_iso: - try: - ref_dates.append(datetime.fromisoformat(imp_iso.replace("Z", "+00:00"))) - except Exception: - pass - if not ref_dates: - return digi_iso, "radarr:digitalRelease" - try: - d_digi = datetime.fromisoformat(digi_iso.replace("Z", "+00:00")) - ref_dt = min(ref_dates) - lag = (d_digi - ref_dt).days - if lag > config.movie_digital_max_lag_days: - if config.movie_digital_invalid_fallback == "import": - imp_iso2, imp_src2 = from_import_history() - if imp_iso2: - return imp_iso2, "radarr:history.import" - cine_iso2, _ = from_radarr_field("cinema") - if cine_iso2: - return cine_iso2, "radarr:inCinemas" - except Exception: - pass - return digi_iso, "radarr:digitalRelease" - - # Priority flows - _log("INFO", f"Date selection for {imdb_id}: priority={config.movie_priority}, poll_external={poll_external}") - - if config.movie_priority == "import_then_digital": - _log("DEBUG", "Trying import history first (import_then_digital mode)") - imp_iso, imp_src = from_import_history() - if imp_iso: - _log("INFO", f"Selected import date: {imp_iso} ({imp_src})") - return imp_iso, imp_src, released_iso - - _log("DEBUG", "No import date found, trying digital release dates") - digi_iso, digi_src = from_external_digital_bundle() - digi_iso, digi_src = maybe_sanitize_digital(digi_iso) - if digi_iso: - _log("INFO", f"Selected digital date: {digi_iso} ({digi_src})") - return digi_iso, digi_src, released_iso - - _log("DEBUG", "No digital date found, trying cinema release") - cine_iso, cine_src = from_radarr_field("cinema") - if cine_iso: - _log("INFO", f"Selected cinema date: {cine_iso} ({cine_src})") - return cine_iso, cine_src, released_iso or cine_iso - - _log("DEBUG", "No external dates found, trying moviefile dateAdded as fallback") - moviefile_iso, moviefile_src = from_moviefile_dateadded() - if moviefile_iso: - _log("WARNING", f"Using moviefile dateAdded for existing/renamed file: {moviefile_iso} ({moviefile_src})") - return moviefile_iso, moviefile_src, released_iso - - _log("DEBUG", "No moviefile date found, falling back to file mtime") - mtime_iso, mtime_src = from_file_mtime() - if mtime_iso: - _log("WARNING", f"Using file mtime as last resort: {mtime_iso} ({mtime_src})") - return mtime_iso, mtime_src, released_iso - - # No valid date found - create manual review marker - _log("ERROR", f"No valid dateadded found for {imdb_id} - marking for MANUAL_REVIEW_NEEDED") - return "MANUAL_REVIEW_NEEDED", "manual_review_required", released_iso - - # digital_then_import - _log("DEBUG", "Trying digital release dates first (digital_then_import mode)") - digi_iso, digi_src = from_external_digital_bundle() - digi_iso, digi_src = maybe_sanitize_digital(digi_iso) - if digi_iso: - _log("INFO", f"Selected digital date: {digi_iso} ({digi_src})") - return digi_iso, digi_src, released_iso - - _log("DEBUG", "No digital date found, trying import history") - imp_iso, imp_src = from_import_history() - if imp_iso: - _log("INFO", f"Selected import date: {imp_iso} ({imp_src})") - return imp_iso, imp_src, released_iso - - _log("DEBUG", "No import date found, trying cinema release") - cine_iso, cine_src = from_radarr_field("cinema") - if cine_iso: - _log("INFO", f"Selected cinema date: {cine_iso} ({cine_src})") - return cine_iso, cine_src, released_iso or cine_iso - - _log("DEBUG", "No external dates found, falling back to file mtime") - mtime_iso, mtime_src = from_file_mtime() - if mtime_iso: - _log("WARNING", f"Using file mtime as last resort: {mtime_iso} ({mtime_src})") - return mtime_iso, mtime_src, released_iso - - # No valid date found - create manual review marker - _log("ERROR", f"No valid dateadded found for {imdb_id} - marking for MANUAL_REVIEW_NEEDED") - return "MANUAL_REVIEW_NEEDED", "manual_review_required", released_iso - -# --------------------------- -# Movie apply -# --------------------------- - -def parse_imdb_from_movie_path(p: Path) -> Optional[str]: - m = re.search(r'\[imdb-(tt\d+)\]', p.name, re.IGNORECASE) - imdb_id = m.group(1).lower() if m else None - _log("DEBUG", f"Parsed IMDb ID from path '{p.name}': {imdb_id}") - return imdb_id - -def is_movie_dir(p: Path) -> bool: - return p.is_dir() and parse_imdb_from_movie_path(p) is not None - -def set_movie_file_mtime(movie_dir: Path, dateadded_iso: str): - if not dateadded_iso: - return - try: - ts = datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp() - - # Update ALL files in the directory to the correct timestamp - files_updated = 0 - for f in movie_dir.iterdir(): - if f.is_file(): - try: - os.utime(f, (ts, ts), follow_symlinks=False) - files_updated += 1 - _log("DEBUG", f"Updated file mtime: {f.name} -> {dateadded_iso}") - except Exception as e: - _log("WARNING", f"Failed to update mtime on {f.name}: {e}") - - # Update directory timestamp - try: - os.utime(movie_dir, (ts, ts), follow_symlinks=False) - _log("INFO", f"Updated {files_updated} files and directory mtime: {movie_dir.name} -> {dateadded_iso}") - except Exception as e: - _log("WARNING", f"Failed to update mtime on directory {movie_dir}: {e}") - except Exception as e: - _log("WARNING", f"Bad timestamp for movie mtimes: {dateadded_iso}: {e}") - -def apply_movie(movie_dir: Path, conn: sqlite3.Connection): - imdb_id = parse_imdb_from_movie_path(movie_dir) - if not imdb_id: - _log("ERROR", f"Movie path does not contain an IMDb id [imdb-tt...]: {movie_dir}") - return - - ensure_movie_tables(conn) - - conn.execute( - "INSERT INTO movies(imdb_id, movie_path, last_applied) VALUES(?,?,?) " - "ON CONFLICT(imdb_id) DO UPDATE SET movie_path=excluded.movie_path, last_applied=excluded.last_applied", - (imdb_id, str(movie_dir), _iso_now()), - ) - - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - has_video_files = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_dir.iterdir()) - if not has_video_files: - _log("WARNING", f"No video files found in movie directory: {movie_dir}") - conn.execute( - "INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) " - "ON CONFLICT(imdb_id) DO UPDATE SET has_video_file=excluded.has_video_file", - (imdb_id, None, None, None, 0), - ) - conn.commit() - return - - row = conn.execute( - "SELECT released, dateadded, source FROM movie_dates WHERE imdb_id=?", (imdb_id,) - ).fetchone() - existing_released = row[0] if row else None - existing_dateadded = row[1] if row else None - existing_source = row[2] if row else None - - # poll policy - if config.movie_poll_mode == "never": - poll_external = False - elif config.movie_poll_mode == "if_missing": - poll_external = not existing_dateadded or (existing_source or "") == "file:mtime" - else: - poll_external = True - - preserve = False - if config.movie_date_update_mode == "backfill_only": - if existing_dateadded: - if (config.movie_preserve_radarr_import and (existing_source or "").startswith("radarr:moviefile")) \ - or (config.movie_preserve_existing and (existing_source or "") != "file:mtime"): - preserve = True - - if preserve: - dateadded_iso = existing_dateadded - src_detail = existing_source or "unknown" - if not existing_released and poll_external and (config.radarr_url and config.radarr_api_key): - movie_obj = _radarr_movie_by_imdb(imdb_id) - if movie_obj: - existing_released = _parse_date_to_iso(movie_obj.get("inCinemas") or movie_obj.get("theatricalRelease")) - chosen_released = existing_released - _log("INFO", f"Preserving existing movie date for {movie_dir.name} ({dateadded_iso}; {src_detail})") - else: - dateadded_iso, src_detail, decided_released = _decide_movie_date(imdb_id, movie_dir, poll_external=poll_external) - chosen_released = existing_released or decided_released - conn.execute( - "INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) " - "ON CONFLICT(imdb_id) DO UPDATE SET released=excluded.released, dateadded=excluded.dateadded, " - "source=excluded.source, has_video_file=excluded.has_video_file", - (imdb_id, chosen_released, dateadded_iso, src_detail, 1), - ) - - if config.manage_nfo: - upsert_movie_nfo( - movie_dir, - imdb_id, - chosen_released, - dateadded_iso, - source_detail=src_detail or "unknown", - lock_metadata=config.lock_metadata, - media_label="Movie", - brand=config.manager_brand, - ) - - if config.fix_dir_mtimes and dateadded_iso and dateadded_iso != "MANUAL_REVIEW_NEEDED": - set_movie_file_mtime(movie_dir, dateadded_iso) - - conn.commit() - _log("INFO", f"Processed movie: {movie_dir.name} (source={src_detail or 'unknown'}; preserved={preserve})") - - # Add detailed logging for debugging with searchable format - _log("INFO", f"MOVIE_SUMMARY: imdb={imdb_id} title='{movie_dir.name}' dateadded={dateadded_iso} source={src_detail} preserved={preserve}") - -# --------------------------- -# Batching -# --------------------------- - -class WebhookBatcher: - def __init__(self): - self.pending_items: Dict[str, Dict] = {} - self.timers: Dict[str, threading.Timer] = {} - self.processing: Set[str] = set() - self.lock = threading.Lock() - self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent_series, thread_name_prefix="media-processor") - - def add_webhook(self, key: str, webhook_data: Dict, media_type: str): - with self.lock: - if key in self.timers: - self.timers[key].cancel() - _log("DEBUG", f"Cancelled existing timer for {key}") - - webhook_data['media_type'] = media_type - self.pending_items[key] = webhook_data - _log("INFO", f"Batched {media_type} webhook for {key} (delay: {config.batch_delay}s)") - - timer = threading.Timer(config.batch_delay, self._process_item, args=[key]) - self.timers[key] = timer - timer.start() - - def _process_item(self, key: str): - with self.lock: - if key in self.processing or key not in self.pending_items: - return - self.processing.add(key) - webhook_data = self.pending_items.pop(key) - self.timers.pop(key, None) - - try: - media_type = webhook_data.get('media_type', 'unknown') - _log("INFO", f"Processing batched {media_type}: {key}") - self.executor.submit(self._process_item_sync, key, webhook_data) - except Exception as e: - _log("ERROR", f"Error submitting {media_type} processing for {key}: {e}") - with self.lock: - self.processing.discard(key) - - def _process_item_sync(self, key: str, webhook_data: Dict): - try: - media_type = webhook_data.get('media_type', 'unknown') - if media_type == 'tv': - self._process_tv_sync(key, webhook_data) - elif media_type == 'movie': - self._process_movie_sync(key, webhook_data) - else: - _log("ERROR", f"Unknown media type: {media_type}") - finally: - with self.lock: - self.processing.discard(key) - - def _process_tv_sync(self, imdb_id: str, webhook_data: Dict): - try: - series_info = webhook_data.get('series', {}) - series_title = series_info.get("title", "Unknown") - series_path_str = webhook_data.get('series_path') - - if not series_path_str: - _log("ERROR", f"No series path found for {imdb_id}") - return - - series_path = Path(series_path_str) - if not series_path.exists(): - _log("ERROR", f"Series path does not exist: {series_path}") - return - - conn = None - try: - conn = db_connect(db_path) - conn.execute("PRAGMA busy_timeout = 30000") - conn.execute("PRAGMA journal_mode = WAL") - - class MockArgs: - def __init__(self): - self.manage_nfo = config.manage_nfo - self.fix_dir_mtimes = config.fix_dir_mtimes - self.lock_metadata = config.lock_metadata - self.use_cache = True - self.force_refresh = False - self.skip_if_current = False - self.debug = config.debug - self.update_video_mtimes = True - - _log("INFO", f"Processing TV series: {series_title} at {series_path}") - apply_series(series_path, MockArgs(), conn) - _log("INFO", f"Successfully processed batched TV series: {series_title}") - - except Exception as e: - _log("ERROR", f"Error processing TV series {series_title}: {e}") - finally: - if conn: - try: - conn.close() - except: - pass - except Exception as e: - _log("ERROR", f"Error in TV processing: {e}") - - def _process_movie_sync(self, imdb_id: str, webhook_data: Dict): - try: - movie_info = webhook_data.get('movie', {}) - movie_title = movie_info.get("title", "Unknown") - movie_path_str = webhook_data.get('movie_path') - - if not movie_path_str: - _log("ERROR", f"No movie path found for {imdb_id}") - return - - movie_path = Path(movie_path_str) - if not movie_path.exists(): - _log("ERROR", f"Movie path does not exist: {movie_path}") - return - - conn = None - try: - conn = db_connect(db_path) - conn.execute("PRAGMA busy_timeout = 30000") - conn.execute("PRAGMA journal_mode = WAL") - - _log("INFO", f"Processing movie: {movie_title} at {movie_path}") - apply_movie(movie_path, conn) - _log("INFO", f"Successfully processed batched movie: {movie_title}") - - except Exception as e: - _log("ERROR", f"Error processing movie {movie_title}: {e}") - finally: - if conn: - try: - conn.close() - except: - pass - except Exception as e: - _log("ERROR", f"Error in movie processing: {e}") - - def get_status(self) -> Dict: - with self.lock: - return { - "pending_items": list(self.pending_items.keys()), - "processing_items": list(self.processing), - "pending_count": len(self.pending_items), - "processing_count": len(self.processing), - "executor_active_threads": getattr(self.executor, '_threads', 0) - } - -batcher = WebhookBatcher() - -# --------------------------- -# Path helpers -# --------------------------- - -def find_series_path_from_sonarr_path(sonarr_path: str) -> Optional[Path]: - if not sonarr_path: - return None - if sonarr_path.startswith("/mnt/unionfs/Media/TV/"): - container_path = sonarr_path.replace("/mnt/unionfs/Media/TV/", "/media/") - path = Path(container_path) - if path.exists(): - _log("INFO", f"Found series path via Sonarr path: {path}") - return path - return None - -def find_movie_path_from_radarr_path(radarr_path: str) -> Optional[Path]: - if not radarr_path: - return None - if radarr_path.startswith("/mnt/unionfs/Media/Movies/"): - container_path = radarr_path.replace("/mnt/unionfs/Media/Movies/", "/media/") - path = Path(container_path) - if path.exists(): - _log("INFO", f"Found movie path via Radarr path: {path}") - return path - return None - -def find_series_path(series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]: - if sonarr_path: - series_path = find_series_path_from_sonarr_path(sonarr_path) - if series_path: - return series_path - - if imdb_id and not imdb_id.startswith("tt"): - imdb_id = f"tt{imdb_id}" - - _log("DEBUG", f"Searching for series: {series_title} with IMDb ID: {imdb_id}") - - for media_path in config.tv_paths: - if not media_path.exists(): - _log("WARNING", f"TV path does not exist: {media_path}") - continue - - if imdb_id: - pattern = str(media_path / f"*[imdb-{imdb_id}]*") - matches = glob.glob(pattern) - if matches: - found_path = Path(matches[0]) - _log("INFO", f"Found series directory by IMDb ID: {found_path}") - return found_path - - if series_title: - series_title_clean = series_title.lower().replace(" ", "").replace("-", "").replace(":", "") - for item in media_path.iterdir(): - if item.is_dir() and "[imdb-" in item.name.lower(): - item_clean = item.name.lower().replace(" ", "").replace("-", "").replace(":", "") - if series_title_clean in item_clean: - _log("INFO", f"Found series directory by title: {item}") - return item - - _log("WARNING", f"Could not find directory for series: {series_title} ({imdb_id})") - return None - -def find_movie_path(movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]: - if radarr_path: - movie_path = find_movie_path_from_radarr_path(radarr_path) - if movie_path: - return movie_path - - if imdb_id and not imdb_id.startswith("tt"): - imdb_id = f"tt{imdb_id}" - - _log("DEBUG", f"Searching for movie: {movie_title} with IMDb ID: {imdb_id}") - - for media_path in config.movie_paths: - if not media_path.exists(): - _log("WARNING", f"Movie path does not exist: {media_path}") - continue - - if imdb_id: - pattern = str(media_path / f"*[imdb-{imdb_id}]*") - matches = glob.glob(pattern) - if matches: - found_path = Path(matches[0]) - _log("INFO", f"Found movie directory by IMDb ID: {found_path}") - return found_path - - if movie_title: - movie_title_clean = movie_title.lower().replace(" ", "").replace("-", "").replace(":", "") - for item in media_path.iterdir(): - if item.is_dir() and "[imdb-" in item.name.lower(): - item_clean = item.name.lower().replace(" ", "").replace("-", "").replace(":", "") - if movie_title_clean in item_clean: - _log("INFO", f"Found movie directory by title: {item}") - return item - - _log("WARNING", f"Could not find directory for movie: {movie_title} ({imdb_id})") - return None - -# --------------------------- -# Webhook processors -# --------------------------- - -async def process_sonarr_import(webhook_data: SonarrWebhook): - try: - if not webhook_data.series: - _log("WARNING", f"No series data in {webhook_data.eventType} webhook") - return - - series_info = webhook_data.series - series_title = series_info.get("title", "Unknown") - imdb_id = series_info.get("imdbId", "") - sonarr_path = series_info.get("path", "") - - if imdb_id: - imdb_id = imdb_id.replace("tt", "").strip() - if imdb_id: - imdb_id = f"tt{imdb_id}" - - if not imdb_id: - _log("ERROR", f"No IMDb ID found for series: {series_title}") - return - - _log("INFO", f"Received webhook for series: {series_title} (IMDb: {imdb_id})") - - series_path = find_series_path(series_title, imdb_id, sonarr_path) - if not series_path: - _log("ERROR", f"Could not find series directory for: {series_title} ({imdb_id})") - return - - webhook_dict = { - 'series': series_info, - 'series_path': str(series_path), - 'event_type': webhook_data.eventType - } - - batcher.add_webhook(imdb_id, webhook_dict, 'tv') - - except Exception as e: - _log("ERROR", f"Error processing Sonarr webhook: {e}") - raise - -async def process_radarr_import(webhook_data: RadarrWebhook): - try: - if not webhook_data.movie: - _log("WARNING", f"No movie data in {webhook_data.eventType} webhook") - return - - movie_info = webhook_data.movie - movie_title = movie_info.get("title", "Unknown") - radarr_path = movie_info.get("folderPath") or movie_info.get("path", "") - - imdb_id = (movie_info.get("imdbId") or (webhook_data.remoteMovie or {}).get("imdbId") or "").strip() - if imdb_id: - imdb_id = f"tt{imdb_id.replace('tt','')}" - - movie_path = find_movie_path(movie_title, imdb_id, radarr_path) - if not imdb_id and movie_path: - parsed = parse_imdb_from_movie_path(movie_path) - if parsed: - imdb_id = parsed - - if not imdb_id: - _log("WARNING", f"No IMDb ID available for movie '{movie_title}'. Proceeding by path only.") - - _log("INFO", f"Received webhook for movie: {movie_title} (IMDb: {imdb_id or 'unknown'})") - if webhook_data.renamedMovieFiles: - _log("INFO", f"Renamed {len(webhook_data.renamedMovieFiles)} file(s) for {movie_title}") - - if not movie_path: - _log("ERROR", f"Could not find movie directory for: {movie_title} ({imdb_id or 'unknown'})") - return - - webhook_dict = { - 'movie': movie_info, - 'movie_path': str(movie_path), - 'event_type': webhook_data.eventType - } - - batcher.add_webhook(imdb_id or str(movie_path), webhook_dict, 'movie') - - except Exception as e: - _log("ERROR", f"Error processing Radarr webhook: {e}") - raise - -# --------------------------- -# Payload reader (json or form) -# --------------------------- - -async def _read_payload(request: Request) -> dict: - ctype = (request.headers.get("content-type") or "").lower() - try: - if "application/json" in ctype: - return await request.json() - form = await request.form() - if "payload" in form: - return json.loads(form["payload"]) - return dict(form) - except Exception as e: - _log("ERROR", f"Failed to read webhook payload: {e}") - return {} - -# --------------------------- -# Routes -# --------------------------- - -@app.post("/webhook/sonarr") -async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks): - _log("INFO", "Hit /webhook/sonarr") - try: - payload = await _read_payload(request) - if not payload: - raise HTTPException(status_code=422, detail="Empty or unreadable Sonarr payload") - - _log("DEBUG", f"Raw Sonarr webhook payload: {payload}") - webhook = SonarrWebhook(**payload) - _log("INFO", f"Received Sonarr webhook: {webhook.eventType}") - - if webhook.eventType not in ["Download", "Upgrade", "Rename"]: - return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"} - - await process_sonarr_import(webhook) - return {"status": "accepted", "message": "Sonarr webhook queued for batched processing"} - - except HTTPException: - raise - except Exception as e: - _log("ERROR", f"Failed to parse Sonarr webhook: {e}") - raise HTTPException(status_code=422, detail=f"Invalid Sonarr webhook format: {e}") - -@app.post("/webhook/radarr") -async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): - _log("INFO", "Hit /webhook/radarr") - try: - payload = await _read_payload(request) - if not payload: - raise HTTPException(status_code=422, detail="Empty or unreadable Radarr payload") - - _log("DEBUG", f"Raw Radarr webhook payload: {payload}") - webhook = RadarrWebhook(**payload) - _log("INFO", f"Received Radarr webhook: {webhook.eventType}") - - if webhook.eventType not in ["Download", "Upgrade", "Rename", "MovieFileDelete", "MovieDelete", "Test"]: - return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"} - - if webhook.eventType != "Test": - await process_radarr_import(webhook) - return {"status": "accepted", "message": "Radarr webhook queued for batched processing"} - else: - return {"status": "ok", "message": "Radarr test received"} - - except HTTPException: - raise - except Exception as e: - _log("ERROR", f"Failed to parse Radarr webhook: {e}") - raise HTTPException(status_code=422, detail=f"Invalid Radarr webhook format: {e}") - -@app.get("/health") -async def health_check() -> HealthResponse: - uptime = datetime.now(timezone.utc) - start_time - try: - conn = db_connect(db_path) - conn.execute("PRAGMA busy_timeout = 5000") - conn.execute("SELECT 1").fetchone() - conn.close() - db_status = "healthy" - except Exception as e: - db_status = f"error: {e}" - - return HealthResponse( - status="healthy" if db_status == "healthy" else "degraded", - version="1.7.0", - uptime=str(uptime), - database_status=db_status - ) - -@app.get("/stats") -async def get_stats(): - try: - conn = db_connect(db_path) - conn.execute("PRAGMA busy_timeout = 5000") - ensure_movie_tables(conn) - - tv_series_count = conn.execute("SELECT COUNT(*) FROM series").fetchone()[0] - tv_episode_count = conn.execute("SELECT COUNT(*) FROM episode_dates").fetchone()[0] - tv_with_files = conn.execute("SELECT COUNT(*) FROM episode_dates WHERE has_video_file = 1").fetchone()[0] - - movie_count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0] - movies_with_files = conn.execute("SELECT COUNT(*) FROM movie_dates WHERE has_video_file = 1").fetchone()[0] - - tv_sources = conn.execute(""" - SELECT source, COUNT(*) as count - FROM episode_dates - WHERE has_video_file = 1 - GROUP BY source - ORDER BY count DESC - """).fetchall() - - movie_sources = conn.execute(""" - SELECT source, COUNT(*) as count - FROM movie_dates - WHERE has_video_file = 1 - GROUP BY source - ORDER BY count DESC - """).fetchall() - - conn.close() - - return { - "tv_series_count": tv_series_count, - "tv_episode_count": tv_episode_count, - "tv_episodes_with_files": tv_with_files, - "movie_count": movie_count, - "movies_with_files": movies_with_files, - "tv_sources": dict(tv_sources), - "movie_sources": dict(movie_sources) - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/batch/status") -async def get_batch_status(): - return batcher.get_status() - -@app.get("/debug/movie/{imdb_id}") -async def debug_movie_import_date(imdb_id: str): - """Debug endpoint to analyze movie import date detection""" - try: - if not imdb_id.startswith("tt"): - imdb_id = f"tt{imdb_id}" - - _log("INFO", f"=== DEBUG MOVIE IMPORT DATE: {imdb_id} ===") - - if not (config.radarr_url and config.radarr_api_key): - return { - "error": "Radarr not configured", - "imdb_id": imdb_id, - "radarr_configured": False - } - - # Create Radarr client - rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries) - - # Look up movie - movie_obj = rc.movie_by_imdb(imdb_id) - if not movie_obj: - return { - "error": f"Movie not found in Radarr for IMDb ID {imdb_id}", - "imdb_id": imdb_id, - "radarr_configured": True, - "movie_found": False - } - - movie_id = movie_obj.get("id") - movie_title = movie_obj.get("title") - - _log("INFO", f"Found movie: {movie_title} (Radarr ID: {movie_id})") - - # Get import history with detailed logging - import_date = rc.earliest_import_event(movie_id) - - # Get movie files for additional context - movie_files = rc.movie_files(movie_id) - file_dates = [] - for f in movie_files: - file_dates.append({ - "id": f.get("id"), - "relativePath": f.get("relativePath"), - "dateAdded": f.get("dateAdded"), - "quality": f.get("quality", {}).get("quality", {}).get("name", "Unknown") - }) - - # Get ALL history pages - July events might be in older pages - raw_history = [] - try: - page = 1 - all_records = [] - - # Fetch multiple pages to find older events - while page <= 10: # Get up to 10 pages (500 records) - data = rc._get("/api/v3/history", { - "movieId": movie_id, "sortKey": "date", "sortDirection": "ascending", - "pageSize": 50, "page": page - }) - - if not data: - break - - records = data if isinstance(data, list) else data.get("records", []) - if not records: - break - - all_records.extend(records) - - # If we got less than 50, we've reached the end - if len(records) < 50: - break - - page += 1 - - _log("INFO", f"Fetched {len(all_records)} total history records across {page-1} pages for debug") - - for record in all_records: - event_type = record.get("eventType", "") - date_str = record.get("date", "") - event_data = record.get("data", {}) - - # Try multiple ways to get source path info - prioritize droppedPath - source_path = ( - event_data.get('droppedPath', '') or # This contains the real download path! - event_data.get('sourcePath', '') or - event_data.get('path', '') or - event_data.get('sourceTitle', '') or - record.get('sourcePath', '') or - record.get('sourceTitle', '') - ) - - # Check if this looks like a real import - is_import_type = any(word in event_type.lower() for word in ['import', 'download']) - is_from_downloads = any(indicator in source_path.lower() for indicator in [ - '/downloads/', '/download/', '/completed/', '/nzbs/', '/torrents/', - 'nzbget', 'sabnzbd', 'radarr', 'completed/' - ]) - - # Check for July 7 events specifically - is_july_7 = "2025-07-07" in date_str or "2025-07-08" in date_str # Include July 8 for timezone differences - - raw_history.append({ - "eventType": event_type, - "date": date_str, - "sourcePath": source_path, - "fullData": event_data, # Include full data for analysis - "isImportType": is_import_type, - "isFromDownloads": is_from_downloads, - "isRealImport": is_import_type and is_from_downloads, - "isJuly7Event": is_july_7 - }) - - except Exception as e: - raw_history = [{"error": f"Could not fetch raw history: {e}"}] - - return { - "imdb_id": imdb_id, - "radarr_configured": True, - "movie_found": True, - "movie_title": movie_title, - "movie_id": movie_id, - "detected_import_date": import_date, - "movie_files": file_dates, - "raw_history": raw_history, - "analysis": { - "import_date_found": import_date is not None, - "expected_july_7_2025": "2025-07-07" in (import_date or ""), - "method_used": "radarr:history.import" if import_date else "none", - "march_24_is_add_date": "2025-03-24" in (import_date or ""), - "digital_release_matches_july_7": "2025-07-07" in (movie_obj.get("digitalRelease") or "") - }, - "debug_info": { - "radarr_url": config.radarr_url, - "movie_digital_release": movie_obj.get("digitalRelease"), - "movie_in_cinemas": movie_obj.get("inCinemas"), - "movie_physical_release": movie_obj.get("physicalRelease") - } - } - - except Exception as e: - _log("ERROR", f"Debug endpoint error for {imdb_id}: {e}") - return { - "error": str(e), - "imdb_id": imdb_id, - "success": False - } - -# --------------------------- -# Manual scans -# --------------------------- - -def run_manual_scan_sync(scan_paths: List[Path], scan_type: str = "both"): - from media_date_cache import iterate_target_path - - class MockArgs: - def __init__(self): - self.manage_nfo = config.manage_nfo - self.fix_dir_mtimes = config.fix_dir_mtimes - self.lock_metadata = config.lock_metadata - self.use_cache = True - self.force_refresh = False - self.skip_if_current = True - self.debug = config.debug - self.update_video_mtimes = True - - conn = None - try: - conn = db_connect(db_path) - conn.execute("PRAGMA busy_timeout = 30000") - conn.execute("PRAGMA journal_mode = WAL") - ensure_movie_tables(conn) - - for scan_path in scan_paths: - _log("INFO", f"Starting manual scan of: {scan_path}") - - if scan_type in ["both", "tv"]: - # Only run TV scan on paths that are in tv_paths - if scan_path in config.tv_paths: - tv_targets = iterate_target_path(scan_path) - for series_dir in tv_targets: - try: - apply_series(series_dir, MockArgs(), conn) - except Exception as e: - _log("ERROR", f"Failed processing TV series {series_dir}: {e}") - - if scan_type in ["both", "movies"]: - if scan_path in config.movie_paths: - for item in scan_path.iterdir(): - if is_movie_dir(item): - try: - apply_movie(item, conn) - except Exception as e: - _log("ERROR", f"Failed processing movie {item}: {e}") - - _log("INFO", f"Completed manual scan of: {scan_path}") - finally: - if conn: - try: - conn.close() - except: - pass - -@app.post("/manual/scan") -async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"): - if scan_type not in ["both", "tv", "movies"]: - raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'") - - if path: - scan_paths = [Path(path)] - else: - if scan_type == "movies": - scan_paths = list(config.movie_paths) - elif scan_type == "tv": - scan_paths = list(config.tv_paths) - else: - scan_paths = list(config.tv_paths) + list(config.movie_paths) - - async def run_scan(): - await asyncio.get_event_loop().run_in_executor(None, run_manual_scan_sync, scan_paths, scan_type) - - background_tasks.add_task(run_scan) - return {"status": "started", "message": f"Manual {scan_type} scan started for {len(scan_paths)} paths"} - -@app.post("/manual/scan/tv") -async def manual_scan_tv(background_tasks: BackgroundTasks, path: Optional[str] = None): - return await manual_scan(background_tasks, path, "tv") - -@app.post("/manual/scan/movies") -async def manual_scan_movies(background_tasks: BackgroundTasks, path: Optional[str] = None): - return await manual_scan(background_tasks, path, "movies") - -# --------------------------- -# Main -# --------------------------- - -if __name__ == "__main__": - if config.debug: - import logging - logging.basicConfig(level=logging.DEBUG) - os.environ["DEBUG"] = "true" - - _log("INFO", "Starting NFOGuard Webhook Server") - _log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}") - _log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}") - _log("INFO", f"Database path: {db_path}") - _log("INFO", f"Manage NFO: {config.manage_nfo}") - _log("INFO", f"Fix dir mtimes: {config.fix_dir_mtimes}") - _log("INFO", f"Lock metadata: {config.lock_metadata}") - _log("INFO", f"Batch delay: {config.batch_delay}s") - _log("INFO", f"Max concurrent items: {config.max_concurrent_series}") - _log("INFO", f"MOVIE_PRIORITY={config.movie_priority}") - _log("INFO", f"MOVIE_POLL_MODE={config.movie_poll_mode}") - _log("INFO", f"MOVIE_DATE_STRATEGY={config.movie_date_strategy}") - _log("INFO", f"MOVIE_DATE_UPDATE_MODE={config.movie_date_update_mode} " - f"(preserve_import={config.movie_preserve_radarr_import}, " - f"preserve_existing={config.movie_preserve_existing})") - _log("INFO", f"MOVIE_DIGITAL_SANITY_CHECK={config.movie_digital_sanity_check} " - f"(ref={config.movie_digital_sanity_ref}, max_lag_days={config.movie_digital_max_lag_days}, " - f"invalid_fallback={config.movie_digital_invalid_fallback})") - if config.radarr_url and config.radarr_api_key: - _log("INFO", f"Radarr enabled @ {config.radarr_url} (timeout={config.radarr_timeout}s, retries={config.radarr_retries})") - if config.tmdb_api_key: - _log("INFO", f"TMDB enabled (country={config.tmdb_country})") - if config.omdb_api_key: - _log("INFO", "OMDb enabled") - if config.jelly_url and config.jelly_api: - _log("INFO", f"Jellyseerr enabled @ {config.jelly_url}") - -uvicorn.run( - app, # pass the object, not "app:app" - host="0.0.0.0", - port=int(os.environ.get("PORT", "8080")), - reload=False -) -