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
-)
-