diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..868805b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,60 @@
+FROM python:3.11-slim
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PYTHONIOENCODING=utf-8
+
+# Install system dependencies including PostgreSQL client libraries
+RUN apt-get update && apt-get install -y \
+ curl \
+ libpq-dev \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create app user and directory
+RUN useradd --create-home --shell /bin/bash app
+WORKDIR /app
+
+# Copy requirements and install Python dependencies
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Copy DLL to a dedicated directory and create entrypoint script
+RUN mkdir -p /app/emby-plugin && \
+ cp /app/Emby-DLL/NFOGuard.Emby.Plugin.dll /app/emby-plugin/ && \
+ echo '#!/bin/bash' > /app/deploy-plugin.sh && \
+ echo 'if [ -d "/emby-plugins" ]; then' >> /app/deploy-plugin.sh && \
+ echo ' echo "Deploying NFOGuard Emby Plugin to mounted directory: /emby-plugins"' >> /app/deploy-plugin.sh && \
+ echo ' cp /app/emby-plugin/NFOGuard.Emby.Plugin.dll /emby-plugins/' >> /app/deploy-plugin.sh && \
+ echo ' echo "Plugin deployed successfully!"' >> /app/deploy-plugin.sh && \
+ echo 'elif [ -n "$EMBY_PLUGINS_PATH" ] && [ -d "$EMBY_PLUGINS_PATH" ]; then' >> /app/deploy-plugin.sh && \
+ echo ' echo "Deploying NFOGuard Emby Plugin to: $EMBY_PLUGINS_PATH"' >> /app/deploy-plugin.sh && \
+ echo ' cp /app/emby-plugin/NFOGuard.Emby.Plugin.dll "$EMBY_PLUGINS_PATH/"' >> /app/deploy-plugin.sh && \
+ echo ' echo "Plugin deployed successfully!"' >> /app/deploy-plugin.sh && \
+ echo 'else' >> /app/deploy-plugin.sh && \
+ echo ' echo "No Emby plugins directory found - skipping plugin deployment"' >> /app/deploy-plugin.sh && \
+ echo ' echo "To enable plugin deployment, bind mount your Emby plugins directory to /emby-plugins"' >> /app/deploy-plugin.sh && \
+ echo 'fi' >> /app/deploy-plugin.sh && \
+ echo 'exec python nfoguard.py' >> /app/deploy-plugin.sh && \
+ chmod +x /app/deploy-plugin.sh
+
+# Set ownership
+RUN chown -R app:app /app
+
+# Switch to app user
+USER app
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:8080/health || exit 1
+
+# Expose port
+EXPOSE 8080
+
+# Run the application with plugin deployment
+CMD ["/app/deploy-plugin.sh"]
\ No newline at end of file
diff --git a/Emby-DLL/NFOGuard.Emby.Plugin.dll b/Emby-DLL/NFOGuard.Emby.Plugin.dll
new file mode 100644
index 0000000..c215455
Binary files /dev/null and b/Emby-DLL/NFOGuard.Emby.Plugin.dll differ
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..eab0617
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,36 @@
+MIT License
+
+Copyright (c) 2024 NFOGuard
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+---
+
+## Commercial Licensing
+
+For commercial use, enterprise features, or if the MIT license doesn't meet your needs,
+commercial licenses are available. Contact us for more information.
+
+Future premium features (such as web UI, advanced analytics, enterprise support)
+may be offered under separate commercial licensing terms.
+
+## Third-Party Licenses
+
+This software includes third-party libraries with their own licenses.
+See requirements.txt and the respective package documentation for details.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..dba03b0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,390 @@
+# NFOGuard
+
+[](https://hub.docker.com/r/sbcrumb/nfoguard)
+[](https://hub.docker.com/r/sbcrumb/nfoguard)
+[](https://hub.docker.com/r/sbcrumb/nfoguard)
+
+**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
+
+---
+
+> **โ ๏ธ ALPHA SOFTWARE NOTICE โ ๏ธ**
+>
+> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
+>
+> **๐ Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image โ no extra steps required.
+>
+> **๐ฌ Community Feedback**: Join our Discord if youโd like to share feedback, test new features early, or discuss improvements with other users:
+>
+> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
+>
+> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
+
+---
+
+NFOGuard automatically updates movie and TV show NFO files with proper release dates and metadata when triggered by Radarr/Sonarr webhooks. It preserves existing metadata while adding clean, accurate date information at the bottom of NFO files.
+
+## โจ Features
+
+- ๐ฌ **Movie & TV Support** - Works with both Radarr and Sonarr
+- ๐
**Smart Date Handling** - Prioritizes digital, physical, and theatrical release dates
+- ๐ **Webhook Integration** - Triggers automatically on import, upgrade, and rename
+- ๐๏ธ **Database Integration** - Direct PostgreSQL access for better performance
+- ๐ **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom
+- ๐ **Metadata Locking** - Prevents overwrites with lockdata tags
+- โก **Batch Processing** - Efficient handling of multiple files
+- ๐ณ **Docker Ready** - Easy deployment with Docker Compose
+
+## ๐ Quick Start
+
+### 1. Download Configuration Files
+
+```bash
+wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template
+wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template
+wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml
+```
+
+### 2. Configure Environment
+
+```bash
+# Copy and edit main configuration
+cp .env.template .env
+nano .env
+
+# Copy and edit secrets (API keys, passwords)
+cp .env.secrets.template .env.secrets
+nano .env.secrets
+chmod 600 .env.secrets
+
+# Copy and edit Docker Compose
+cp docker-compose.example.yml docker-compose.yml
+nano docker-compose.yml
+```
+
+### 3. Deploy
+
+```bash
+# Create data directory
+mkdir -p ./data
+
+# Start NFOGuard
+docker-compose up -d
+
+# Check logs
+docker-compose logs -f nfoguard
+
+# Verify health
+curl http://localhost:8080/health
+```
+
+## โ๏ธ Configuration
+
+### Environment Files
+
+| File | Purpose | Contains |
+|------|---------|----------|
+| `.env` | Main configuration | Paths, behavior settings, non-sensitive options |
+| `.env.secrets` | Sensitive data | API keys, passwords, database credentials |
+
+### Key Configuration Options
+
+**Media Paths** (Required):
+```bash
+# Container paths (what NFOGuard sees)
+MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
+TV_PATHS=/media/TV/tv,/media/TV/tv6
+
+# *arr application paths (what your apps see)
+RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies
+SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv
+```
+
+**Release Date Priority**:
+```bash
+RELEASE_DATE_PRIORITY=digital,physical,theatrical
+```
+
+**Debug Mode**:
+```bash
+DEBUG=false # Clean production logs
+SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures
+```
+
+## ๐ณ Docker Images
+
+### Production (Stable)
+```yaml
+image: sbcrumb/nfoguard:latest
+```
+
+### Development (Latest Features)
+```yaml
+image: sbcrumb/nfoguard:dev
+```
+
+### Specific Version
+```yaml
+image: sbcrumb/nfoguard:v1.5.5
+```
+
+## ๐ Webhook Setup
+
+Configure these webhook URLs in your applications:
+
+**Radarr**: `http://nfoguard:8080/webhook/radarr`
+**Sonarr**: `http://nfoguard:8080/webhook/sonarr`
+
+**Triggers**: On Import, On Upgrade, On Rename
+
+## ๐ Manual Operations
+
+### Manual Scanning
+
+Trigger manual scans via API endpoints:
+
+```bash
+# Manual scan all media (movies and TV)
+curl -X POST "http://localhost:8080/manual/scan?scan_type=both"
+
+# Manual scan TV only
+curl -X POST "http://localhost:8080/manual/scan?scan_type=tv"
+
+# Manual scan movies only
+curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
+
+# Manual scan specific path
+curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
+
+# Bulk update all movies from Radarr database
+curl -X POST "http://localhost:8080/bulk/update"
+```
+
+### API Endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/health` | GET | Health check |
+| `/webhook/radarr` | POST | Radarr webhook handler |
+| `/webhook/sonarr` | POST | Sonarr webhook handler |
+| `/manual/scan` | POST | Manual media scanning |
+| `/bulk/update` | POST | Bulk movie updates from Radarr DB |
+
+### Manual Scan Parameters
+
+- `scan_type`: `both`, `movies`, `tv`
+- `path`: Specific directory path to scan
+- Use for initial setup or fixing existing media
+
+## ๐ Volume Mapping
+
+Critical: Update `docker-compose.yml` with your actual paths:
+
+```yaml
+volumes:
+ - ./data:/app/data # NFOGuard data
+ - /your/movies/path:/media/Movies/movies:rw # Movie library
+ - /your/tv/path:/media/TV/tv:rw # TV library
+```
+
+### Common Examples
+
+**Unraid**:
+```yaml
+- /mnt/user/Media/Movies:/media/Movies/movies:rw
+- /mnt/user/Media/TV:/media/TV/tv:rw
+```
+
+**Synology**:
+```yaml
+- /volume1/Media/Movies:/media/Movies/movies:rw
+- /volume1/Media/TV:/media/TV/tv:rw
+```
+
+**Standard Linux**:
+```yaml
+- /home/user/media/movies:/media/Movies/movies:rw
+- /home/user/media/tv:/media/TV/tv:rw
+```
+
+## ๐ง Troubleshooting
+
+### Check Logs
+```bash
+docker-compose logs -f nfoguard
+```
+
+### Enable Debug Mode
+```bash
+# In .env file
+DEBUG=true
+PATH_DEBUG=true
+```
+
+### Health Check
+```bash
+curl http://localhost:8080/health
+```
+
+### Common Issues
+
+1. **Permission Errors**: Ensure NFOGuard can write to mounted directories
+2. **Path Mapping**: Verify container paths match `.env` configuration
+3. **Webhooks**: Check URLs and ensure port 8080 is accessible
+4. **Database**: Verify PostgreSQL credentials in `.env.secrets`
+
+## ๐ What NFOGuard Does
+
+### Before
+```xml
+
+ Movie Title
+ 2023
+
+
+```
+
+### After
+```xml
+
+ Movie Title
+ 2023
+
+
+
+ 2023-03-15
+ true
+
+
+```
+
+## ๐ Requirements
+
+- Docker and Docker Compose
+- Radarr and/or Sonarr
+- Media files in accessible directories
+- Network connectivity between services
+
+## ๐ Directory Structure Requirements
+
+NFOGuard identifies movies and TV shows using two methods: directory names with IMDb IDs (primary) or NFO files with IMDb IDs (fallback). Your media should follow these conventions:
+
+### ๐ฌ **Movies**
+
+**Directory Structure:**
+```
+/movies/
+โโโ Movie Title (2024) [tt1234567]/
+ โโโ movie.mkv
+ โโโ movie.nfo (created by NFOGuard)
+```
+
+**Identification Methods:**
+1. **Primary**: Directory name contains IMDb ID in brackets: `[tt1234567]` or `[imdb-tt1234567]`
+2. **Fallback**: NFO file with IMDb ID in movie.nfo file (see NFO format below)
+3. **Video file** required: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`
+4. **Case insensitive** - `[TT1234567]` works too
+
+**Examples:**
+```
+โ
Action Film (2024) [tt1234567]/
+โ
Drama Movie [imdb-tt7654321]/
+โ
SciFi Thriller (2023) [TT9876543]/
+โ Missing IMDB Directory/
+```
+
+### ๐บ **TV Shows**
+
+**Directory Structure:**
+```
+/tv/
+โโโ Series Title (2024) [tt1234567]/
+ โโโ Season 01/
+ โ โโโ Series S01E01.mkv
+ โ โโโ Series S01E02.mkv
+ โ โโโ S01E01.nfo (created by NFOGuard)
+ โ โโโ S01E02.nfo (created by NFOGuard)
+ โโโ Season 02/
+ โโโ tvshow.nfo (created by NFOGuard)
+```
+
+**Identification Methods:**
+1. **Primary**: Series directory contains IMDb ID: `[tt1234567]` or `[imdb-tt1234567]`
+2. **Fallback**: NFO file with IMDb ID in tvshow.nfo file (see NFO format below)
+3. **Season directories** must match pattern: `Season 01`, `Season 1`, `season 01` etc.
+4. **Episode files** must contain season/episode info:
+ - **SxxExx format**: `S01E01`, `S1E1`, `s01e01`
+ - **Dot format**: `1.1`, `01.01`
+5. **Video extensions**: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.ts`, `.m2ts`
+
+**Examples:**
+```
+โ
Drama Series (2024) [tt1234567]/
+ โโโ Season 01/
+ โ โโโ Drama S01E01.mkv
+ โ โโโ Drama S01E02.mkv
+ โโโ Season 02/
+
+โ
Comedy Show [tt7654321]/
+ โโโ Season 1/
+ โโโ Comedy 1.1.mkv
+ โโโ Comedy 1.2.mkv
+
+โ Series Without IMDB []/
+โ Series [tt1234567]/Episode01.mkv (no season directory)
+โ Series [tt1234567]/Season 1/RandomName.mkv (no episode pattern)
+```
+
+### ๐ **NFO File Identification Format**
+
+NFOGuard can extract IMDb IDs from existing NFO files using these XML tags:
+
+**Movie NFO (movie.nfo):**
+```xml
+
+tt1234567
+
+
+tt1234567
+
+
+tt1234567
+```
+
+**TV Show NFO (tvshow.nfo):**
+```xml
+
+tt1234567
+tt1234567
+tt1234567
+```
+
+**Episode NFO Files:**
+NFOGuard creates standardized episode NFO files using the pattern `S##E##.nfo`:
+- `S01E01.nfo` for Season 1, Episode 1
+- `S02E05.nfo` for Season 2, Episode 5
+- Always zero-padded format (S01E01, not S1E1)
+- **Smart Rename**: NFOGuard will find existing NFO files (created by Sonarr/other tools), extract their metadata, and rename them to the standard format
+
+### ๐ซ **What Gets Skipped**
+NFOGuard will ignore:
+- Directories without IMDb IDs in brackets AND no NFO files with IMDb IDs
+- Directories without video files
+- TV episodes without recognizable season/episode patterns
+- Season directories that don't match "Season X" format
+
+## ๐ Support
+
+- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
+- **Documentation**: See `SETUP.md` for detailed instructions
+- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
+
+## ๐ License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+Commercial licensing and enterprise features may be available separately. Contact us for more information.
+
+---
+
+**NFOGuard** - Keeping your media metadata clean and organized! ๐ฏ
diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 0000000..0798ec5
--- /dev/null
+++ b/SETUP.md
@@ -0,0 +1,153 @@
+# NFOGuard Setup Guide
+
+## ๐ Secure Configuration Setup
+
+NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
+
+- **`.env`** - Main configuration (safe to share for debugging)
+- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
+
+### Step 1: Copy Configuration Templates
+
+```bash
+# Copy main configuration
+cp .env.template .env
+
+# Copy secrets configuration
+cp .env.secrets.template .env.secrets
+```
+
+### Step 2: Configure Main Settings
+
+Edit your `.env` file with your specific paths and preferences:
+
+```bash
+# Media paths (adjust to your directory structure)
+TV_PATHS=/media/TV/tv,/media/TV/tv6
+MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
+
+# Database connection details
+RADARR_DB_HOST=radarr-postgres
+RADARR_DB_PORT=5432
+RADARR_DB_NAME=radarr
+RADARR_DB_USER=radarr
+
+# Processing preferences
+PREFER_RELEASE_DATES_OVER_FILE_DATES=true
+ALLOW_FILE_DATE_FALLBACK=false
+RELEASE_DATE_PRIORITY=digital,physical,theatrical
+
+# TV webhook processing mode (v0.6.0+)
+TV_WEBHOOK_PROCESSING_MODE=targeted
+```
+
+### Step 3: Configure Secrets
+
+Edit your `.env.secrets` file with your actual API keys and passwords:
+
+```bash
+# Database password
+RADARR_DB_PASSWORD=your_actual_radarr_password
+
+# TMDB API key (required for release date detection)
+TMDB_API_KEY=your_actual_tmdb_api_key
+
+# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
+SONARR_API_KEY=your_actual_sonarr_api_key
+
+# Optional API keys
+RADARR_API_KEY=your_radarr_api_key
+OMDB_API_KEY=your_omdb_api_key
+```
+
+### Step 4: Verify Configuration
+
+Test your setup:
+
+```bash
+# Test database connections
+curl -X POST "http://localhost:8080/test/bulk-update"
+
+# Test movie scanning
+curl -X POST "http://localhost:8080/test/movie-scan"
+
+# Check system health
+curl "http://localhost:8080/health"
+```
+
+## ๐ Security Features
+
+### API Key Masking
+All API keys and passwords are automatically masked in logs:
+```
+[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
+[2025-09-09T12:34:56] INFO: Database connection password=***masked***
+```
+
+### Sensitive Data Separation
+- **Main `.env`**: Paths, preferences, URLs (safe to share)
+- **`.env.secrets`**: API keys, passwords (never commit to version control)
+- **Automatic loading**: Both files loaded automatically at startup
+
+### Git Protection
+The `.gitignore` file prevents accidental commits:
+```
+.env
+.env.secrets
+.env.local
+```
+
+## ๐ Troubleshooting
+
+### "Environment files not loaded" Warning
+Install python-dotenv:
+```bash
+pip install python-dotenv==1.0.0
+# or
+docker-compose build # rebuilds with updated requirements.txt
+```
+
+### Sharing Configuration for Help
+You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
+
+### Migration from Old Setup
+If you have an existing `.env` with API keys:
+1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
+2. Remove sensitive data from `.env`
+3. Restart NFOGuard
+
+## ๐ฏ Docker Compose Example
+
+```yaml
+version: '3.8'
+services:
+ nfoguard:
+ image: sbcrumb/nfoguard:latest
+ container_name: nfoguard
+ ports:
+ - "8080:8080"
+ volumes:
+ - /path/to/your/media:/media:rw
+ - ./data:/app/data
+ - ./.env:/app/.env:ro # Main configuration
+ - ./.env.secrets:/app/.env.secrets:ro # Secrets
+ environment:
+ - PORT=8080
+ depends_on:
+ - radarr-postgres
+```
+
+## ๐ Configuration Reference
+
+### Main Configuration (.env)
+- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
+- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
+- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
+- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
+
+### Secrets Configuration (.env.secrets)
+- **Database**: `RADARR_DB_PASSWORD`
+- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
+- **Optional**: `JELLYSEERR_API_KEY`
+
+This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
\ No newline at end of file
diff --git a/SUMMARY.md b/SUMMARY.md
new file mode 100644
index 0000000..181b580
--- /dev/null
+++ b/SUMMARY.md
@@ -0,0 +1,496 @@
+# NFOGuard Project Summary
+
+## Overview
+NFOGuard is a Python-based tool that manages NFO (metadata) files for movies and TV shows in Plex/Emby media servers. It integrates with Radarr and Sonarr to automatically manage metadata, ensuring proper dates and metadata consistency.
+
+## ๐ฏ CURRENT SESSION ACCOMPLISHMENTS (September 21, 2025)
+
+### โ
MAJOR ISSUES RESOLVED
+1. **Database Save Bug**: Identified and resolved "phantom" database save issue (was logging interpretation error)
+2. **Enhanced Movie Detection**: Movies without IMDb IDs in directory names now detected via filenames/NFO content
+3. **Enhanced Logging**: Crystal-clear source attribution showing NFOGuard DB vs Radarr vs External APIs
+4. **Priority Flow Confirmed**: Perfect implementation of user's requested flow
+
+### โ
KEY IMPROVEMENTS MADE
+
+**๐ Enhanced Movie Detection**:
+- Added `find_movie_imdb_id()` function with comprehensive detection
+- Supports directory names, filenames, and NFO file content parsing
+- Movies like "Ick (2024)" now properly processed without directory IMDb tags
+
+**๐ Crystal-Clear Logging System**:
+- Added emoji-based logging for easy visual scanning
+- Clear source attribution: `nfoguard_db:`, `radarr:db.`, `tmdb:`, etc.
+- Priority flow display shows exact decision path
+- Final completion logs show chosen date and source
+
+**๐ Perfect Priority Flow**:
+1. NFOGuard Database (cached entries) โ
+2. Radarr Import History (real import dates) โ
+3. External APIs (TMDB/OMDb with configurable priority) โ
+4. Save to NFOGuard Database (cache for future)
+
+**๐ Manual Scan Commands**:
+- Movies only: `curl -X POST "http://nfoguard:8080/manual/scan?scan_type=movies"`
+- TV only: `curl -X POST "http://nfoguard:8080/manual/scan?scan_type=tv"`
+- Both: `curl -X POST "http://nfoguard:8080/manual/scan?scan_type=both"`
+
+### โ
CONFIGURATION MAINTAINED
+All existing .env settings fully preserved and respected:
+- `RELEASE_DATE_PRIORITY=digital,physical,theatrical`
+- `MOVIE_PRIORITY=import_then_digital`
+- `PREFER_RELEASE_DATES_OVER_FILE_DATES=true`
+- `ALLOW_FILE_DATE_FALLBACK=false`
+
+### โ
DEBUGGING COMPLETED
+- **Root Cause**: "Phantom database save" was actually two different movie executions in logs
+- **Database Save**: Working correctly in both early-exit and full-processing paths
+- **NFO Processing**: Fixed to execute before date validation (preserves existing metadata)
+- **File Detection**: Enhanced to handle movies without directory IMDb tags
+
+## Core Architecture
+
+### Main Components
+- **nfoguard.py**: Main application file containing the core logic
+- **core/nfo_manager.py**: Handles NFO file creation and updates
+- **clients/**: External API integrations (Radarr, Sonarr, etc.)
+
+### Key Features
+- Creates and updates movie.nfo files with proper metadata
+- Manages TV show NFO files (tvshow.nfo, season.nfo, episode NFOs)
+- Preserves existing metadata while adding NFOGuard-managed fields
+- Supports date management from multiple sources (Radarr, file dates, etc.)
+- Webhook support for real-time processing
+
+## Current Issue Analysis
+
+### Problem
+When a movie.nfo file already exists (e.g., from Radarr), NFOGuard is **not** processing the file to:
+1. Move date fields to the bottom
+2. Add NFOGuard comment
+3. Add NFOGuard-managed fields (dateadded, lockdata, etc.)
+
+### Root Cause Identified
+The issue is in `nfoguard.py:1009-1012`. The processing logic has a premature exit condition:
+
+```python
+# Skip processing if no valid date found and file dates disabled
+if dateadded is None:
+ _log("WARNING", f"Skipping movie {movie_path.name} - no valid date source available")
+ self.db.upsert_movie_dates(imdb_id, released, None, source, True)
+ return # <-- This exits BEFORE NFO processing
+```
+
+**The NFO processing code (lines 1015-1018) never executes when dateadded is None.**
+
+### Analysis of NFO Processing Logic
+The `create_movie_nfo` function in `core/nfo_manager.py:52-147` is actually well-designed:
+- Lines 60-78: Properly loads and preserves existing NFO content
+- Lines 69-78: Removes NFOGuard-managed fields to re-add them at the bottom
+- Lines 96-123: Adds all NFOGuard fields at the end
+- Lines 124-141: Adds NFOGuard comment and proper formatting
+
+**The NFO manager code is correct - it's just not being called due to the early exit.**
+
+## Files That Need Updates
+1. `nfoguard.py` - Fix the early exit logic around line 1009-1012
+
+## Status
+- โ
NFO Processing Issue: Fixed in nfoguard.py:1008-1025
+- ๐ **NEW ISSUE**: Database not persisting dateadded values on manual scans
+
+## Fix Summary
+
+### Movie NFO Processing (โ
Fixed)
+**Problem**: NFO processing was skipped entirely when `dateadded` was None due to early return.
+
+**Solution**: Moved NFO processing (`create_movie_nfo`) to execute **before** the `dateadded` check, ensuring that:
+1. Existing NFO files are always processed and standardized
+2. NFOGuard comment is added
+3. Date fields are moved to bottom
+4. lockdata is added if configured
+5. Only file mtime updates are skipped when no valid date is found
+
+### TV Episode NFO Processing (โ
Enhanced)
+**Enhancement**: Added smart episode NFO migration for long-named files.
+
+**New Functionality**:
+1. **Detection**: `find_existing_episode_nfo()` scans for non-standard NFO files that match season/episode
+2. **Migration**: Extracts metadata from long-named files (e.g., `Episode.Name.Here.S01E05.nfo`)
+3. **Standardization**: Creates standard `S01E05.nfo` with preserved metadata + NFOGuard enhancements
+4. **Cleanup**: Automatically deletes old long-named NFO files after successful migration
+5. **Preservation**: All existing metadata is preserved, dates moved to bottom, NFOGuard fields added
+
+## ๐ DATABASE SAVE BUG RESOLVED! โ
+
+### Problem Analysis COMPLETED
+**Root Cause Identified**: The "phantom database save bug" was actually a **logging interpretation error**.
+
+### What Actually Happened
+The debug logs showing these two lines:
+```
+DEBUG: Movie processing reached post-mtime section: Dear Santa (2024) [imdb-tt2396431]
+INFO: Completed processing movie: Dear Santa (2024) [imdb-tt2396431] (source: tmdb:digital)
+```
+
+**Cannot be from the same function execution!** Here's why:
+
+### The Real Execution Flow
+**Line 1014-1018**: Early exit when `dateadded is None`:
+```python
+if dateadded is None:
+ _log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed")
+ self.db.upsert_movie_dates(imdb_id, released, None, source, True)
+ return # <-- EXITS HERE, never reaches line 1029!
+```
+
+**Line 1029**: Debug log that only executes when `dateadded` has a valid value:
+```python
+_log("DEBUG", f"Movie processing reached post-mtime section: {movie_path.name}")
+```
+
+**Line 1045**: Completion log that only executes after successful database save:
+```python
+_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
+```
+
+### Key Insight
+**The logs are from TWO different executions:**
+1. **First run**: `dateadded = None` โ early exit at line 1018 โ database save executes BUT for NULL value
+2. **Second run**: `dateadded` has value โ reaches line 1029 โ full processing โ line 1045
+
+### Database Save Status
+โ
**Database saves ARE working correctly!**
+- Early exit path (line 1018): Saves NULL dateadded when no valid date found
+- Full processing path (lines 1038-1043): Saves valid dateadded when available
+- Both paths call `upsert_movie_dates()` appropriately
+
+### Investigation Status
+โ
**Enhanced Movie Detection**: Working (Ick 2024 now processed)
+โ
**NFO Processing**: Working (files get updated)
+โ
**File Timestamp Updates**: Working (mtimes get set)
+โ
**Database Persistence**: **WORKING CORRECTLY** - saves happen in both execution paths
+
+## ๐ ENHANCED LOGGING SYSTEM (September 21, 2025) โ
+
+### New Crystal-Clear Source Attribution
+**Problem**: Logs didn't clearly show whether dates came from NFOGuard DB vs Radarr vs external APIs.
+
+**Solution**: Added comprehensive logging with emojis and clear source identification.
+
+### New Logging Format
+**Priority Flow Display**:
+```
+๐ PRIORITY FLOW: NFOGuard DB โ Radarr import โ External APIs (digital,physical,theatrical) โ Save to NFOGuard DB
+```
+
+**NFOGuard Database Lookups**:
+```
+๐ NFOGuard DB lookup for tt1234567: FOUND - dateadded=2025-06-15T11:10:31-04:00, source=radarr:db.history.import
+โ
MANUAL SCAN: Using existing NFOGuard database entry: 2025-06-15T11:10:31-04:00 (original source: radarr:db.history.import)
+```
+
+**External Source Queries**:
+```
+๐ RADARR DATABASE: Checking import history for movie ID 123 (tt1234567)...
+๐ฆ RADARR DATABASE: Found import date=2025-07-20T14:30:00Z, source=radarr:db.history.import
+๐ EXTERNAL APIs: Trying digital release date fallback...
+๐ EXTERNAL APIs: Found digital release date=2025-06-15T00:00:00+00:00, source=tmdb:digital
+```
+
+**Final Decision Tracking**:
+```
+โ
FINAL CHOICE: Using Radarr import date 2025-07-20T14:30:00-04:00 from radarr:db.history.import
+๐ฌ COMPLETED: Movie Name (2024) | Final date: 2025-07-20T14:30:00-04:00 | Final source: radarr:db.history.import
+```
+
+### Source Attribution System
+**NFOGuard Database**: `nfoguard_db:radarr:db.history.import` (shows original source)
+**Radarr Database**: `radarr:db.history.import` (fresh query)
+**External APIs**: `tmdb:digital`, `omdb:dvd`, etc.
+
+### Perfect Priority Flow Confirmed
+1. โ
**NFOGuard DB First**: Check cached entries, use if available
+2. โ
**Radarr Import History**: Query for real import dates if not cached
+3. โ
**External APIs**: TMDB/OMDb fallback with configurable priority (`RELEASE_DATE_PRIORITY`)
+4. โ
**Save to NFOGuard DB**: Cache result for future lookups
+
+**This ensures we don't repeatedly query Radarr/external APIs for the same movies!**
+
+## ๐ MANUAL SCAN API COMMANDS
+
+### Available Scan Types
+**Movies Only:**
+```bash
+curl -X POST "http://nfoguard:8080/manual/scan?scan_type=movies"
+```
+
+**TV Shows Only:**
+```bash
+curl -X POST "http://nfoguard:8080/manual/scan?scan_type=tv"
+```
+
+**Both Movies AND TV Shows (Default):**
+```bash
+curl -X POST "http://nfoguard:8080/manual/scan?scan_type=both"
+# OR simply:
+curl -X POST "http://nfoguard:8080/manual/scan"
+```
+
+**Specific Path Scan:**
+```bash
+curl -X POST "http://nfoguard:8080/manual/scan?path=/media/Movies/Movie.Name.2024&scan_type=movies"
+```
+
+### What Each Scan Does
+- **Movies**: Processes all movie directories in `MOVIE_PATHS`, follows NFOGuard priority flow
+- **TV**: Processes all TV series in `TV_PATHS`, updates episode NFO files with proper dates
+- **Both**: Processes both movies and TV shows in sequence (recommended for full refresh)
+
+All scans run in background and return immediately with status confirmation.
+
+### Movie IMDb ID Detection (โ
Enhanced)
+**Enhancement**: Added comprehensive IMDb ID detection for movies without directory tags.
+
+**New Detection Methods**:
+1. **Directory Name**: `[imdb-tt123456]` format (existing)
+2. **Filename Patterns**: IMDb ID in any video file name `Movie.Name.2024.[tt123456].mkv`
+3. **NFO File Content**: `tt123456` (NEW)
+4. **Legacy NFO Tags**: `` and `` tags (NEW)
+
+**Implementation**:
+- Added `find_movie_imdb_id()` function that tries all methods in priority order
+- Extended `parse_imdb_from_nfo()` to handle XML parsing
+- Updated movie processing to use comprehensive detection
+
+**Example**: Ick (2024) directory without IMDb in folder name will now be detected via NFO file content.
+
+
+# NFOGuard Development Summary
+
+## Current Status: v1.6.6 - Docker Plugin Deployment! ๐ณ
+
+### Latest Updates (v1.6.6) - September 19, 2025
+- **๐ณ DOCKER PLUGIN DEPLOYMENT**: Added automatic Emby plugin deployment via Docker
+- **๐ฆ DLL PACKAGING**: NFOGuard.Emby.Plugin.dll now packaged with Docker image
+- **๐ BIND MOUNT SUPPORT**: Primary method using bind mount to Emby plugins directory
+- **โ๏ธ ENVIRONMENT FALLBACK**: Secondary method using EMBY_PLUGINS_PATH environment variable
+- **๐ AUTO-UPDATE**: Plugin automatically updates when new Docker image is deployed
+- **๐ CONFIGURATION**: Added .env.example with plugin deployment settings
+- **๐ STARTUP SCRIPT**: Container automatically deploys plugin on startup
+- **๐ฏ WORKFLOW INTEGRATION**: Compatible with Gitea โ DockerHub deployment pipeline
+
+### Docker Plugin Deployment Flow
+**Bind Mount Method (Recommended):**
+1. Set `EMBY_PLUGINS_PATH=/path/to/emby/plugins` in .env
+2. Docker bind mounts host directory to `/emby-plugins` in container
+3. On startup, NFOGuard copies DLL to mounted directory
+4. Plugin updates automatically with each new image deployment
+
+**Benefits:**
+- Works with separate Emby/NFOGuard containers
+- Compatible with native Emby installations
+- Secure - only exposes plugins directory
+- Automatic updates via CI/CD pipeline
+
+## Previous Status: v1.6.0 - NFO-Based Identification! ๐
+
+### Latest Updates (v1.6.0) - September 16, 2025
+- **๐ NFO IDENTIFICATION**: Added support for identifying movies/TV shows from NFO files
+- **๐ DUAL METHOD**: Directory name IMDb IDs (primary) + NFO file IMDb IDs (fallback)
+- **๐ NFO FORMATS**: Supports ``, ``, and `` XML tags
+- **๐ฌ MOVIE SUPPORT**: Reads movie.nfo files for IMDb ID extraction
+- **๐บ TV SUPPORT**: Reads tvshow.nfo files for series identification
+- **โ
SMART FALLBACK**: Only checks NFO files when directory name lacks IMDb ID
+- **๐ DOCUMENTATION**: Updated README with NFO identification examples and formats
+- **๐ NFO RENAMING**: Added smart episode NFO file renaming to standardized `S##E##.nfo` format
+- **๐ฏ SONARR INTEGRATION**: Detects existing NFO files created by Sonarr/other tools, extracts metadata, and renames to standard format
+- **๐ METADATA PRESERVATION**: Preserves all existing metadata when renaming non-standard NFO files
+- **๐งน CLEANUP**: Removes old non-standard NFO files after successful rename to prevent duplicates
+
+### Previous Updates (v1.5.5) - September 15, 2025
+- **๐ง DATABASE BUG FIX**: Fixed `upsert_movie_dates` to be a proper upsert operation
+- **๐พ DATA INTEGRITY**: Manual scans now properly save `dateadded` to database
+- **๐ฏ ROOT CAUSE**: Fixed UPDATE-only operation that was leaving `dateadded` fields NULL
+- **โ
WEBHOOK FIX**: Webhooks now find existing database entries correctly
+- **๐บ TV SHOWS**: NOT affected - already using proper `INSERT OR REPLACE` operations
+
+### Previous Updates (v1.5.4) - September 15, 2025
+- **๐ฏ WEBHOOK DATE FIX**: Fixed webhook processing to use proper date decision logic
+- **๐ SMART FALLBACK**: Webhooks now check database โ Radarr import dates โ release dates โ timestamp
+- **โ ISSUE RESOLVED**: Upgrade webhooks no longer incorrectly use current timestamp when database exists
+- **โ
PROPER FLOW**: Webhook mode now uses same date logic as manual scans for missing entries
+
+### Previous Updates (v1.5.3) - September 15, 2025
+- **๐ง RADARR RENAME FIX**: Added missing event type filtering to Radarr webhooks
+- **โ
CONSISTENCY**: Radarr now matches Sonarr's event handling (Download, Upgrade, Rename)
+- **๐ฏ FIXED ISSUE**: Radarr rename events now properly trigger NFO updates with correct dates
+- **๐ WORKFLOW**: Both Sonarr and Radarr now follow identical webhook processing flows
+
+### Previous Updates (v1.5.2)
+- **๐ TVDB WARNINGS**: Added SUPPRESS_TVDB_WARNINGS configuration option
+- **โ
CLEAN LOGS**: Can now suppress non-critical TVDB API failure warnings
+- **๐งน PRODUCTION**: Cleaner logs for production monitoring
+- **๐ฏ FUNCTIONAL**: System works perfectly without TVDB (uses IMDb/Sonarr data)
+
+### TVDB Warning Suppression
+**Added new environment variable:**
+```bash
+# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
+SUPPRESS_TVDB_WARNINGS=true
+```
+
+### Why Suppress TVDB Warnings?
+- **โ Common Failures**: Many series don't exist in TVDB database
+- **โ
Non-Critical**: NFOGuard works perfectly without TVDB data
+- **๐ Primary Data**: Uses IMDb IDs and Sonarr metadata (more reliable)
+- **๐งน Log Noise**: Reduces repetitive warning messages
+
+### Before vs After
+
+**Before (noisy):**
+```
+INFO: All episodes found in cache
+WARNING: GET https://api4.thetvdb.com/v4/search/remoteid?remoteId=tt0804484&type=series failed: HTTP Error 400: Bad Request
+INFO: Completed processing TV series: Foundation (2021) [imdb-tt0804484]
+WARNING: GET https://api4.thetvdb.com/v4/search/remoteid?remoteId=tt9737326&type=series failed: HTTP Error 400: Bad Request
+```
+
+**After (clean):**
+```
+INFO: All episodes found in cache
+INFO: Completed processing TV series: Foundation (2021) [imdb-tt0804484]
+INFO: Completed processing TV series: Invasion (2021) [imdb-tt9737326]
+```
+
+### Your Configuration
+```bash
+DEBUG=false # Clean production logging
+PATH_DEBUG=false # No path mapping debug
+SUPPRESS_TVDB_WARNINGS=true # Hide non-critical TVDB failures
+```
+
+### System Status
+- โ
**Processing**: All TV series processing successfully
+- โ
**Episodes**: All episodes found and processed
+- โ
**NFO Files**: Being created/updated with proper metadata
+- โ
**Functionality**: TVDB failures don't affect operation
+
+## ๐ NFOGuard v1.5.2 - Ultra-clean production logs! ๐
+
+### Copy SUPPRESS_TVDB_WARNINGS=true to your server .env file and restart!
+
+---
+
+## ๐ง FIXED: Database Upsert Bug (September 15, 2025) - **CRITICAL**
+
+### Issue Discovery
+**The Real Root Cause**: Manual scans were NOT actually saving `dateadded` to database due to faulty upsert operation!
+
+### Problem Details
+**Database Method Bug** in `core/database.py:173-182`:
+- `upsert_movie_dates()` was doing UPDATE-only, not proper upsert
+- If movie row didn't exist properly, UPDATE would affect 0 rows
+- `dateadded` field remained NULL even after manual scan
+- Webhooks found database entries but with NULL `dateadded` โ fell back to current timestamp
+
+### ๐ง **Fix Applied**
+- **Location**: `core/database.py:173-186`
+- **Changed**: UPDATE-only โ proper `INSERT OR REPLACE` upsert
+- **Result**: Manual scans now properly save `dateadded` to database
+- **Webhook Impact**: Now finds existing dates correctly, no more false timestamps
+
+### Before vs After Database Operations
+**Before (BROKEN):**
+```sql
+UPDATE movies SET dateadded = '2025-06-15...' WHERE imdb_id = 'tt123';
+-- If row doesn't exist properly: 0 rows affected, dateadded stays NULL
+```
+
+**After (FIXED):**
+```sql
+INSERT OR REPLACE INTO movies (...) VALUES (...);
+-- Always works: Creates row OR replaces with all data including dateadded
+```
+
+---
+
+## ๐ฏ FIXED: Webhook Date Logic Issue (September 15, 2025)
+
+### Issue Discovery
+Upgrade webhooks were incorrectly using current timestamp (`webhook:first_seen`) instead of checking Radarr for proper import dates when no database entry existed.
+
+**Example Problem:**
+- Database populated via manual scan: `2025-06-15T11:10:31-04:00` (radarr:db.history.import)
+- Upgrade webhook comes in: `2025-09-15T12:04:23-04:00` (webhook:first_seen) โ **WRONG!**
+
+### Root Cause
+Webhook mode was bypassing the full date decision logic (`_decide_movie_dates`) and jumping straight to current timestamp as fallback.
+
+### โ
**Fix Applied**
+- **Location**: `nfoguard.py:975-990`
+- **Logic**: Webhook mode now uses same date priority as manual scans:
+ 1. **Database first** (if exists)
+ 2. **Full date logic** (Radarr import โ release dates โ fallbacks)
+ 3. **Current timestamp** (only as absolute last resort)
+
+### Result
+Webhook upgrades now properly find and use existing Radarr import dates instead of creating false "first seen" timestamps.
+
+---
+
+## ๐ง FIXED: Radarr Rename Event Issue (September 15, 2025)
+
+### Issue Resolution
+**COMPLETED**: Fixed missing Radarr rename event handling by adding proper event type filtering.
+
+### โ
**Both Sonarr & Radarr Webhook Handling** - **NOW WORKING CORRECTLY**
+- **Sonarr Location**: `nfoguard.py:1429`
+- **Radarr Location**: `nfoguard.py:1479` **(FIXED)**
+- **Event Filtering**: `if event_type not in ["Download", "Upgrade", "Rename"]:`
+- **โ
STATUS**: **Rename events ARE processed for both systems**
+- **โ
BEHAVIOR**: Both systems now update NFO files with proper date metadata on rename
+
+### What Was Fixed
+1. **Added Event Type Filtering**: Radarr webhooks now explicitly filter for supported events
+2. **Consistent Processing**: Both Sonarr and Radarr now follow identical webhook workflows
+3. **Proper Rename Handling**: Rename events trigger full NFO processing pipeline
+
+---
+
+## ๐ NFOGuard Webhook Workflow Documentation
+
+### ๐ฌ **Radarr Webhook Workflow**
+1. **Import Event**:
+ - Check database - if not seen before, current timestamp = truth of first import
+ - Update movie.nfo with proper date metadata
+
+2. **Rename Event**:
+ - Check database - if we have the date, use existing date
+ - Update movie.nfo file as always (preserving original import date)
+
+3. **Rename Event (No Database Entry)**:
+ - Check Radarr database for earliest import date
+ - If found, use earliest import date โ update database + movie.nfo
+ - If no import date found, use digital/theatrical release date (fallback logic)
+
+### ๐บ **Sonarr Webhook Workflow**
+1. **Import Event**:
+ - Check database - if not seen before, current timestamp = truth of first import
+ - Update episode.nfo with proper date metadata
+
+2. **Rename Event**:
+ - Check database - if we have the date, use existing date
+ - Update episode.nfo file as always (preserving original import date)
+
+3. **Rename Event (No Database Entry)**:
+ - Check Sonarr API for earliest import date
+ - If found, use earliest import date โ update database + episode.nfo
+ - If no import date found, use air date (fallback logic)
+
+### ๐ **Key Processing Logic**
+- **Database First**: Always check NFOGuard database for existing dates
+- **Import Truth**: Webhook import events establish "source of truth" timestamps
+- **Date Preservation**: Rename events preserve original import dates, never overwrite
+- **Smart Fallbacks**: API queries โ Release dates โ Air dates โ File dates (configurable)
+- **Batch Processing**: All webhooks go through batching system with configurable delays
\ No newline at end of file
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..d8c5e72
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.6.8
diff --git a/clients/external_clients.py b/clients/external_clients.py
new file mode 100644
index 0000000..1d4c16c
--- /dev/null
+++ b/clients/external_clients.py
@@ -0,0 +1,597 @@
+#!/usr/bin/env python3
+"""
+External API clients for TMDB, OMDb, and Jellyseerr
+"""
+import json
+import os
+import time
+from datetime import datetime, timezone
+from typing import Dict, Any, List, Optional, Tuple
+from urllib.parse import urlencode, quote
+from urllib.request import Request as UrlRequest, urlopen
+from urllib.error import URLError, HTTPError
+
+from core.logging import _log
+
+
+def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]:
+ """Make GET request and return JSON"""
+ 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 HTTPError as e:
+ # Handle specific HTTP errors more gracefully
+ if suppress_404 and e.code in [400, 404]:
+ _log("DEBUG", f"TVDB API: {url} - item not found (HTTP {e.code}) - this is expected")
+ return None
+ else:
+ _log("WARNING", f"GET {url} failed: HTTP Error {e.code}: {e.reason}")
+ return None
+ except Exception as e:
+ _log("WARNING", f"GET {url} failed: {e}")
+ return None
+
+
+def _parse_date_to_iso(date_str: str) -> Optional[str]:
+ """Parse various date formats to ISO string"""
+ if not date_str or date_str == "N/A":
+ return None
+ try:
+ if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD
+ dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
+ else:
+ dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
+ return dt.isoformat(timespec="seconds")
+ except Exception:
+ return None
+
+
+class TVDBClient:
+ """The TV Database API client for IMDB to TVDB ID conversion"""
+
+ def __init__(self, api_key: str = None):
+ self.api_key = api_key or os.environ.get("TVDB_API_KEY", "")
+ self.base_url = "https://api4.thetvdb.com/v4"
+ self._token = None
+ self._token_expires = 0
+
+ def _get_token(self) -> Optional[str]:
+ """Get TVDB auth token (cached)"""
+ if not self.api_key:
+ _log("DEBUG", "TVDB: No API key provided")
+ return None
+
+ if time.time() < self._token_expires and self._token:
+ return self._token
+
+ try:
+ _log("DEBUG", f"TVDB: Authenticating with API key: {self.api_key[:8]}...")
+ req = UrlRequest(
+ f"{self.base_url}/login",
+ data=json.dumps({"apikey": self.api_key}).encode('utf-8'),
+ headers={"Content-Type": "application/json"}
+ )
+ with urlopen(req, timeout=10) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ _log("DEBUG", f"TVDB login response: {data}")
+ if data.get("status") == "success":
+ self._token = data["data"]["token"]
+ self._token_expires = time.time() + 3600 # 1 hour
+ _log("INFO", f"โ
TVDB: Authentication successful")
+ return self._token
+ else:
+ _log("WARNING", f"TVDB login failed: {data}")
+ except Exception as e:
+ _log("WARNING", f"TVDB login failed: {e}")
+ return None
+
+ def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
+ """Convert IMDB ID to TVDB series ID using TVDB v4 API"""
+ token = self._get_token()
+ if not token:
+ return None
+
+ try:
+ # Try the official v4 search endpoint first
+ # According to docs: /search?query=imdb_id&type=series
+ url = f"{self.base_url}/search?query={imdb_id}&type=series&meta=translations"
+ headers = {"Authorization": f"Bearer {token}"}
+
+ _log("DEBUG", f"TVDB: Searching for {imdb_id} using /search endpoint")
+ data = _get_json(url, headers=headers, suppress_404=True)
+
+ if data and data.get("status") == "success" and data.get("data"):
+ series_list = data["data"]
+ _log("DEBUG", f"TVDB search response: found {len(series_list)} results")
+
+ # Look for exact IMDB match in results
+ for series in series_list:
+ # Check if this series has the IMDB ID we're looking for
+ remote_ids = series.get("remote_ids", [])
+ for remote in remote_ids:
+ if (remote.get("source_name") == "IMDB" and
+ remote.get("remote_id") == imdb_id):
+ tvdb_id = series.get("tvdb_id") or series.get("id")
+ if tvdb_id:
+ _log("INFO", f"โ
TVDB: Found series {imdb_id} โ {tvdb_id}")
+ return str(tvdb_id)
+
+ # If no exact match, try the first result if it looks promising
+ if series_list:
+ first_result = series_list[0]
+ tvdb_id = first_result.get("tvdb_id") or first_result.get("id")
+ if tvdb_id:
+ _log("INFO", f"โ
TVDB: Found series {imdb_id} โ {tvdb_id} (first result)")
+ return str(tvdb_id)
+
+ # If search didn't work, try the legacy remoteid endpoint
+ _log("DEBUG", f"TVDB: Trying legacy remoteid endpoint for {imdb_id}")
+ url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
+ data = _get_json(url, headers=headers, suppress_404=True)
+
+ if data and data.get("status") == "success" and data.get("data"):
+ series_list = data["data"]
+ if series_list and len(series_list) > 0:
+ tvdb_id = series_list[0].get("id")
+ if tvdb_id:
+ _log("INFO", f"โ
TVDB: Found series {imdb_id} โ {tvdb_id} (legacy endpoint)")
+ return str(tvdb_id)
+
+ # If we get here, the series wasn't found in TVDB
+ _log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)")
+
+ except Exception as e:
+ _log("WARNING", f"TVDB API error for {imdb_id}: {e}")
+
+ return None
+
+
+class TMDBClient:
+ """The Movie Database API client"""
+
+ def __init__(self, api_key: str = None, primary_country: str = "US"):
+ self.api_key = api_key or os.environ.get("TMDB_API_KEY", "")
+ self.primary_country = primary_country.upper()
+ self.enabled = bool(self.api_key)
+
+ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
+ """Make GET request to TMDB API"""
+ if not self.enabled:
+ return None
+
+ params = params or {}
+ params["api_key"] = self.api_key
+ url = f"https://api.themoviedb.org/3{path}?{urlencode(params)}"
+ return _get_json(url, timeout=20)
+
+ def find_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """Find movie by IMDb ID"""
+ result = self._get(f"/find/{quote(imdb_id)}", {"external_source": "imdb_id"})
+ if result and result.get("movie_results"):
+ return result["movie_results"][0]
+ return None
+
+ def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
+ """Get detailed movie information"""
+ return self._get(f"/movie/{tmdb_id}")
+
+ def get_digital_release_date(self, imdb_id: str) -> Optional[str]:
+ """Get digital release date for a movie"""
+ _log("INFO", f"๐ TMDB: Looking for digital release date for {imdb_id}")
+ movie = self.find_by_imdb(imdb_id)
+ if not movie:
+ _log("WARNING", f"โ TMDB: Movie not found for {imdb_id}")
+ return None
+
+ tmdb_id = movie.get("id")
+ _log("INFO", f"โ
TMDB: Found movie ID {tmdb_id} for {imdb_id}")
+ if not tmdb_id:
+ return None
+
+ release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
+ if not release_dates:
+ _log("WARNING", f"โ TMDB: No release dates data for movie {tmdb_id}")
+ return None
+
+ _log("INFO", f"๐ TMDB: Got release dates data, looking for {self.primary_country} digital releases")
+
+ # Debug: Show all available countries
+ countries = [entry.get("iso_3166_1") for entry in release_dates.get("results", [])]
+ _log("INFO", f"๐ TMDB: Available countries: {countries}")
+
+ for entry in release_dates.get("results", []):
+ country = entry.get("iso_3166_1", "").upper()
+ if country != self.primary_country:
+ continue
+
+ _log("INFO", f"๐ฏ TMDB: Found {country} release data")
+ releases = entry.get("release_dates", [])
+ _log("INFO", f"๐ฌ TMDB: Release types available: {[r.get('type') for r in releases]}")
+
+ # Collect all available releases with their types and dates
+ available_releases = []
+ for release in releases:
+ release_type = release.get("type")
+ release_date = release.get("release_date")
+ _log("INFO", f"๐
TMDB: Type {release_type}, Date: {release_date}")
+
+ if release_date:
+ parsed_date = _parse_date_to_iso(release_date)
+ if parsed_date:
+ available_releases.append((release_type, parsed_date))
+
+ # Apply TMDB release type priority order
+ tmdb_priority = self._get_tmdb_type_priority()
+ for preferred_type in tmdb_priority:
+ for release_type, parsed_date in available_releases:
+ if release_type == preferred_type:
+ release_type_names = {
+ 1: "Premiere", 2: "Limited Theatrical", 3: "Theatrical",
+ 4: "Digital", 5: "Physical", 6: "TV Premiere"
+ }
+ type_name = release_type_names.get(release_type, f"Type {release_type}")
+ _log("INFO", f"โ
TMDB: Selected {type_name} release date: {parsed_date} (priority: {tmdb_priority})")
+ return parsed_date
+
+ _log("WARNING", f"โ TMDB: No release dates found for {imdb_id} in {self.primary_country}")
+ return None
+
+ def _get_tmdb_type_priority(self) -> List[int]:
+ """Get TMDB release type priority order from environment"""
+ # Default priority: Digital first, then Physical, then Theatrical, then others
+ default_priority = "4,5,3,2,6,1" # digital,physical,theatrical,limited,tv,premiere
+
+ priority_str = os.environ.get("TMDB_TYPE_PRIORITY", default_priority)
+ try:
+ # Parse comma-separated numbers
+ priority_list = [int(x.strip()) for x in priority_str.split(",") if x.strip().isdigit()]
+ if priority_list:
+ _log("DEBUG", f"TMDB type priority: {priority_list}")
+ return priority_list
+ else:
+ _log("WARNING", f"Invalid TMDB_TYPE_PRIORITY '{priority_str}', using default")
+ return [4, 5, 3, 2, 6, 1]
+ except Exception as e:
+ _log("WARNING", f"Error parsing TMDB_TYPE_PRIORITY: {e}, using default")
+ return [4, 5, 3, 2, 6, 1]
+
+ def get_theatrical_release_date(self, imdb_id: str) -> Optional[str]:
+ """Get theatrical release date for a movie"""
+ movie = self.find_by_imdb(imdb_id)
+ if not movie:
+ return None
+
+ tmdb_id = movie.get("id")
+ if not tmdb_id:
+ return None
+
+ release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
+ if not release_dates:
+ return None
+
+ for entry in release_dates.get("results", []):
+ if entry.get("iso_3166_1", "").upper() != self.primary_country:
+ continue
+
+ for release in entry.get("release_dates", []):
+ if release.get("type") == 3 and release.get("release_date"): # Theatrical release
+ return _parse_date_to_iso(release["release_date"])
+
+ return None
+
+ def get_physical_release_date(self, imdb_id: str) -> Optional[str]:
+ """Get physical release date (DVD/Blu-ray) for a movie"""
+ movie = self.find_by_imdb(imdb_id)
+ if not movie:
+ return None
+
+ tmdb_id = movie.get("id")
+ if not tmdb_id:
+ return None
+
+ release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
+ if not release_dates:
+ return None
+
+ for entry in release_dates.get("results", []):
+ if entry.get("iso_3166_1", "").upper() != self.primary_country:
+ continue
+
+ for release in entry.get("release_dates", []):
+ if release.get("type") == 5 and release.get("release_date"): # Physical release
+ return _parse_date_to_iso(release["release_date"])
+
+ return None
+
+ def get_tv_season_episodes(self, tv_id: int, season_number: int) -> Dict[int, str]:
+ """Get episode air dates for a TV season"""
+ result = self._get(f"/tv/{tv_id}/season/{season_number}")
+ episodes = {}
+
+ if result:
+ for episode in result.get("episodes", []):
+ ep_num = episode.get("episode_number")
+ air_date = episode.get("air_date")
+ if isinstance(ep_num, int) and air_date:
+ episodes[ep_num] = air_date
+
+ return episodes
+
+
+class OMDbClient:
+ """Open Movie Database API client"""
+
+ def __init__(self, api_key: str = None):
+ self.api_key = api_key or os.environ.get("OMDB_API_KEY", "")
+ self.enabled = bool(self.api_key)
+
+ def get_movie_details(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """Get movie details from OMDb"""
+ if not self.enabled:
+ return None
+
+ params = {"i": imdb_id, "apikey": self.api_key}
+ url = f"http://www.omdbapi.com/?{urlencode(params)}"
+ result = _get_json(url, timeout=15)
+
+ if result and result.get("Response") == "True":
+ return result
+ return None
+
+ def get_dvd_release_date(self, imdb_id: str) -> Optional[str]:
+ """Get DVD/digital release date"""
+ details = self.get_movie_details(imdb_id)
+ if not details:
+ return None
+
+ dvd_date = details.get("DVD") or details.get("Released")
+ if not dvd_date or dvd_date == "N/A":
+ return None
+
+ # Try to parse various date formats
+ for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"):
+ try:
+ dt = datetime.strptime(dvd_date, fmt).replace(tzinfo=timezone.utc)
+ return dt.isoformat(timespec="seconds")
+ except Exception:
+ continue
+
+ return None
+
+ def get_tv_season_episodes(self, imdb_id: str, season_number: int) -> Dict[int, str]:
+ """Get episode release dates for a TV season"""
+ if not self.enabled:
+ return {}
+
+ params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key}
+ url = f"http://www.omdbapi.com/?{urlencode(params)}"
+ result = _get_json(url, timeout=15)
+
+ episodes = {}
+ if result and result.get("Response") == "True":
+ for episode in result.get("Episodes", []):
+ try:
+ ep_num = int(episode.get("Episode", 0))
+ released = episode.get("Released")
+ if ep_num and released and released != "N/A":
+ episodes[ep_num] = released
+ except Exception:
+ continue
+
+ return episodes
+
+
+class JellyseerrClient:
+ """Jellyseerr API client"""
+
+ def __init__(self, base_url: str = None, api_key: str = None):
+ self.base_url = (base_url or os.environ.get("JELLYSEERR_URL", "")).rstrip("/")
+ self.api_key = api_key or os.environ.get("JELLYSEERR_API_KEY", "")
+ self.enabled = bool(self.base_url and self.api_key)
+
+ def _get(self, path: str) -> Optional[Dict[str, Any]]:
+ """Make GET request to Jellyseerr API"""
+ if not self.enabled:
+ return None
+
+ url = f"{self.base_url}/api/v1{path}"
+ headers = {"X-Api-Key": self.api_key, "Accept": "application/json"}
+ return _get_json(url, timeout=20, headers=headers)
+
+ def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
+ """Get movie details from Jellyseerr"""
+ return self._get(f"/movie/{tmdb_id}")
+
+ def get_digital_release_dates(self, tmdb_id: int) -> List[str]:
+ """Get digital release date candidates from Jellyseerr"""
+ details = self.get_movie_details(tmdb_id)
+ if not details:
+ return []
+
+ candidates = []
+
+ # Check direct fields
+ for field in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"):
+ value = details.get(field)
+ if value:
+ iso_date = _parse_date_to_iso(value)
+ if iso_date:
+ candidates.append(iso_date)
+
+ # Check release dates array
+ for array_field in ("releaseDates", "releases", "dates"):
+ release_array = details.get(array_field)
+ if not isinstance(release_array, list):
+ continue
+
+ for release in release_array:
+ if not isinstance(release, dict):
+ continue
+
+ release_type = (release.get("type") or release.get("label") or "").lower()
+ release_date = release.get("date") or release.get("releaseDate")
+
+ if release_date and ("digital" in release_type or "vod" in release_type or "stream" in release_type):
+ iso_date = _parse_date_to_iso(release_date)
+ if iso_date:
+ candidates.append(iso_date)
+
+ return candidates
+
+
+class ExternalClientManager:
+ """Manager for all external API clients"""
+
+ def __init__(self):
+ # Get country from environment, default to US
+ tmdb_country = os.environ.get("TMDB_COUNTRY", "US")
+ _log("INFO", f"๐ TMDB: Initializing with country: {tmdb_country}")
+
+ self.tmdb = TMDBClient(primary_country=tmdb_country)
+ self.omdb = OMDbClient()
+ self.jellyseerr = JellyseerrClient()
+ self.tvdb = TVDBClient()
+
+ 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 = {}
+
+ if self.tmdb.enabled:
+ # Digital release
+ digital_date = self.tmdb.get_digital_release_date(imdb_id)
+ if digital_date:
+ release_options["digital"] = (digital_date, "tmdb:digital")
+
+ # Physical release
+ physical_date = self.tmdb.get_physical_release_date(imdb_id)
+ if physical_date:
+ release_options["physical"] = (physical_date, "tmdb:physical")
+
+ # Theatrical release
+ theatrical_date = self.tmdb.get_theatrical_release_date(imdb_id)
+ if theatrical_date:
+ release_options["theatrical"] = (theatrical_date, "tmdb:theatrical")
+
+ # Add OMDb options
+ if self.omdb.enabled:
+ omdb_date = self.omdb.get_dvd_release_date(imdb_id)
+ if omdb_date and "physical" not in release_options:
+ release_options["physical"] = (omdb_date, "omdb:dvd")
+
+ # Add Jellyseerr digital releases
+ if self.jellyseerr.enabled and self.tmdb.enabled and "digital" not in release_options:
+ tmdb_movie = self.tmdb.find_by_imdb(imdb_id)
+ if tmdb_movie:
+ tmdb_id = tmdb_movie.get("id")
+ if tmdb_id:
+ jellyseerr_dates = self.jellyseerr.get_digital_release_dates(tmdb_id)
+ if jellyseerr_dates:
+ earliest_jellyseerr = min(jellyseerr_dates)
+ release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital")
+
+ # 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 = []
+
+ # Try the new priority system with digital-first fallback
+ result = self.get_release_date_by_priority(imdb_id, ["digital", "physical", "theatrical"])
+ if result:
+ candidates.append(result)
+
+ return candidates
+
+ def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]:
+ """Get the earliest digital release date (legacy method)"""
+ candidates = self.get_digital_release_candidates(imdb_id)
+ return candidates[0] if candidates else None
+
+ def get_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
+ """Get TVDB series ID from IMDB ID"""
+ # Check if TVDB lookups are disabled
+ if os.environ.get("DISABLE_TVDB", "false").lower() in ["true", "1", "yes"]:
+ _log("DEBUG", "TVDB lookups disabled via DISABLE_TVDB environment variable")
+ return None
+
+ if not self.tvdb.api_key:
+ _log("INFO", "TVDB API key not configured, skipping TVDB ID lookup (set TVDB_API_KEY to enable)")
+ return None
+
+ return self.tvdb.imdb_to_tvdb_series_id(imdb_id)
+
+
+if __name__ == "__main__":
+ # Test the clients
+ manager = ExternalClientManager()
+
+ test_imdb = "tt1596343" # Example IMDb ID
+ digital_candidates = manager.get_digital_release_candidates(test_imdb)
+ print(f"Digital release candidates for {test_imdb}: {digital_candidates}")
+
+ earliest = manager.get_earliest_digital_release(test_imdb)
+ if earliest:
+ print(f"Earliest digital release: {earliest[0]} ({earliest[1]})")
+ else:
+ print("No digital release dates found")
\ No newline at end of file
diff --git a/clients/radarr_client.py b/clients/radarr_client.py
new file mode 100644
index 0000000..71becaf
--- /dev/null
+++ b/clients/radarr_client.py
@@ -0,0 +1,534 @@
+#!/usr/bin/env python3
+"""Enhanced Radarr API client with improved import date detection"""
+
+import json
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Dict, Any, List, Optional, Tuple
+from urllib.parse import urlencode, urljoin
+from urllib.request import Request as UrlRequest, urlopen
+from urllib.error import URLError, HTTPError
+
+from core.logging import _log
+
+# Import path mapper for proper path handling
+try:
+ from core.path_mapper import path_mapper
+except ImportError:
+ # Fallback for standalone testing
+ class DummyPathMapper:
+ def analyze_import_source_path(self, path):
+ return "/downloads/" in path.lower(), "basic_check"
+
+ def is_download_path(self, path):
+ return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower()
+
+ path_mapper = DummyPathMapper()
+
+# Import database client for enhanced performance
+try:
+ from clients.radarr_db_client import RadarrDbClient
+except ImportError:
+ RadarrDbClient = None
+
+
+class RadarrClient:
+ """Enhanced Radarr API client with improved import date detection"""
+
+ # Radarr History API event types (HistoryEventType enum)
+ # From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs
+ EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer
+ EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library
+ EVENT_TYPE_FAILED = 4 # Download or import failed
+ EVENT_TYPE_RETAGGED = 6 # Files were tagged
+ EVENT_TYPE_RENAMED = 8 # Files were renamed
+
+ # Event types that indicate real imports
+ REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events
+
+ # These are now handled by path_mapper, but keeping for backward compatibility
+ DOWNLOAD_PATH_INDICATORS = [
+ '/downloads/', '/download/', '/completed/', '/importing/',
+ '/nzbs/', '/torrents/', '/temp/', '/tmp/',
+ 'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
+ 'usenet', 'torrent', 'radarr', 'completed', 'processing'
+ ]
+
+ 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)
+
+ # Initialize database client - REQUIRED for operation
+ self.db_client = None
+ if RadarrDbClient:
+ try:
+ self.db_client = RadarrDbClient.from_env()
+ if self.db_client:
+ _log("INFO", "โ
DATABASE ONLY MODE: Direct database access enabled")
+ else:
+ _log("ERROR", "โ DATABASE ONLY MODE: Database configuration required - API mode disabled")
+ except Exception as e:
+ _log("ERROR", f"โ DATABASE ONLY MODE: Failed to initialize database client: {e}")
+ self.db_client = None
+ else:
+ _log("ERROR", "โ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies")
+
+ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
+ """Make GET request to Radarr API with retries"""
+ 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(f"{self.base_url}/", path.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)
+ 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)) # Exponential backoff
+ attempt += 1
+
+ _log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}")
+ return None
+
+ def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """Find movie by IMDb ID - DATABASE ONLY mode"""
+ imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
+ _log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
+
+ # Database required - no API fallback
+ if self.db_client:
+ try:
+ movie = self.db_client.get_movie_by_imdb(imdb_id)
+ if movie:
+ _log("INFO", f"โ
Found via database: {movie.get('title')} (ID: {movie.get('id')})")
+ return movie
+ else:
+ _log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}")
+ return None
+ except Exception as e:
+ _log("ERROR", f"Database lookup failed: {e}")
+ return None
+
+ # No database client available
+ _log("ERROR", "Database client required for movie lookup - API mode disabled")
+ return None
+
+ def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]:
+ """
+ Analyze a history event to determine if it's a real import.
+
+ Args:
+ event: The history event to analyze
+ movie_info: Optional movie information to validate paths against
+
+ Returns:
+ (is_real_import, reason, date_iso)
+ """
+ event_type = event.get("eventType")
+ date_str = event.get("date")
+ event_data = event.get("data", {})
+
+ # Parse date
+ date_iso = None
+ if date_str:
+ try:
+ date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
+ except Exception:
+ date_iso = None
+
+ if not date_iso:
+ return False, "no_valid_date", None
+
+ # Convert event type to int if needed
+ try:
+ event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type
+ except (ValueError, TypeError):
+ event_type_int = None
+
+ # Check if event type indicates import
+ if event_type_int not in self.REAL_IMPORT_EVENT_TYPES:
+ return False, f"event_type_not_import({event_type})", date_iso
+
+ # Get all possible source paths/titles
+ source_items = []
+
+ # Get both sourcePath and importedPath if available
+ if event_data:
+ for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']:
+ if event_data.get(key):
+ source_items.append(event_data[key])
+
+ # Also check event root for these fields
+ for key in ['sourcePath', 'sourceTitle', 'importedPath']:
+ if event.get(key):
+ source_items.append(event[key])
+
+ # Clean up and make unique
+ source_items = [str(s).lower().strip() for s in source_items if s]
+ source_items = list(set(source_items)) # Remove duplicates
+
+ if not source_items:
+ return False, "no_source_paths", date_iso
+
+ # If we have movie info, look for title/year match
+ if movie_info:
+ movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.')
+ movie_year = str(movie_info.get('year', ''))
+
+ for source in source_items:
+ # Clean up source text for comparison
+ source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.')
+
+ # Check if both title and year are in the source
+ if movie_title and movie_year:
+ if movie_title in source_clean and movie_year in source_clean:
+ _log("DEBUG", f"โ
Match found - Title: {movie_title}, Year: {movie_year}")
+ return True, "matched_title_and_year", date_iso
+
+ # Also check for downloads path as secondary validation
+ if path_mapper.is_download_path(source):
+ _log("DEBUG", f"Source is from downloads: {source}")
+ return True, "from_downloads_path", date_iso
+
+ _log("DEBUG", f"โ ๏ธ No match found in sources: {source_items}")
+ return False, "no_title_year_match", date_iso
+
+ # Fallback to basic path validation if no movie info
+ for source in source_items:
+ if path_mapper.is_download_path(source):
+ return True, "basic_download_path_match", date_iso
+
+ return False, "no_download_path_match", date_iso
+
+ def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]:
+ """
+ Find earliest real import event with optimized querying.
+ Stops as soon as we find valid import events instead of loading everything.
+ """
+ _log("INFO", f"Finding earliest import for movie_id {movie_id}")
+
+ # Get movie info for path validation
+ movie_info = self._get(f"/api/v3/movie/{movie_id}")
+ if not movie_info or not isinstance(movie_info, dict):
+ _log("ERROR", f"Could not get movie info for ID {movie_id}")
+ return None
+
+ earliest_real_import = None
+ first_grab = None
+ page = 1
+ page_size = 50 # Smaller pages for faster iteration
+ total_processed = 0
+
+ while page <= 20: # Safety limit
+ # Get history in chronological order
+ data = self._get("/api/v3/history", {
+ "movieId": str(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", [])
+ if not items:
+ break
+
+ _log("DEBUG", f"Page {page}: Processing {len(items)} events")
+
+ for event in items:
+ total_processed += 1
+ event_type = event.get("eventType")
+ if not event_type:
+ continue
+
+ # Convert event type to int or handle string types
+ try:
+ if isinstance(event_type, str):
+ # Map string event types to numeric values
+ string_to_numeric = {
+ "grabbed": self.EVENT_TYPE_GRABBED,
+ "downloadFolderImported": self.EVENT_TYPE_IMPORTED,
+ "movieFileImported": self.EVENT_TYPE_IMPORTED,
+ "downloadFailed": self.EVENT_TYPE_FAILED,
+ "movieFileRenamed": self.EVENT_TYPE_RENAMED,
+ "movieFileDeleted": 5 # Not in our constants but common
+ }
+ event_type = string_to_numeric.get(event_type, 0)
+ else:
+ event_type = int(event_type)
+ except (ValueError, TypeError):
+ _log("DEBUG", f"Unknown event type: {event_type}")
+ continue
+
+ # Check for grab events (type 1) - but validate it's a real download
+ if event_type == self.EVENT_TYPE_GRABBED and not first_grab:
+ if event.get("date"):
+ try:
+ # Get event data to check if this is a real grab with download info
+ event_data = event.get("data", {})
+ if isinstance(event_data, str):
+ try:
+ event_data = json.loads(event_data)
+ except (json.JSONDecodeError, AttributeError):
+ event_data = {}
+
+ # Check if this grab has actual download/indexer info
+ source_title = event_data.get("sourceTitle", "")
+ indexer = event_data.get("indexer", "")
+
+ # Only count grabs that have actual download metadata
+ if source_title or indexer:
+ first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
+ _log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}")
+ else:
+ _log("DEBUG", f"Skipping grab event without download info at {event.get('date')}")
+ except Exception:
+ pass
+
+ # Only process import events (type 3)
+ if event_type != self.EVENT_TYPE_IMPORTED:
+ continue
+
+ # Get imported path from event data
+ imported_path = None
+ event_data = event.get("data", {})
+
+ # Handle both string and dict data
+ if isinstance(event_data, str):
+ try:
+ event_data = json.loads(event_data)
+ except (json.JSONDecodeError, AttributeError) as e:
+ _log("DEBUG", f"Failed to parse event data JSON: {e}")
+ continue
+ elif not isinstance(event_data, dict):
+ continue
+
+ imported_path = event_data.get("importedPath", "")
+ if not imported_path:
+ continue
+
+ imported_path = imported_path.lower()
+
+ movie_imdb = (movie_info.get("imdbId", "") or "").lower()
+ movie_title = (movie_info.get("title", "") or "").lower()
+ movie_year = str(movie_info.get("year", ""))
+
+ # First try IMDb ID match
+ # First try IMDb ID match
+ if movie_imdb and (
+ f"[imdb-{movie_imdb}]" in imported_path or
+ f"[{movie_imdb}]" in imported_path or
+ movie_imdb in imported_path
+ ):
+ _log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}")
+ date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
+ _log("INFO", f"โ
FOUND IMPORT: exact IMDb match at {date_iso}")
+ earliest_real_import = date_iso
+ break
+
+ # Then try title/year match with fuzzy path cleaning
+ if movie_title and movie_year:
+ # Clean strings for comparison
+ clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower()
+ clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower()
+
+ # Look for both title and year in the path
+ if clean_title in clean_path and movie_year in clean_path:
+ date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
+ _log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})")
+ _log("INFO", f"โ
FOUND IMPORT at {date_iso}")
+ earliest_real_import = date_iso
+ break
+
+ # Fallback to normal import analysis
+ is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info)
+ if is_real and date_iso:
+ source_path = (event.get("data", {}).get("sourcePath", "") or
+ event.get("data", {}).get("droppedPath", "") or
+ event.get("sourcePath", "") or
+ event.get("data", {}).get("importedPath", "") or
+ event.get("importedPath", "") or "").lower()
+
+ if source_path:
+ # Check for path match
+ movie_imdb = (movie_info.get("imdbId", "") or "").lower()
+ if movie_imdb and (
+ f"[imdb-{movie_imdb}]" in source_path or
+ f"[{movie_imdb}]" in source_path or
+ movie_imdb in source_path
+ ):
+ _log("INFO", f"โ
FOUND IMPORT: IMDb match in path at {date_iso}")
+ earliest_real_import = date_iso
+ break
+
+ # Check for title/year match
+ movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".")
+ movie_year = str(movie_info.get("year", ""))
+ if movie_title and movie_year and movie_title in source_path and movie_year in source_path:
+ _log("INFO", f"โ
FOUND IMPORT: Title/year match at {date_iso}")
+ earliest_real_import = date_iso
+ break
+ elif event_type == 3:
+ _log("DEBUG", f"โ ๏ธ Skipped import event: {reason}")
+
+ # If we found a real import, no need to continue
+ if earliest_real_import:
+ break
+
+ # If we got less than page size, we've seen all events
+ if len(items) < page_size:
+ break
+
+ page += 1
+
+ _log("INFO", f"Processed {total_processed} events across {page-1} pages")
+
+ if earliest_real_import:
+ _log("INFO", f"โ
Using earliest real import: {earliest_real_import}")
+ return earliest_real_import
+
+ if first_grab:
+ _log("WARNING", f"โ ๏ธ No real imports found, using grab date: {first_grab}")
+ return first_grab
+
+ _log("ERROR", f"โ No import or grab events found for movie_id {movie_id}")
+ return None
+
+ def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
+ """Get movie files for a movie - DATABASE ONLY mode"""
+ if self.db_client:
+ _log("INFO", "Using database for movie files lookup")
+ # Database handles this internally in get_movie_file_date()
+ return []
+
+ _log("ERROR", "Database client required for movie files - API mode disabled")
+ return []
+
+ def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
+ """Get earliest file dateAdded - DATABASE ONLY mode"""
+ if self.db_client:
+ try:
+ return self.db_client.get_movie_file_date(movie_id)
+ except Exception as e:
+ _log("ERROR", f"Database file date query failed: {e}")
+ return None
+
+ _log("ERROR", "Database client required for file dates - API mode disabled")
+ return None
+
+ def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
+ """
+ Get the best import date for a movie - DATABASE ONLY mode.
+
+ Returns:
+ (date_iso, source_description)
+ """
+ # Database required - no API fallback
+ if self.db_client:
+ try:
+ date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date)
+ if date_iso:
+ return date_iso, source
+ else:
+ _log("WARNING", f"No import date found in database for movie_id {movie_id}")
+ return None, "radarr:db.no_date_found"
+ except Exception as e:
+ _log("ERROR", f"Database import date query failed: {e}")
+ return None, "radarr:db.error"
+
+ # No database client available
+ _log("ERROR", "Database client required for import date detection - API mode disabled")
+ return None, "radarr:db.not_configured"
+
+ def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]:
+ """Get the earliest import date from Radarr history."""
+ _log("INFO", f"Finding earliest import for movie_id {movie_id}")
+
+ earliest_real_import = None
+ earliest_grab_date = None
+ page = 1
+ page_size = 50
+ total_events = 0
+
+ # Get full movie history
+ while True:
+ # Get page of history
+ history_data = self._get_movie_history_page(movie_id, page, page_size)
+ if not history_data:
+ break
+
+ # Process events on this page
+ for event in history_data:
+ event_type = event.get("eventType")
+ if not event_type:
+ continue
+
+ # Parse event date
+ event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
+
+ # Track earliest grab date as fallback (EventType 1)
+ if event_type == 1 and not earliest_grab_date:
+ earliest_grab_date = event_date
+ _log("DEBUG", f"Found first grab event at {earliest_grab_date}")
+ continue
+
+ # Look for import events (EventType 3)
+ if event_type == 3:
+ try:
+ data = json.loads(event.get("data", "{}"))
+ if data.get("importedPath"):
+ _log("INFO", f"โ
FOUND IMPORT at {event_date}")
+ earliest_real_import = event_date
+ break
+ except (json.JSONDecodeError, AttributeError):
+ continue
+
+ # Break if we found an import
+ if earliest_real_import:
+ break
+
+ total_events += len(history_data)
+ page += 1
+
+ _log("INFO", f"Processed {total_events} events across {page}")
+
+ if earliest_real_import:
+ return earliest_real_import
+ if earliest_grab_date:
+ _log("WARNING", f"โ ๏ธ No EventType 3 (import) found, using grab date: {earliest_grab_date}")
+ return earliest_grab_date
+ return None
+
+ def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]:
+ """Get a page of movie history."""
+ data = self._get("/api/v3/history", {
+ "movieId": str(movie_id),
+ "page": page,
+ "pageSize": page_size,
+ "sortKey": "date",
+ "sortDirection": "ascending"
+ })
+ return data if isinstance(data, list) else data.get("records", [])
\ No newline at end of file
diff --git a/clients/radarr_db_client.py b/clients/radarr_db_client.py
new file mode 100644
index 0000000..bf32419
--- /dev/null
+++ b/clients/radarr_db_client.py
@@ -0,0 +1,646 @@
+#!/usr/bin/env python3
+"""
+Direct Radarr Database Client for NFOGuard
+Provides high-performance access to Radarr's SQLite/PostgreSQL database
+"""
+
+import os
+import sqlite3
+import psycopg2
+import psycopg2.extras
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Dict, Any, List, Optional, Tuple, Union
+from urllib.parse import urlparse
+
+from core.logging import _log
+
+
+class RadarrDbClient:
+ """Direct database client for Radarr's SQLite or PostgreSQL database"""
+
+ def __init__(self,
+ db_type: str = "sqlite",
+ db_path: Optional[str] = None,
+ db_host: Optional[str] = None,
+ db_port: Optional[int] = None,
+ db_name: Optional[str] = None,
+ db_user: Optional[str] = None,
+ db_password: Optional[str] = None):
+ """
+ Initialize Radarr database client
+
+ Args:
+ db_type: "sqlite" or "postgresql"
+ db_path: Path to SQLite database file
+ db_host: PostgreSQL host
+ db_port: PostgreSQL port
+ db_name: PostgreSQL database name
+ db_user: PostgreSQL username
+ db_password: PostgreSQL password
+ """
+ self.db_type = db_type.lower()
+ self.db_path = db_path
+ self.db_host = db_host
+ self.db_port = db_port or 5432
+ self.db_name = db_name
+ self.db_user = db_user
+ self.db_password = db_password
+
+ self._test_connection()
+
+ @classmethod
+ def from_env(cls) -> Optional['RadarrDbClient']:
+ """Create client from environment variables"""
+ db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
+
+ if not db_type:
+ return None
+
+ if db_type == "sqlite":
+ db_path = os.environ.get("RADARR_DB_PATH")
+ if not db_path or not Path(db_path).exists():
+ _log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}")
+ return None
+ return cls(db_type="sqlite", db_path=db_path)
+
+ elif db_type == "postgresql":
+ # Support both individual vars and connection string
+ db_url = os.environ.get("RADARR_DB_URL")
+ if db_url:
+ parsed = urlparse(db_url)
+ return cls(
+ db_type="postgresql",
+ db_host=parsed.hostname,
+ db_port=parsed.port or 5432,
+ db_name=parsed.path.lstrip('/'),
+ db_user=parsed.username,
+ db_password=parsed.password
+ )
+ else:
+ return cls(
+ db_type="postgresql",
+ db_host=os.environ.get("RADARR_DB_HOST"),
+ db_port=int(os.environ.get("RADARR_DB_PORT", "5432")),
+ db_name=os.environ.get("RADARR_DB_NAME"),
+ db_user=os.environ.get("RADARR_DB_USER"),
+ db_password=os.environ.get("RADARR_DB_PASSWORD")
+ )
+ else:
+ _log("ERROR", f"Unsupported database type: {db_type}")
+ return None
+
+ def _test_connection(self) -> None:
+ """Test database connection on initialization"""
+ try:
+ conn = self._get_connection()
+ if conn:
+ conn.close()
+ _log("INFO", f"Connected to Radarr {self.db_type} database successfully")
+ else:
+ raise Exception("Failed to create connection")
+ except Exception as e:
+ _log("ERROR", f"Failed to connect to Radarr database: {e}")
+ raise
+
+ def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
+ """Get database connection"""
+ if self.db_type == "sqlite":
+ conn = sqlite3.connect(self.db_path)
+ conn.row_factory = sqlite3.Row
+ return conn
+ elif self.db_type == "postgresql":
+ conn = psycopg2.connect(
+ host=self.db_host,
+ port=self.db_port,
+ database=self.db_name,
+ user=self.db_user,
+ password=self.db_password
+ )
+ return conn
+ else:
+ raise ValueError(f"Unsupported database type: {self.db_type}")
+
+ def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """
+ Find movie by IMDb ID using database query
+
+ Returns:
+ Dictionary with movie info including id, imdbId, title, year, path
+ """
+ imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
+
+ query = """
+ SELECT
+ m."Id" as id,
+ m."Path" as path,
+ m."Added" as added,
+ mm."ImdbId" as imdb_id,
+ mm."Title" as title,
+ mm."Year" as year,
+ mm."DigitalRelease" as digital_release
+ FROM "Movies" m
+ JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
+ WHERE mm."ImdbId" = %s
+ """
+
+ if self.db_type == "sqlite":
+ query = query.replace("%s", "?")
+
+ try:
+ with self._get_connection() as conn:
+ if self.db_type == "postgresql":
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ else:
+ cursor = conn.cursor()
+
+ cursor.execute(query, (imdb_id,))
+ row = cursor.fetchone()
+
+ if row:
+ return dict(row) if self.db_type == "sqlite" else row
+
+ except Exception as e:
+ _log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
+
+ return None
+
+ def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
+ """
+ Get earliest import date from History table, accounting for upgrade scenarios
+
+ Args:
+ movie_id: Radarr movie ID
+
+ Returns:
+ (date_iso, source_description)
+ """
+ # If first event is rename, all subsequent imports are upgrades - skip them
+ if self.is_first_event_rename_based(movie_id):
+ _log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping")
+ return None, "radarr:db.upgrade_imports_skipped"
+
+ # Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import)
+ import_query = """
+ SELECT
+ h."Date" as event_date,
+ h."Data" as event_data,
+ h."EventType" as event_type
+ FROM "History" h
+ WHERE h."MovieId" = %s
+ AND h."EventType" = 3
+ ORDER BY h."Date" ASC
+ LIMIT 1
+ """
+
+ # Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab)
+ grab_query = """
+ SELECT
+ h."Date" as event_date,
+ h."Data" as event_data,
+ h."EventType" as event_type
+ FROM "History" h
+ WHERE h."MovieId" = %s
+ AND h."EventType" = 1
+ AND h."Data" IS NOT NULL
+ ORDER BY h."Date" ASC
+ LIMIT 1
+ """
+
+ if self.db_type == "sqlite":
+ import_query = import_query.replace("%s", "?")
+ grab_query = grab_query.replace("%s", "?")
+
+ try:
+ with self._get_connection() as conn:
+ if self.db_type == "postgresql":
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ else:
+ cursor = conn.cursor()
+
+ # Try import events first
+ cursor.execute(import_query, (movie_id,))
+ row = cursor.fetchone()
+
+ if row:
+ event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
+ event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
+ if isinstance(event_date, str):
+ dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
+ else:
+ dt = event_date.replace(tzinfo=timezone.utc)
+
+ date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
+ _log("INFO", f"โ
Found import event ({event_type}) for movie {movie_id} at {date_iso}")
+ return date_iso, "radarr:db.history.import"
+
+ # Fallback to grab events
+ cursor.execute(grab_query, (movie_id,))
+ row = cursor.fetchone()
+
+ if row:
+ event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
+ event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
+ if isinstance(event_date, str):
+ dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
+ else:
+ dt = event_date.replace(tzinfo=timezone.utc)
+
+ date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
+ _log("WARNING", f"โ ๏ธ Using grab event ({event_type}) for movie {movie_id} at {date_iso}")
+ return date_iso, "radarr:db.history.grab"
+
+ except Exception as e:
+ _log("ERROR", f"Database query error for movie {movie_id}: {e}")
+
+ return None, "radarr:db.no_date_found"
+
+ def is_first_event_rename_based(self, movie_id: int) -> bool:
+ """
+ Check if the first event in history is rename-based (not a true import)
+
+ This helps identify movies where:
+ - First event: movieFileRenamed (EventType = 8)
+ - Followed by: downloadFolderImported (EventType = 3) - this is an upgrade
+
+ In such cases, we should prefer release dates over the upgrade date
+ """
+ query = """
+ SELECT h."EventType" as event_type, h."Date" as event_date
+ FROM "History" h
+ WHERE h."MovieId" = %s
+ ORDER BY h."Date" ASC
+ LIMIT 5
+ """
+
+ if self.db_type == "sqlite":
+ query = query.replace("%s", "?")
+
+ try:
+ with self._get_connection() as conn:
+ if self.db_type == "postgresql":
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ else:
+ cursor = conn.cursor()
+
+ cursor.execute(query, (movie_id,))
+ rows = cursor.fetchall()
+
+ if rows:
+ _log("INFO", f"Movie {movie_id} history debug - first 5 events:")
+ for i, row in enumerate(rows):
+ event_type = row['event_type'] if self.db_type == "postgresql" else row[0]
+ event_date = row['event_date'] if self.db_type == "postgresql" else row[1]
+ _log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}")
+
+ first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0]
+ # EventType 8 = movieFileRenamed
+ # Also check for EventType 7 = movieFileRenamed in some Radarr versions
+ is_rename_first = first_event_type in [7, 8]
+ _log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}")
+
+ if is_rename_first:
+ _log("INFO", f"๐ฏ Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates")
+
+ return is_rename_first
+ else:
+ _log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data")
+
+ except Exception as e:
+ _log("ERROR", f"Error checking first event type for movie {movie_id}: {e}")
+
+ return False
+
+ def get_movie_file_date(self, movie_id: int) -> Optional[str]:
+ """
+ Get earliest file dateAdded as fallback
+
+ Args:
+ movie_id: Radarr movie ID
+
+ Returns:
+ ISO date string or None
+ """
+ query = """
+ SELECT MIN(mf."DateAdded") as earliest_date
+ FROM "MovieFiles" mf
+ WHERE mf."MovieId" = %s
+ """
+
+ if self.db_type == "sqlite":
+ query = query.replace("%s", "?")
+
+ try:
+ with self._get_connection() as conn:
+ if self.db_type == "postgresql":
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ else:
+ cursor = conn.cursor()
+
+ cursor.execute(query, (movie_id,))
+ row = cursor.fetchone()
+
+ if row:
+ date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0]
+ if date_value:
+ if isinstance(date_value, str):
+ dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
+ else:
+ dt = date_value.replace(tzinfo=timezone.utc)
+
+ return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
+
+ except Exception as e:
+ _log("ERROR", f"Database query error for movie file date {movie_id}: {e}")
+
+ return None
+
+ def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
+ """
+ Get the best import date for a movie using optimized database queries
+
+ Args:
+ movie_id: Radarr movie ID
+ fallback_to_file_date: Whether to fall back to file dateAdded
+
+ Returns:
+ (date_iso, source_description)
+ """
+ # Try history first - this handles upgrade detection internally
+ date_iso, source = self.get_earliest_import_date(movie_id)
+ if date_iso:
+ return date_iso, source
+
+ # Check if we skipped upgrades and should prefer release dates
+ if source == "radarr:db.upgrade_imports_skipped":
+ _log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates")
+ return None, "radarr:db.prefer_release_dates"
+
+ # Fallback to file date if requested
+ if fallback_to_file_date:
+ file_date = self.get_movie_file_date(movie_id)
+ if file_date:
+ _log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
+ return file_date, "radarr:db.file.dateAdded"
+
+ return None, "radarr:db.no_date_found"
+
+ def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]:
+ """
+ Get import dates for multiple movies in a single query
+
+ Args:
+ imdb_ids: List of IMDb IDs
+
+ Returns:
+ Dictionary mapping imdb_id -> (date_iso, source)
+ """
+ if not imdb_ids:
+ return {}
+
+ # Ensure all IMDb IDs have tt prefix
+ clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids]
+
+ placeholders = ",".join(["%s"] * len(clean_imdb_ids))
+ if self.db_type == "sqlite":
+ placeholders = ",".join(["?"] * len(clean_imdb_ids))
+
+ query = f"""
+ SELECT
+ mm."ImdbId" as imdb_id,
+ m."Id" as movie_id,
+ MIN(h."Date") as earliest_import
+ FROM "Movies" m
+ JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
+ LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3
+ WHERE mm."ImdbId" IN ({placeholders})
+ GROUP BY mm."ImdbId", m."Id"
+ """
+
+ results = {}
+
+ try:
+ with self._get_connection() as conn:
+ if self.db_type == "postgresql":
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ else:
+ cursor = conn.cursor()
+
+ cursor.execute(query, clean_imdb_ids)
+ rows = cursor.fetchall()
+
+ for row in rows:
+ if self.db_type == "postgresql":
+ imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import']
+ else:
+ imdb_id, movie_id, earliest_import = row[0], row[1], row[2]
+
+ if earliest_import:
+ if isinstance(earliest_import, str):
+ dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
+ else:
+ dt = earliest_import.replace(tzinfo=timezone.utc)
+
+ date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
+ results[imdb_id] = (date_iso, "radarr:db.bulk.import")
+ else:
+ results[imdb_id] = (None, "radarr:db.bulk.no_import")
+
+ except Exception as e:
+ _log("ERROR", f"Bulk query error: {e}")
+ # Return empty results for failed queries
+ for imdb_id in clean_imdb_ids:
+ if imdb_id not in results:
+ results[imdb_id] = (None, "radarr:db.bulk.error")
+
+ return results
+
+ def get_database_stats(self) -> Dict[str, Any]:
+ """Get basic statistics about the Radarr database"""
+ stats = {}
+
+ queries = {
+ "total_movies": 'SELECT COUNT(*) FROM "Movies"',
+ "total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"',
+ "total_history_events": 'SELECT COUNT(*) FROM "History"',
+ "import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
+ "grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
+ }
+
+ try:
+ with self._get_connection() as conn:
+ cursor = conn.cursor()
+
+ for stat_name, query in queries.items():
+ cursor.execute(query)
+ result = cursor.fetchone()
+ stats[stat_name] = result[0] if result else 0
+
+ except Exception as e:
+ _log("ERROR", f"Stats query error: {e}")
+ stats["error"] = str(e)
+
+ return stats
+
+ def health_check(self) -> Dict[str, Any]:
+ """
+ Comprehensive health check for the Radarr database connection
+
+ Returns:
+ Dictionary with health status, connection info, and basic functionality tests
+ """
+ health = {
+ "status": "healthy",
+ "database_type": self.db_type,
+ "connection": "ok",
+ "readable": False,
+ "writable": False,
+ "tables_exist": False,
+ "sample_data": False,
+ "issues": [],
+ "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
+ }
+
+ try:
+ # Test 1: Basic connection
+ with self._get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Test 2: Check if we can read (basic query)
+ try:
+ cursor.execute('SELECT 1')
+ result = cursor.fetchone()
+ if result and result[0] == 1:
+ health["readable"] = True
+ health["connection"] = "readable"
+ else:
+ health["issues"].append("Basic SELECT query failed")
+ except Exception as e:
+ health["issues"].append(f"Read test failed: {e}")
+ health["status"] = "degraded"
+
+ # Test 3: Check required tables exist
+ required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"]
+ existing_tables = []
+
+ try:
+ if self.db_type == "postgresql":
+ cursor.execute("""
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
+ """)
+ else: # SQLite
+ cursor.execute("""
+ SELECT name
+ FROM sqlite_master
+ WHERE type='table'
+ AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
+ """)
+
+ rows = cursor.fetchall()
+ existing_tables = [row[0] for row in rows]
+
+ if len(existing_tables) == len(required_tables):
+ health["tables_exist"] = True
+ else:
+ missing = set(required_tables) - set(existing_tables)
+ health["issues"].append(f"Missing tables: {list(missing)}")
+ health["status"] = "degraded"
+
+ health["existing_tables"] = existing_tables
+
+ except Exception as e:
+ health["issues"].append(f"Table check failed: {e}")
+ health["status"] = "degraded"
+
+ # Test 4: Check for sample data
+ if health["tables_exist"]:
+ try:
+ cursor.execute('SELECT COUNT(*) FROM "Movies"')
+ movie_count = cursor.fetchone()[0]
+
+ cursor.execute('SELECT COUNT(*) FROM "History"')
+ history_count = cursor.fetchone()[0]
+
+ if movie_count > 0 and history_count > 0:
+ health["sample_data"] = True
+ health["movie_count"] = movie_count
+ health["history_count"] = history_count
+ else:
+ health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}")
+
+ except Exception as e:
+ health["issues"].append(f"Sample data check failed: {e}")
+
+ # Test 5: Test a real query (movie with IMDb lookup)
+ if health["sample_data"]:
+ try:
+ cursor.execute("""
+ SELECT COUNT(*)
+ FROM "Movies" m
+ JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
+ WHERE mm."ImdbId" IS NOT NULL
+ """)
+ imdb_movies = cursor.fetchone()[0]
+ health["movies_with_imdb"] = imdb_movies
+
+ if imdb_movies > 0:
+ health["functional"] = True
+ else:
+ health["issues"].append("No movies with IMDb IDs found")
+
+ except Exception as e:
+ health["issues"].append(f"Functional test failed: {e}")
+ health["status"] = "degraded"
+
+ except Exception as e:
+ health["status"] = "error"
+ health["connection"] = "failed"
+ health["issues"].append(f"Connection failed: {e}")
+ _log("ERROR", f"Database health check failed: {e}")
+
+ # Overall status determination
+ if health["issues"]:
+ if health["status"] == "healthy":
+ health["status"] = "degraded"
+
+ # Add connection details (safe info only)
+ health["connection_info"] = {
+ "type": self.db_type,
+ "host": self.db_host if self.db_type == "postgresql" else None,
+ "port": self.db_port if self.db_type == "postgresql" else None,
+ "database": self.db_name if self.db_type == "postgresql" else None,
+ "path": self.db_path if self.db_type == "sqlite" else None
+ }
+
+ return health
+
+
+if __name__ == "__main__":
+ # Test the database client
+ print("Testing RadarrDbClient...")
+
+ # Test with environment variables
+ client = RadarrDbClient.from_env()
+ if client:
+ print("โ
Connected to Radarr database")
+
+ # Test stats
+ stats = client.get_database_stats()
+ print(f"Database stats: {stats}")
+
+ # Test movie lookup
+ test_movie = client.get_movie_by_imdb("tt1596343")
+ if test_movie:
+ print(f"Found test movie: {test_movie}")
+
+ # Test import date
+ movie_id = test_movie['id']
+ date_iso, source = client.get_movie_import_date_optimized(movie_id)
+ print(f"Import date: {date_iso} (source: {source})")
+ else:
+ print("Test movie not found")
+ else:
+ print("โ Could not connect to database - check environment variables")
\ No newline at end of file
diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py
new file mode 100644
index 0000000..48c754e
--- /dev/null
+++ b/clients/sonarr_client.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""
+Enhanced Sonarr API client for TV show metadata and episode management
+"""
+import json
+import time
+from datetime import datetime, timezone
+from typing import Dict, Any, List, Optional
+from urllib.parse import urlencode
+from urllib.request import Request as UrlRequest, urlopen
+from urllib.error import URLError, HTTPError
+
+from core.logging import _log
+
+
+class SonarrClient:
+ """Enhanced Sonarr API client for TV series and episode management"""
+
+ 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)
+ self.enabled = bool(self.base_url and self.api_key)
+
+ def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
+ """Make GET request to Sonarr API with retries"""
+ if not self.enabled:
+ return None
+
+ url = f"{self.base_url}/api/v3{path}"
+ if params:
+ url += "?" + urlencode(params)
+
+ headers = {"X-Api-Key": self.api_key}
+
+ for attempt in range(self.retries):
+ try:
+ _log("DEBUG", f"Sonarr API Request: {url}")
+ req = UrlRequest(url, headers=headers)
+
+ with urlopen(req, timeout=self.timeout) as resp:
+ data = resp.read().decode("utf-8")
+ result = json.loads(data) if data else None
+ return result
+
+ except HTTPError as e:
+ if e.code == 401:
+ _log("ERROR", "Sonarr authentication failed - check API key")
+ return None
+ elif e.code == 429:
+ wait_time = (attempt + 1) * 2
+ _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {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))
+
+ _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
+ return None
+
+ def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """Find series by IMDb ID using lookup endpoint"""
+ search_term = f"imdbid:{imdb_id}"
+ _log("DEBUG", f"Searching Sonarr with term: {search_term}")
+
+ result = self._get("/series/lookup", {"term": search_term})
+ if not result:
+ _log("WARNING", f"No results from Sonarr lookup for: {search_term}")
+ return None
+
+ _log("DEBUG", f"Sonarr lookup returned {len(result)} results")
+
+ # Log all results for debugging
+ for i, series in enumerate(result):
+ series_imdb = series.get("imdbId", "")
+ series_title = series.get("title", "")
+ series_id = series.get("id", "")
+ _log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
+
+ # Find exact IMDb match (case insensitive)
+ target_imdb = imdb_id.lower()
+ for series in result:
+ series_imdb = (series.get("imdbId") or "").lower()
+ if series_imdb == target_imdb:
+ _log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
+ return series
+
+ # Try partial match as fallback
+ for series in result:
+ series_imdb = (series.get("imdbId") or "").lower()
+ if target_imdb in series_imdb or series_imdb in target_imdb:
+ _log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
+ return series
+
+ _log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
+ return None
+
+ def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
+ """Search for series by title as fallback when IMDb lookup fails"""
+ _log("DEBUG", f"Searching Sonarr by title: {title}")
+
+ result = self._get("/series/lookup", {"term": title})
+ if not result:
+ _log("WARNING", f"No results from Sonarr title search for: {title}")
+ return None
+
+ _log("DEBUG", f"Sonarr title search returned {len(result)} results")
+
+ title_lower = title.lower()
+
+ # Look for exact title match
+ for series in result:
+ series_title = (series.get("title") or "").lower()
+ if series_title == title_lower:
+ _log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
+ return series
+
+ # Look for partial title match
+ for series in result:
+ series_title = (series.get("title") or "").lower()
+ if title_lower in series_title or series_title in title_lower:
+ _log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
+ return series
+
+ _log("WARNING", f"No title match found for: {title}")
+ return None
+
+ def get_all_series(self) -> List[Dict[str, Any]]:
+ """Get all series from Sonarr"""
+ return self._get("/series") or []
+
+ def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """Find series by scanning all series for IMDb match (slower but more reliable)"""
+ _log("DEBUG", f"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: int) -> List[Dict[str, Any]]:
+ """Get all episodes for a series"""
+ return self._get("/episode", {"seriesId": series_id}) or []
+
+ def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
+ """Get episode file details"""
+ return self._get(f"/episodefile/{episode_file_id}")
+
+ def get_episode_import_history(self, episode_id: int) -> Optional[str]:
+ """
+ Get the original import date from history with enhanced detection.
+ Focuses on finding the earliest REAL import, not upgrades.
+ """
+ all_records = []
+ page = 1
+ page_size = 100
+
+ # Collect all history records for this episode
+ while True:
+ history = self._get("/history", {
+ "episodeId": episode_id,
+ "sortKey": "date",
+ "sortDir": "asc",
+ "page": page,
+ "pageSize": page_size
+ })
+
+ if not history:
+ break
+
+ records = history.get("records", []) if isinstance(history, dict) else []
+ if not records:
+ break
+
+ all_records.extend(records)
+
+ if len(records) < page_size:
+ break
+
+ page += 1
+ if page > 10: # Safety valve
+ break
+
+ _log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
+
+ # Categorize events
+ import_events = []
+ grabbed_events = []
+ rename_events = []
+
+ for event in all_records:
+ event_type = event.get("eventType", "").lower()
+ date = event.get("date")
+
+ if not date:
+ continue
+
+ _log("DEBUG", f"History event: {event_type} at {date}")
+
+ if event_type == "downloadfolderimported":
+ import_events.append({"date": date, "event": event})
+ elif event_type == "grabbed":
+ grabbed_events.append({"date": date, "event": event})
+ elif event_type == "episodefilerenamed":
+ rename_events.append({"date": date, "event": event})
+
+ # Use the earliest real import event
+ if import_events:
+ earliest_import = min(import_events, key=lambda x: x["date"])
+ import_date = earliest_import["date"]
+ _log("INFO", f"Found import date: {import_date} for episode {episode_id}")
+
+ # Check if this looks like an upgrade by comparing to renames
+ if rename_events:
+ earliest_rename = min(rename_events, key=lambda x: x["date"])
+ rename_date = earliest_rename["date"]
+
+ 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
+
+ # If import is significantly after rename, prefer rename date
+ if days_diff > 30:
+ _log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
+ return rename_date
+
+ except Exception as e:
+ _log("DEBUG", f"Error comparing dates: {e}")
+
+ return import_date
+
+ # Fallback to grab event
+ if grabbed_events:
+ earliest_grab = min(grabbed_events, key=lambda x: x["date"])
+ _log("WARNING", f"No import events, using grab date: {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
+
+
+if __name__ == "__main__":
+ # Test the client
+ import os
+
+ base_url = os.environ.get("SONARR_URL", "")
+ api_key = os.environ.get("SONARR_API_KEY", "")
+
+ if base_url and api_key:
+ client = SonarrClient(base_url, api_key)
+
+ # Test with a known series
+ test_imdb = "tt2085059" # Example
+ series = client.series_by_imdb(test_imdb)
+
+ if series:
+ series_id = series.get("id")
+ print(f"Found series: {series.get('title')} (ID: {series_id})")
+
+ # Get episodes
+ episodes = client.episodes_for_series(series_id)
+ print(f"Found {len(episodes)} episodes")
+
+ # Test import date for first episode
+ if episodes:
+ first_episode = episodes[0]
+ episode_id = first_episode.get("id")
+ import_date = client.get_episode_import_history(episode_id)
+ print(f"First episode import date: {import_date}")
+ else:
+ print(f"Series not found: {test_imdb}")
+ else:
+ print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing")
\ No newline at end of file
diff --git a/core/database.py b/core/database.py
new file mode 100644
index 0000000..80503de
--- /dev/null
+++ b/core/database.py
@@ -0,0 +1,274 @@
+#!/usr/bin/env python3
+"""
+Database management for NFOGuard
+Handles SQLite database operations for tracking media dates and processing history
+"""
+import sqlite3
+import json
+import threading
+from pathlib import Path
+from datetime import datetime
+from typing import Optional, Dict, List, Any
+from contextlib import contextmanager
+
+class NFOGuardDatabase:
+ """Manages NFOGuard SQLite database operations"""
+
+ def __init__(self, db_path: Path):
+ self.db_path = Path(db_path)
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._local = threading.local()
+ self._init_database()
+
+ def _get_connection(self) -> sqlite3.Connection:
+ """Get thread-local database connection"""
+ if not hasattr(self._local, 'connection'):
+ self._local.connection = sqlite3.connect(
+ self.db_path,
+ check_same_thread=False,
+ timeout=30.0
+ )
+ self._local.connection.row_factory = sqlite3.Row
+ return self._local.connection
+
+ @contextmanager
+ def get_connection(self):
+ """Context manager for database connections"""
+ conn = self._get_connection()
+ try:
+ yield conn
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+
+ def _init_database(self):
+ """Initialize database tables with migration support"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Series table
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS series (
+ imdb_id TEXT PRIMARY KEY,
+ path TEXT NOT NULL,
+ last_updated TEXT NOT NULL,
+ metadata TEXT
+ )
+ """)
+
+ # Episodes table
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS episodes (
+ imdb_id TEXT NOT NULL,
+ season INTEGER NOT NULL,
+ episode INTEGER NOT NULL,
+ aired TEXT,
+ dateadded TEXT,
+ source TEXT,
+ last_updated TEXT NOT NULL,
+ PRIMARY KEY (imdb_id, season, episode),
+ FOREIGN KEY (imdb_id) REFERENCES series(imdb_id)
+ )
+ """)
+
+ # Movies table
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS movies (
+ imdb_id TEXT PRIMARY KEY,
+ path TEXT NOT NULL,
+ released TEXT,
+ dateadded TEXT,
+ source TEXT,
+ last_updated TEXT NOT NULL
+ )
+ """)
+
+ # Processing history table
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS processing_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ imdb_id TEXT NOT NULL,
+ media_type TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ processed_at TEXT NOT NULL,
+ details TEXT
+ )
+ """)
+
+ # Add missing columns if they don't exist (migration)
+ # Check current schema and add missing columns
+ cursor.execute("PRAGMA table_info(movies)")
+ movie_columns = [row[1] for row in cursor.fetchall()]
+
+ cursor.execute("PRAGMA table_info(episodes)")
+ episode_columns = [row[1] for row in cursor.fetchall()]
+
+ # Add missing columns to movies table
+ if 'path' not in movie_columns:
+ cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT")
+ cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL")
+
+ if 'has_video_file' not in movie_columns:
+ cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
+
+ if 'last_updated' not in movie_columns:
+ cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT")
+ cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL")
+
+ # Add missing columns to episodes table
+ if 'has_video_file' not in episode_columns:
+ cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
+
+ if 'last_updated' not in episode_columns:
+ cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT")
+ cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL")
+
+ # Create indexes
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
+
+ def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
+ """Insert or update series record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata)
+ VALUES (?, ?, ?, ?)
+ """, (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None))
+
+ def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
+ aired: Optional[str], dateadded: Optional[str],
+ source: str, has_video_file: bool = False):
+ """Insert or update episode date record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT OR REPLACE INTO episodes
+ (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """, (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
+
+ def upsert_movie(self, imdb_id: str, path: str):
+ """Insert or update movie record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ try:
+ cursor.execute("""
+ INSERT OR REPLACE INTO movies (imdb_id, path, last_updated)
+ VALUES (?, ?, ?)
+ """, (imdb_id, path, datetime.utcnow().isoformat()))
+ except sqlite3.OperationalError as e:
+ if "no column named path" in str(e):
+ # Fallback for databases without path column - just insert imdb_id
+ cursor.execute("""
+ INSERT OR REPLACE INTO movies (imdb_id, last_updated)
+ VALUES (?, ?)
+ """, (imdb_id, datetime.utcnow().isoformat()))
+ else:
+ raise
+
+ def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
+ dateadded: Optional[str], source: str, has_video_file: bool = False):
+ """Insert or update movie date record"""
+ print(f"๐ DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ # Use INSERT OR REPLACE to ensure we always update the dates properly
+ cursor.execute("""
+ INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
+ VALUES (
+ ?,
+ COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'),
+ ?, ?, ?, ?, ?
+ )
+ """, (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
+
+ # Debug: Check what was actually saved
+ cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,))
+ result = cursor.fetchone()
+ print(f"๐ DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}")
+
+ def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
+ """Get all episodes for a series"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ query = "SELECT * FROM episodes WHERE imdb_id = ?"
+ params = [imdb_id]
+
+ if has_video_file_only:
+ query += " AND has_video_file = TRUE"
+
+ cursor.execute(query, params)
+ return [dict(row) for row in cursor.fetchall()]
+
+ def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
+ """Get episode date record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT * FROM episodes
+ WHERE imdb_id = ? AND season = ? AND episode = ?
+ """, (imdb_id, season, episode))
+
+ row = cursor.fetchone()
+ return dict(row) if row else None
+
+ def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
+ """Get movie date record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,))
+
+ row = cursor.fetchone()
+ return dict(row) if row else None
+
+ def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None):
+ """Add processing history entry"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details)
+ VALUES (?, ?, ?, ?, ?)
+ """, (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
+ json.dumps(details) if details else None))
+
+ def get_stats(self) -> Dict[str, Any]:
+ """Get database statistics"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Series stats
+ cursor.execute("SELECT COUNT(*) FROM series")
+ series_count = cursor.fetchone()[0]
+
+ # Episode stats
+ cursor.execute("SELECT COUNT(*) FROM episodes")
+ episodes_total = cursor.fetchone()[0]
+
+ cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
+ episodes_with_video = cursor.fetchone()[0]
+
+ # Movie stats
+ cursor.execute("SELECT COUNT(*) FROM movies")
+ movies_total = cursor.fetchone()[0]
+
+ cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
+ movies_with_video = cursor.fetchone()[0]
+
+ # Processing history
+ cursor.execute("SELECT COUNT(*) FROM processing_history")
+ history_count = cursor.fetchone()[0]
+
+ return {
+ "series_count": series_count,
+ "episodes_total": episodes_total,
+ "episodes_with_video": episodes_with_video,
+ "movies_total": movies_total,
+ "movies_with_video": movies_with_video,
+ "processing_history_count": history_count,
+ "database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2)
+ }
\ No newline at end of file
diff --git a/core/logging.py b/core/logging.py
new file mode 100644
index 0000000..a673f42
--- /dev/null
+++ b/core/logging.py
@@ -0,0 +1,53 @@
+"""Logging utilities for NFOguard"""
+
+from datetime import datetime, timezone
+import os
+
+def _get_local_timezone():
+ """Get the local timezone, respecting TZ environment variable"""
+ tz_name = os.environ.get('TZ', 'UTC')
+
+ try:
+ # Try zoneinfo first (Python 3.9+)
+ from zoneinfo import ZoneInfo
+ return ZoneInfo(tz_name)
+ except ImportError:
+ # Fallback for older Python versions
+ try:
+ import pytz
+ return pytz.timezone(tz_name)
+ except:
+ # Final fallback to UTC
+ return timezone.utc
+ except:
+ # If zone name is invalid, fallback to UTC
+ return timezone.utc
+
+def _log(level: str, msg: str):
+ """Basic logging function that writes to console"""
+ tz = _get_local_timezone()
+ print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}")
+
+def convert_utc_to_local(utc_iso_string: str) -> str:
+ """Convert UTC ISO timestamp to local timezone timestamp"""
+ if not utc_iso_string:
+ return utc_iso_string
+
+ try:
+ # Parse UTC timestamp
+ if utc_iso_string.endswith('Z'):
+ dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
+ elif '+00:00' in utc_iso_string:
+ dt_utc = datetime.fromisoformat(utc_iso_string)
+ else:
+ # Assume UTC if no timezone info
+ dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
+
+ # Convert to local timezone
+ local_tz = _get_local_timezone()
+ dt_local = dt_utc.astimezone(local_tz)
+
+ return dt_local.isoformat(timespec='seconds')
+ except Exception:
+ # If conversion fails, return original
+ return utc_iso_string
\ No newline at end of file
diff --git a/core/nfo_manager.py b/core/nfo_manager.py
new file mode 100644
index 0000000..8baac7c
--- /dev/null
+++ b/core/nfo_manager.py
@@ -0,0 +1,565 @@
+#!/usr/bin/env python3
+"""
+NFO Manager for creating and managing metadata files
+Handles NFO creation for movies, TV shows, seasons, and episodes
+"""
+import os
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from datetime import datetime
+from typing import Optional, Dict, Any
+import re
+
+
+class NFOManager:
+ """Manages NFO file creation and updates"""
+
+ def __init__(self, manager_brand: str = "NFOGuard"):
+ self.manager_brand = manager_brand
+
+ def parse_imdb_from_path(self, path: Path) -> Optional[str]:
+ """Extract IMDb ID from directory path or filename"""
+ # Look for various IMDb patterns in both directory and file names
+ path_str = str(path).lower()
+
+ # Try [imdb-ttXXXXXXX] format first (most explicit)
+ match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
+ if match:
+ return match.group(1)
+
+ # Try standalone [ttXXXXXXX] format in brackets
+ match = re.search(r'\[(tt\d+)\]', path_str)
+ if match:
+ return match.group(1)
+
+ # Try {imdb-ttXXXXXXX} format with curly braces
+ match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
+ if match:
+ return match.group(1)
+
+ # Try (imdb-ttXXXXXXX) format with parentheses
+ match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
+ if match:
+ return match.group(1)
+
+ # Try ttXXXXXXX at end of filename/dirname (common pattern)
+ match = re.search(r'[-_\s](tt\d+)$', path_str)
+ if match:
+ return match.group(1)
+
+ return None
+
+ def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
+ """Extract IMDb ID from NFO file content"""
+ if not nfo_path.exists():
+ return None
+
+ try:
+ tree = ET.parse(nfo_path)
+ root = tree.getroot()
+
+ # Check for ttXXXXXX
+ imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
+ if imdb_uniqueid is not None and imdb_uniqueid.text:
+ imdb_id = imdb_uniqueid.text.strip()
+ if imdb_id.startswith('tt'):
+ return imdb_id
+
+ # Check for legacy ttXXXXXX
+ imdbid_elem = root.find('.//imdbid')
+ if imdbid_elem is not None and imdbid_elem.text:
+ imdb_id = imdbid_elem.text.strip()
+ if imdb_id.startswith('tt'):
+ return imdb_id
+
+ # Check for legacy ttXXXXXX
+ imdb_elem = root.find('.//imdb')
+ if imdb_elem is not None and imdb_elem.text:
+ imdb_id = imdb_elem.text.strip()
+ if imdb_id.startswith('tt'):
+ return imdb_id
+
+ except (ET.ParseError, Exception):
+ # Skip corrupted or non-XML files
+ pass
+
+ return None
+
+ def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
+ """Find IMDb ID from directory name, filenames, or NFO file"""
+ # First try directory name
+ imdb_id = self.parse_imdb_from_path(movie_dir)
+ if imdb_id:
+ return imdb_id
+
+ # Try all files in the directory for IMDb ID patterns
+ for file_path in movie_dir.iterdir():
+ if file_path.is_file():
+ imdb_id = self.parse_imdb_from_path(file_path)
+ if imdb_id:
+ return imdb_id
+
+ # Finally, try NFO file content
+ nfo_path = movie_dir / "movie.nfo"
+ imdb_id = self.parse_imdb_from_nfo(nfo_path)
+ if imdb_id:
+ print(f"๐ Found IMDb ID in NFO file: {imdb_id} from {nfo_path}")
+ return imdb_id
+
+ return None
+
+ def _parse_nfo_with_tolerance(self, nfo_path: Path):
+ """Parse NFO file with tolerance for URLs appended after XML"""
+ try:
+ # First try normal parsing
+ tree = ET.parse(nfo_path)
+ return tree.getroot()
+ except ET.ParseError as e:
+ # If parsing fails, try to extract just the XML part
+ try:
+ with open(nfo_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Find the last tag and truncate after it
+ last_movie_end = content.rfind('')
+ if last_movie_end != -1:
+ xml_content = content[:last_movie_end + 8] # +8 for
+
+ # Try parsing the truncated content
+ root = ET.fromstring(xml_content)
+ print(f"โ
Successfully parsed NFO after removing trailing content: {nfo_path.name}")
+ return root
+ else:
+ # Re-raise original error if we can't find
+ raise e
+ except Exception:
+ # Re-raise original error if our fix attempt fails
+ raise e
+
+ def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
+ released: Optional[str] = None, source: str = "unknown",
+ lock_metadata: bool = True) -> None:
+ """Create or update movie.nfo file preserving existing content"""
+ nfo_path = movie_dir / "movie.nfo"
+
+ try:
+ # Try to load existing NFO file
+ if nfo_path.exists():
+ try:
+ # Try to parse the XML, handling URLs appended after
+ movie = self._parse_nfo_with_tolerance(nfo_path)
+
+ # Ensure root element is
+ if movie.tag != "movie":
+ raise ValueError("Root element is not ")
+
+ # Remove existing NFOGuard-managed elements to avoid duplicates
+ # These will be re-added at the very bottom
+ nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"]
+ for tag in nfoguard_fields:
+ existing = movie.find(tag)
+ if existing is not None:
+ # Store the value before removing (for premiered/year)
+ if tag == "premiered" and not released:
+ released = existing.text # Preserve existing premiered date
+ movie.remove(existing)
+
+ # Remove ALL existing uniqueid with type="imdb" regardless of attributes
+ # We'll add a clean one at the bottom
+ for uniqueid in movie.findall("uniqueid[@type='imdb']"):
+ movie.remove(uniqueid)
+
+ except (ET.ParseError, ValueError) as e:
+ print(f"โ ๏ธ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
+ print(f" Creating new clean NFO file to replace corrupted one")
+ movie = ET.Element("movie")
+ else:
+ # Create new NFO structure
+ movie = ET.Element("movie")
+
+ # Now append ALL NFOGuard and date fields at the VERY END of the file
+ # This ensures they appear after all existing content including actors
+
+ # Add IMDb uniqueid at the end (after all existing content)
+ uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true")
+ uniqueid.text = imdb_id
+
+ # Add premiered date at the bottom if we have it
+ if released:
+ premiered_elem = ET.SubElement(movie, "premiered")
+ premiered_elem.text = released[:10] if len(released) >= 10 else released
+
+ # Extract year from premiered date for consistency
+ try:
+ year_value = released[:4] if len(released) >= 4 else None
+ if year_value and year_value.isdigit():
+ year_elem = ET.SubElement(movie, "year")
+ year_elem.text = year_value
+ except:
+ pass # Skip year if we can't extract it
+
+ # Add dateadded at the end
+ if dateadded:
+ dateadded_elem = ET.SubElement(movie, "dateadded")
+ dateadded_elem.text = dateadded
+
+ # Add lockdata at the very end
+ if lock_metadata:
+ lockdata = ET.SubElement(movie, "lockdata")
+ lockdata.text = "true"
+
+ # Add NFOGuard comment at the beginning
+ comment_text = f" Created by {self.manager_brand} - Source: {source} "
+
+ # Write file with proper formatting
+ tree = ET.ElementTree(movie)
+ ET.indent(tree, space=" ", level=0)
+
+ # Write to string first to add comment
+ import xml.etree.ElementTree as ET_temp
+ xml_str = ET.tostring(movie, encoding='unicode')
+
+ # Add XML declaration and comment
+ full_xml = f'\n\n{xml_str}'
+
+ # Write to file
+ with open(nfo_path, 'w', encoding='utf-8') as f:
+ f.write(full_xml)
+
+ print(f"โ
Successfully created/updated movie NFO: {nfo_path}")
+ print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
+
+ except Exception as e:
+ print(f"โ Error creating/updating movie NFO {nfo_path}: {e}")
+
+ def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
+ """Create or update tvshow.nfo file preserving existing content"""
+ nfo_path = series_dir / "tvshow.nfo"
+
+ try:
+ # Try to load existing NFO file
+ if nfo_path.exists():
+ try:
+ tree = ET.parse(nfo_path)
+ tvshow = tree.getroot()
+
+ # Ensure root element is
+ if tvshow.tag != "tvshow":
+ raise ValueError("Root element is not ")
+
+ # Remove existing NFOGuard-managed elements to avoid duplicates
+ # These will be re-added at the bottom
+ for tag in ["lockdata"]:
+ existing = tvshow.find(tag)
+ if existing is not None:
+ tvshow.remove(existing)
+
+ # Remove ALL existing uniqueid with type="imdb" regardless of attributes
+ for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
+ tvshow.remove(uniqueid)
+
+ except (ET.ParseError, ValueError) as e:
+ print(f"โ ๏ธ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
+ print(f" Creating new clean tvshow.nfo file to replace corrupted one")
+ tvshow = ET.Element("tvshow")
+ else:
+ # Create new NFO structure
+ tvshow = ET.Element("tvshow")
+
+ # Add NFOGuard fields at the bottom
+
+ # Add IMDb uniqueid at the end
+ imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
+ imdb_uniqueid.text = imdb_id
+
+ # Add TVDB ID if available (preserve existing or add new)
+ if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
+ tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
+ tvdb_uniqueid.text = tvdb_id
+
+ # Add lockdata at the very end
+ lockdata = ET.SubElement(tvshow, "lockdata")
+ lockdata.text = "true"
+
+ # Add NFOGuard comment at the beginning
+ comment_text = f" Created by {self.manager_brand} "
+
+ # Write file with proper formatting
+ tree = ET.ElementTree(tvshow)
+ ET.indent(tree, space=" ", level=0)
+
+ # Write to string first to add comment
+ xml_str = ET.tostring(tvshow, encoding='unicode')
+
+ # Add XML declaration and comment
+ full_xml = f'\n\n{xml_str}'
+
+ # Write to file
+ with open(nfo_path, 'w', encoding='utf-8') as f:
+ f.write(full_xml)
+
+ print(f"โ
Successfully created/updated TV show NFO: {nfo_path}")
+ print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
+
+ except Exception as e:
+ print(f"โ Error creating/updating tvshow NFO {nfo_path}: {e}")
+
+ def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
+ """Create or update season.nfo file preserving existing content"""
+ nfo_path = season_dir / "season.nfo"
+
+ try:
+ season_dir.mkdir(exist_ok=True)
+
+ # Try to load existing NFO file
+ if nfo_path.exists():
+ try:
+ tree = ET.parse(nfo_path)
+ season = tree.getroot()
+
+ # Ensure root element is
+ if season.tag != "season":
+ raise ValueError("Root element is not ")
+
+ # Remove existing NFOGuard-managed elements
+ for tag in ["seasonnumber", "lockdata"]:
+ existing = season.find(tag)
+ if existing is not None:
+ season.remove(existing)
+
+ except (ET.ParseError, ValueError) as e:
+ print(f"โ ๏ธ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
+ print(f" Creating new clean season.nfo file to replace corrupted one")
+ season = ET.Element("season")
+ else:
+ # Create new NFO structure
+ season = ET.Element("season")
+
+ # Add NFOGuard fields at the bottom
+ seasonnumber = ET.SubElement(season, "seasonnumber")
+ seasonnumber.text = str(season_number)
+
+ # Add lockdata at the end
+ lockdata = ET.SubElement(season, "lockdata")
+ lockdata.text = "true"
+
+ # Add NFOGuard comment at the beginning
+ comment_text = f" Created by {self.manager_brand} "
+
+ # Write file with proper formatting
+ tree = ET.ElementTree(season)
+ ET.indent(tree, space=" ", level=0)
+
+ # Write to string first to add comment
+ xml_str = ET.tostring(season, encoding='unicode')
+
+ # Add XML declaration and comment
+ full_xml = f'\n\n{xml_str}'
+
+ # Write to file
+ with open(nfo_path, 'w', encoding='utf-8') as f:
+ f.write(full_xml)
+
+ print(f"โ
Successfully created/updated season NFO: {nfo_path}")
+ print(f" Season: {season_number}")
+
+ except Exception as e:
+ print(f"โ Error creating/updating season NFO {nfo_path}: {e}")
+
+ def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
+ """Find existing episode NFO file that matches season/episode but isn't standardized name"""
+ if not season_dir.exists():
+ return None
+
+ # Standard filename pattern we're looking to create
+ standard_pattern = f"S{season_num:02d}E{episode_num:02d}.nfo"
+
+ # Look for NFO files in the season directory
+ for nfo_file in season_dir.glob("*.nfo"):
+ # Skip if it's already the standard format
+ if nfo_file.name == standard_pattern:
+ continue
+
+ # Check if this NFO contains the right season/episode
+ try:
+ tree = ET.parse(nfo_file)
+ root = tree.getroot()
+
+ if root.tag == "episodedetails":
+ # Check for season/episode elements
+ season_elem = root.find("season")
+ episode_elem = root.find("episode")
+
+ if (season_elem is not None and episode_elem is not None and
+ season_elem.text and episode_elem.text):
+ try:
+ file_season = int(season_elem.text)
+ file_episode = int(episode_elem.text)
+
+ if file_season == season_num and file_episode == episode_num:
+ print(f"๐ Found existing episode NFO: {nfo_file.name} -> will migrate to {standard_pattern}")
+ return nfo_file
+ except ValueError:
+ continue
+
+ except (ET.ParseError, Exception):
+ # Skip corrupted or non-XML files
+ continue
+
+ return None
+
+ def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
+ aired: Optional[str], dateadded: Optional[str], source: str,
+ lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
+ """Create or update episode NFO file preserving existing content"""
+ # Generate episode filename pattern
+ episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
+ nfo_path = season_dir / episode_filename
+
+ # Track if we need to delete an old long-named NFO file
+ old_nfo_to_delete = None
+
+ try:
+ # First, check for existing long-named NFO files that need migration
+ existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
+
+ # Try to load existing NFO file (either standard or long-named)
+ source_nfo_path = nfo_path if nfo_path.exists() else existing_long_nfo
+
+ if source_nfo_path:
+ try:
+ tree = ET.parse(source_nfo_path)
+ episode = tree.getroot()
+
+ # Ensure root element is
+ if episode.tag != "episodedetails":
+ raise ValueError("Root element is not ")
+
+ # If we're migrating from a long-named file, mark it for deletion
+ if existing_long_nfo and source_nfo_path == existing_long_nfo:
+ old_nfo_to_delete = existing_long_nfo
+ print(f"๐ฆ Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}")
+
+ # Remove existing NFOGuard-managed elements to avoid duplicates
+ # These will be re-added at the bottom
+ nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
+ for tag in nfoguard_fields:
+ existing = episode.find(tag)
+ if existing is not None:
+ # Store the aired value before removing
+ if tag == "aired" and not aired:
+ aired = existing.text # Preserve existing aired date
+ episode.remove(existing)
+
+ except (ET.ParseError, ValueError) as e:
+ print(f"โ ๏ธ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
+ print(f" Creating new clean episode NFO file to replace corrupted one")
+ episode = ET.Element("episodedetails")
+ else:
+ # Create new NFO structure
+ episode = ET.Element("episodedetails")
+
+ # Enhanced metadata should be preserved - only add if not already present
+ if enhanced_metadata:
+ if enhanced_metadata.get("title") and not episode.find("title"):
+ title_elem = ET.SubElement(episode, "title")
+ title_elem.text = enhanced_metadata["title"]
+
+ if enhanced_metadata.get("overview") and not episode.find("plot"):
+ plot_elem = ET.SubElement(episode, "plot")
+ plot_elem.text = enhanced_metadata["overview"]
+
+ if enhanced_metadata.get("runtime") and not episode.find("runtime"):
+ runtime_elem = ET.SubElement(episode, "runtime")
+ runtime_elem.text = str(enhanced_metadata["runtime"])
+
+ # Add NFOGuard fields at the bottom
+
+ # Basic episode info at the end
+ season_elem = ET.SubElement(episode, "season")
+ season_elem.text = str(season_num)
+
+ episode_elem = ET.SubElement(episode, "episode")
+ episode_elem.text = str(episode_num)
+
+ # Dates at the end
+ if aired:
+ aired_elem = ET.SubElement(episode, "aired")
+ aired_elem.text = aired[:10] if len(aired) >= 10 else aired
+
+ if dateadded:
+ dateadded_elem = ET.SubElement(episode, "dateadded")
+ dateadded_elem.text = dateadded
+
+ # Add lockdata at the very end
+ if lock_metadata:
+ lockdata = ET.SubElement(episode, "lockdata")
+ lockdata.text = "true"
+
+ # Add NFOGuard comment at the beginning
+ comment_text = f" Created by {self.manager_brand} - Source: {source} "
+
+ # Write file with proper formatting
+ tree = ET.ElementTree(episode)
+ ET.indent(tree, space=" ", level=0)
+
+ # Write to string first to add comment
+ xml_str = ET.tostring(episode, encoding='unicode')
+
+ # Add XML declaration and comment
+ full_xml = f'\n\n{xml_str}'
+
+ # Write to file
+ with open(nfo_path, 'w', encoding='utf-8') as f:
+ f.write(full_xml)
+
+ print(f"โ
Successfully created/updated episode NFO: {nfo_path}")
+ print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
+
+ # Clean up old long-named NFO file if we migrated from it
+ if old_nfo_to_delete and old_nfo_to_delete.exists():
+ try:
+ old_nfo_to_delete.unlink()
+ print(f"๐๏ธ Cleaned up old NFO file: {old_nfo_to_delete.name}")
+ except Exception as cleanup_error:
+ print(f"โ ๏ธ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}")
+
+ except Exception as e:
+ print(f"โ Error creating/updating episode NFO {nfo_path}: {e}")
+
+ def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
+ """Set file modification time to match import date"""
+ try:
+ # Parse ISO timestamp
+ if iso_timestamp.endswith('Z'):
+ dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
+ elif '+' in iso_timestamp or 'T' in iso_timestamp:
+ dt = datetime.fromisoformat(iso_timestamp)
+ else:
+ # Assume it's already a simple date
+ dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
+
+ # Convert to timestamp
+ timestamp = dt.timestamp()
+
+ # Set both access and modification times
+ os.utime(file_path, (timestamp, timestamp))
+ print(f"โ
Updated file timestamp: {file_path.name} -> {iso_timestamp}")
+
+ except Exception as e:
+ print(f"โ Error setting mtime for {file_path}: {e}")
+
+ def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
+ """Update modification times for all video files in movie directory"""
+ video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
+ updated_files = []
+
+ for file_path in movie_dir.iterdir():
+ if file_path.is_file() and file_path.suffix.lower() in video_exts:
+ self.set_file_mtime(file_path, iso_timestamp)
+ updated_files.append(file_path.name)
+
+ if updated_files:
+ print(f"โ
Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
+ else:
+ print(f"โ ๏ธ No video files found to update in {movie_dir.name}")
\ No newline at end of file
diff --git a/core/path_mapper.py b/core/path_mapper.py
new file mode 100644
index 0000000..59d5805
--- /dev/null
+++ b/core/path_mapper.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""
+Path mapping utilities for NFOGuard
+Handles conversion between external service paths and container paths
+"""
+import os
+import re
+from pathlib import Path
+
+class PathMapper:
+ """Handles path mapping between different environments"""
+
+ def __init__(self, config):
+ """Initialize path mapper with configuration"""
+ # Use environment variables directly since config attribute names are unclear
+ import os
+
+ radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '')
+ sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '')
+ movie_paths_str = os.getenv('MOVIE_PATHS', '')
+ tv_paths_str = os.getenv('TV_PATHS', '')
+
+ self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()]
+ self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()]
+ self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()]
+ self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()]
+
+ # Check if path debugging is enabled
+ self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true'
+
+ if self.path_debug:
+ print(f"PATH_DEBUG: PathMapper initialized with:")
+ print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
+ print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
+ print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
+ print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
+
+ def sonarr_path_to_container_path(self, sonarr_path: str) -> str:
+ """Convert Sonarr path to container path using environment mappings"""
+ if self.path_debug:
+ print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}")
+ print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
+ print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
+
+ # Sort roots by length (longest first) to avoid substring matching issues
+ indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)]
+ indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
+
+ # Try to match against configured Sonarr root folders (longest first)
+ for original_index, sonarr_root in indexed_roots:
+ if self.path_debug:
+ print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}")
+ if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root:
+ if self.path_debug:
+ print(f"PATH_DEBUG: Match found! Index {original_index}")
+ # Map to corresponding TV path
+ if original_index < len(self.tv_paths):
+ container_root = self.tv_paths[original_index]
+ relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
+ result = str(Path(container_root) / relative_path) if relative_path else container_root
+ if self.path_debug:
+ print(f"PATH_DEBUG: Mapped to: {result}")
+ return result
+
+ if self.path_debug:
+ print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}")
+ # No fallback - if path mapping fails, return original and let validation catch it
+ return sonarr_path
+
+ def radarr_path_to_container_path(self, radarr_path: str) -> str:
+ """Convert Radarr path to container path using environment mappings"""
+ if self.path_debug:
+ print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}")
+ print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
+ print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
+
+ # Sort roots by length (longest first) to avoid substring matching issues
+ indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)]
+ indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
+
+ # Try to match against configured Radarr root folders (longest first)
+ for original_index, radarr_root in indexed_roots:
+ if self.path_debug:
+ print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}")
+ if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root:
+ if self.path_debug:
+ print(f"PATH_DEBUG: Match found! Index {original_index}")
+ # Map to corresponding movie path
+ if original_index < len(self.movie_paths):
+ container_root = self.movie_paths[original_index]
+ relative_path = radarr_path[len(radarr_root):].lstrip('/')
+ result = str(Path(container_root) / relative_path) if relative_path else container_root
+ if self.path_debug:
+ print(f"PATH_DEBUG: Mapped to: {result}")
+ return result
+
+ if self.path_debug:
+ print(f"PATH_DEBUG: No match found, returning original: {radarr_path}")
+ # No fallback - if path mapping fails, return original and let validation catch it
+ return radarr_path
+
+ def container_path_to_host_path(self, container_path: str) -> str:
+ """Convert container path back to host path if needed"""
+ # This might be needed for file operations
+ return container_path
\ No newline at end of file
diff --git a/docker-compose.example b/docker-compose.example
new file mode 100644
index 0000000..5f760b2
--- /dev/null
+++ b/docker-compose.example
@@ -0,0 +1,165 @@
+version: '3.8'
+
+services:
+ nfoguard:
+ # Use the official image from Docker Hub
+ image: sbcrumb/nfoguard:latest
+
+ # Alternative: Use specific version
+ # image: sbcrumb/nfoguard:v1.5.5
+
+ # Alternative: Use development version
+ # image: sbcrumb/nfoguard:dev
+
+ container_name: nfoguard
+
+ # Restart policy
+ restart: unless-stopped
+
+ # Environment files
+ env_file:
+ - .env
+ - .env.secrets
+
+ # Environment variables
+ environment:
+ - TZ=America/New_York # Set to your local timezone
+ # Alternative examples:
+ # - TZ=Europe/London
+ # - TZ=America/Los_Angeles
+ # - TZ=Australia/Sydney
+ # - TZ=UTC
+
+ # Port mapping (adjust if needed)
+ ports:
+ - "8080:8080"
+
+ # Volume mounts - CRITICAL: These must match your media setup
+ volumes:
+ # NFOGuard data directory (database, logs, cache)
+ - ./data:/app/data
+
+ # Bind mount your Emby plugins directory for automatic plugin deployment
+ - ${EMBY_PLUGINS_PATH}:/emby-plugins
+
+ # Movie libraries - Mount your movie directories here
+ # Format: host_path:container_path:rw
+ - /path/to/your/movies:/media/Movies/movies:rw
+ - /path/to/your/movies2:/media/Movies/movies6:rw
+
+ # TV libraries - Mount your TV show directories here
+ - /path/to/your/tv:/media/TV/tv:rw
+ - /path/to/your/tv2:/media/TV/tv6:rw
+
+ # Optional: Mount download directories for detection
+ # - /path/to/downloads:/downloads:ro
+
+ # Health check
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+ # Resource limits (optional)
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: '0.5'
+ reservations:
+ memory: 256M
+ cpus: '0.25'
+
+ # Network (optional - use if you have a custom network)
+ # networks:
+ # - media-network
+
+ # Logging configuration
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+# Optional: Custom network for media services
+# networks:
+# media-network:
+# external: true
+
+# ===========================================
+# SETUP INSTRUCTIONS
+# ===========================================
+# 1. Copy this file to docker-compose.yml
+# 2. Update volume mounts to match your actual media paths
+# 3. Copy .env.template to .env and configure
+# 4. Copy .env.secrets.template to .env.secrets and add credentials
+# 5. Create data directory: mkdir -p ./data
+# 6. Run: docker-compose up -d
+# 7. Check logs: docker-compose logs -f nfoguard
+# 8. Access health check: curl http://localhost:8080/health
+
+# ===========================================
+# VOLUME MAPPING EXAMPLES
+# ===========================================
+# Common setups:
+#
+# Unraid:
+# - /mnt/user/Media/Movies:/media/Movies/movies:rw
+# - /mnt/user/Media/TV:/media/TV/tv:rw
+#
+# Synology:
+# - /volume1/Media/Movies:/media/Movies/movies:rw
+# - /volume1/Media/TV:/media/TV/tv:rw
+#
+# Standard Linux:
+# - /home/user/media/movies:/media/Movies/movies:rw
+# - /home/user/media/tv:/media/TV/tv:rw
+#
+# Windows (Docker Desktop):
+# - C:\Media\Movies:/media/Movies/movies:rw
+# - C:\Media\TV:/media/TV/tv:rw
+
+# ===========================================
+# WEBHOOK CONFIGURATION
+# ===========================================
+# Configure these webhook URLs in your *arr applications:
+#
+# Radarr webhook URL:
+# http://nfoguard:8080/webhook/radarr
+# (or http://localhost:8080/webhook/radarr if not using Docker network)
+#
+# Sonarr webhook URL:
+# http://nfoguard:8080/webhook/sonarr
+# (or http://localhost:8080/webhook/sonarr if not using Docker network)
+#
+# Webhook triggers:
+# - On Import
+# - On Upgrade
+# - On Rename (recommended)
+
+# ===========================================
+# TROUBLESHOOTING
+# ===========================================
+# Common issues and solutions:
+#
+# 1. Permission denied errors:
+# - Ensure NFOGuard container can write to mounted directories
+# - Check file ownership and permissions
+# - Consider using PUID/PGID environment variables
+#
+# 2. Path mapping issues:
+# - Verify container paths match .env configuration
+# - Ensure *arr applications can see the same files
+# - Check RADARR_ROOT_FOLDERS and SONARR_ROOT_FOLDERS in .env
+#
+# 3. Database connection issues:
+# - Verify PostgreSQL credentials in .env.secrets
+# - Check network connectivity to database
+# - Ensure database exists and user has permissions
+#
+# 4. Webhook not triggering:
+# - Verify webhook URLs in *arr applications
+# - Check NFOGuard logs for incoming requests
+# - Ensure port 8080 is accessible
diff --git a/nfoguard.py b/nfoguard.py
new file mode 100644
index 0000000..2b1bd82
--- /dev/null
+++ b/nfoguard.py
@@ -0,0 +1,2218 @@
+#!/usr/bin/env python3
+"""
+NFOGuard - Automated NFO file management for Radarr and Sonarr
+Modular architecture with webhook processing and intelligent date handling
+"""
+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 zoneinfo import ZoneInfo
+from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
+from pydantic import BaseModel
+from typing import Optional, Dict, Any, List, Set, Tuple
+import threading
+from concurrent.futures import ThreadPoolExecutor
+import uvicorn
+
+# Import our new modular components
+from core.database import NFOGuardDatabase
+from core.nfo_manager import NFOManager
+from core.path_mapper import PathMapper
+from clients.radarr_client import RadarrClient
+from clients.sonarr_client import SonarrClient
+from clients.external_clients import ExternalClientManager
+
+# ---------------------------
+# Configuration & Logging
+# ---------------------------
+
+class TimezoneAwareFormatter(logging.Formatter):
+ """Formatter that respects the container timezone"""
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.timezone = self._get_local_timezone()
+
+ def _get_local_timezone(self):
+ """Get the local timezone, respecting TZ environment variable"""
+ tz_name = os.environ.get('TZ', 'UTC')
+
+ try:
+ # Try zoneinfo first (Python 3.9+)
+ return ZoneInfo(tz_name)
+ except ImportError:
+ # Fallback for older Python versions
+ try:
+ import pytz
+ return pytz.timezone(tz_name)
+ except:
+ # Final fallback to UTC
+ return timezone.utc
+ except:
+ # If zone name is invalid, fallback to UTC
+ return timezone.utc
+
+ def formatTime(self, record, datefmt=None):
+ dt = datetime.fromtimestamp(record.created, tz=self.timezone)
+ if datefmt:
+ return dt.strftime(datefmt)
+ return dt.isoformat(timespec='seconds')
+
+def _setup_file_logging():
+ """Setup file logging for NFOGuard"""
+ log_dir = Path(os.environ.get("LOG_DIR", "/app/data/logs"))
+ log_dir.mkdir(parents=True, exist_ok=True)
+
+ logger = logging.getLogger("NFOGuard")
+ logger.setLevel(logging.DEBUG)
+
+ file_handler = logging.handlers.RotatingFileHandler(
+ log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
+ )
+
+ formatter = TimezoneAwareFormatter(
+ '[%(asctime)s] %(levelname)s: %(message)s'
+ )
+ file_handler.setFormatter(formatter)
+ logger.addHandler(file_handler)
+ return logger
+
+def _mask_sensitive_data(msg: str) -> str:
+ """Mask API keys and other sensitive data in log messages"""
+ import re
+
+ # List of patterns to mask
+ sensitive_patterns = [
+ (r'api_key=([a-zA-Z0-9_\-]+)', r'api_key=***masked***'),
+ (r'password=([^\s&]+)', r'password=***masked***'),
+ (r'token=([a-zA-Z0-9_\-]+)', r'token=***masked***'),
+ (r'key=([a-zA-Z0-9_\-]{8,})', r'key=***masked***'), # Keys longer than 8 chars
+ (r'([a-zA-Z0-9]{32,})', lambda m: m.group(1)[:8] + '***masked***' if len(m.group(1)) > 16 else m.group(1)) # Long strings likely to be keys
+ ]
+
+ masked_msg = msg
+ for pattern, replacement in sensitive_patterns:
+ if isinstance(replacement, str):
+ masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE)
+ else:
+ masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE)
+
+ return masked_msg
+
+def _get_local_timezone():
+ """Get the local timezone, respecting TZ environment variable"""
+ tz_name = os.environ.get('TZ', 'UTC')
+
+ try:
+ # Try zoneinfo first (Python 3.9+)
+ return ZoneInfo(tz_name)
+ except ImportError:
+ # Fallback for older Python versions
+ try:
+ import pytz
+ return pytz.timezone(tz_name)
+ except:
+ # Final fallback to UTC
+ return timezone.utc
+ except:
+ # If zone name is invalid, fallback to UTC
+ return timezone.utc
+
+def _log(level: str, msg: str):
+ """Enhanced logging that writes to both console and file with sensitive data masking"""
+ masked_msg = _mask_sensitive_data(msg)
+ tz = _get_local_timezone()
+ print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {masked_msg}")
+
+ try:
+ file_logger = _setup_file_logging()
+ getattr(file_logger, level.lower(), file_logger.info)(masked_msg)
+ except Exception as e:
+ print(f"File logging error: {e}")
+
+def convert_utc_to_local(utc_iso_string: str) -> str:
+ """Convert UTC ISO timestamp to local timezone timestamp"""
+ if not utc_iso_string:
+ return utc_iso_string
+
+ try:
+ # Parse UTC timestamp
+ if utc_iso_string.endswith('Z'):
+ dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
+ elif '+00:00' in utc_iso_string:
+ dt_utc = datetime.fromisoformat(utc_iso_string)
+ else:
+ # Assume UTC if no timezone info
+ dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
+
+ # Convert to local timezone
+ local_tz = _get_local_timezone()
+ dt_local = dt_utc.astimezone(local_tz)
+
+ return dt_local.isoformat(timespec='seconds')
+ except Exception:
+ # If conversion fails, return original
+ return utc_iso_string
+
+# Initialize logging
+_setup_file_logging()
+
+def _load_environment_files():
+ """Load environment variables from .env and optionally .env.secrets"""
+ from pathlib import Path
+
+ # Try to load from python-dotenv if available
+ try:
+ from dotenv import load_dotenv
+
+ # Load main .env file
+ env_file = Path(".env")
+ if env_file.exists():
+ load_dotenv(env_file)
+ _log("INFO", f"Loaded environment from {env_file}")
+
+ # Load secrets file if it exists
+ secrets_file = Path(".env.secrets")
+ if secrets_file.exists():
+ load_dotenv(secrets_file)
+ _log("INFO", f"Loaded secrets from {secrets_file}")
+
+ except ImportError:
+ _log("WARNING", "python-dotenv not available - environment files not loaded")
+
+# Load environment files at startup
+_load_environment_files()
+
+# Add debug logging near where configuration is loaded
+print(f"DEBUG: Environment check - SONARR_ROOT_FOLDERS: {os.getenv('SONARR_ROOT_FOLDERS')}")
+print(f"DEBUG: Environment check - TV_PATHS: {os.getenv('TV_PATHS')}")
+
+def _bool_env(name: str, default: bool) -> bool:
+ v = os.environ.get(name)
+ if v is None:
+ return default
+ return v.lower() in ("1", "true", "yes", "y", "on")
+
+# ---------------------------
+# Configuration
+# ---------------------------
+
+class NFOGuardConfig:
+ def __init__(self):
+ # Paths - No hardcoded defaults, must be configured via environment
+ tv_paths_env = os.environ.get("TV_PATHS", "")
+ movie_paths_env = os.environ.get("MOVIE_PATHS", "")
+
+ if not tv_paths_env:
+ raise ValueError("TV_PATHS environment variable is required but not set")
+ if not movie_paths_env:
+ raise ValueError("MOVIE_PATHS environment variable is required but not set")
+
+ self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()]
+ self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()]
+
+ # Core settings
+ 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.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
+
+ # Batching
+ self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0"))
+ self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3"))
+
+ # Database
+ self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
+
+ # Movie processing
+ self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower()
+ 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()
+
+ # TV processing
+ self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
+ self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
+ self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
+
+config = NFOGuardConfig()
+
+# ---------------------------
+# 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
+
+ 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
+
+ class Config:
+ extra = "allow"
+
+class HealthResponse(BaseModel):
+ status: str
+ version: str
+ uptime: str
+ database_status: str
+ radarr_database: Optional[Dict[str, Any]] = None
+
+class TVSeasonRequest(BaseModel):
+ series_path: str
+ season_name: str
+
+class TVEpisodeRequest(BaseModel):
+ series_path: str
+ season_name: str
+ episode_name: str
+
+# ---------------------------
+# Core Processing
+# ---------------------------
+
+class TVProcessor:
+ """Handles TV series processing"""
+
+ def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
+ self.db = db
+ self.nfo_manager = nfo_manager
+ self.path_mapper = path_mapper
+ self.sonarr = SonarrClient(
+ os.environ.get("SONARR_URL", ""),
+ os.environ.get("SONARR_API_KEY", "")
+ )
+ self.external_clients = ExternalClientManager()
+
+ def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]:
+ """Find series directory path"""
+ # Try webhook path first
+ if sonarr_path:
+ container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path)
+ path_obj = Path(container_path)
+ if path_obj.exists():
+ return path_obj
+
+ # Search by IMDb ID or title
+ for media_path in config.tv_paths:
+ if not media_path.exists():
+ continue
+
+ # Search by IMDb ID
+ if imdb_id:
+ pattern = str(media_path / f"*[imdb-{imdb_id}]*")
+ matches = glob.glob(pattern)
+ if matches:
+ return Path(matches[0])
+
+ # Search by title
+ if series_title:
+ title_clean = series_title.lower().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("-", "")
+ if title_clean in item_clean:
+ return item
+
+ return None
+
+ def process_series(self, series_path: Path) -> None:
+ """Process a TV series directory"""
+ imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID found in series path: {series_path}")
+ return
+
+ _log("INFO", f"Processing TV series: {series_path.name}")
+
+ # Update database
+ self.db.upsert_series(imdb_id, str(series_path))
+
+ # Find video files
+ disk_episodes = self._find_disk_episodes(series_path)
+ _log("INFO", f"Found {len(disk_episodes)} episodes on disk")
+
+ # Get episode dates
+ episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes)
+
+ # Process episodes
+ for (season, episode), (aired, dateadded, source) in episode_dates.items():
+ if (season, episode) in disk_episodes:
+ # Create NFO
+ if config.manage_nfo:
+ season_dir = series_path / config.tv_season_dir_format.format(season=season)
+ self.nfo_manager.create_episode_nfo(
+ season_dir,
+ season, episode, aired, dateadded, source, config.lock_metadata
+ )
+
+ # Update file mtimes
+ if config.fix_dir_mtimes and dateadded:
+ video_files = disk_episodes[(season, episode)]
+ for video_file in video_files:
+ self.nfo_manager.set_file_mtime(video_file, dateadded)
+
+ # Save to database
+ self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
+
+ # Create season/tvshow NFOs
+ if config.manage_nfo:
+ seasons_processed = set()
+ for (season, episode) in disk_episodes.keys():
+ if season not in seasons_processed:
+ season_dir = series_path / config.tv_season_dir_format.format(season=season)
+ self.nfo_manager.create_season_nfo(season_dir, season)
+ seasons_processed.add(season)
+
+ # Get TVDB ID for better Emby compatibility
+ tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
+ self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
+
+ _log("INFO", f"Completed processing TV series: {series_path.name}")
+
+ def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
+ """Find all episode video files on disk"""
+ disk_episodes = {}
+ video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
+
+ for season_dir in series_path.iterdir():
+ if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)):
+ continue
+
+ try:
+ # Extract season number from directory name
+ # Handle formats like "Season 01", "S01", "Season01", etc.
+ dir_name = season_dir.name.lower()
+ if config.tv_season_dir_pattern in dir_name:
+ # Extract everything after the pattern
+ season_part = dir_name[len(config.tv_season_dir_pattern):].strip()
+ else:
+ continue
+ season_num = int(season_part)
+ except (ValueError, IndexError):
+ continue
+
+ for video_file in season_dir.iterdir():
+ if video_file.is_file() and video_file.suffix.lower() in video_exts:
+ match = re.search(r"S(\d{2})E(\d{2})", video_file.name, re.IGNORECASE)
+ if match:
+ file_season, file_episode = int(match.group(1)), int(match.group(2))
+ key = (season_num, file_episode) # Use directory season number
+ if key not in disk_episodes:
+ disk_episodes[key] = []
+ disk_episodes[key].append(video_file)
+
+ return disk_episodes
+
+ def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict) -> Dict:
+ """Gather episode dates from various sources"""
+ episode_dates = {}
+
+ # Check cache first
+ cached_episodes = self.db.get_series_episodes(imdb_id, has_video_file_only=True)
+ for ep in cached_episodes:
+ key = (ep["season"], ep["episode"])
+ if key in disk_episodes: # Only use cached data for episodes we have
+ episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"])
+
+ # Find missing episodes
+ cached_keys = set(episode_dates.keys())
+ missing_keys = set(disk_episodes.keys()) - cached_keys
+
+ if not missing_keys:
+ _log("INFO", "All episodes found in cache")
+ return episode_dates
+
+ _log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes")
+
+ # Query Sonarr for missing episodes
+ if self.sonarr.enabled:
+ series = self.sonarr.series_by_imdb(imdb_id)
+ if series:
+ series_id = series.get("id")
+ if series_id:
+ episodes = self.sonarr.episodes_for_series(series_id)
+ for ep in episodes:
+ season_num = ep.get("seasonNumber")
+ episode_num = ep.get("episodeNumber")
+
+ if not isinstance(season_num, int) or not isinstance(episode_num, int):
+ continue
+
+ key = (season_num, episode_num)
+ if key not in missing_keys:
+ continue
+
+ # Get dates
+ aired = self._parse_date_to_iso(ep.get("airDateUtc"))
+ dateadded = None
+ source = "sonarr:episode.airDateUtc"
+
+ # Try to get import history
+ episode_id = ep.get("id")
+ if episode_id:
+ import_date = self.sonarr.get_episode_import_history(episode_id)
+ if import_date:
+ dateadded = self._parse_date_to_iso(import_date)
+ source = "sonarr:history.import"
+
+ if not dateadded:
+ dateadded = aired
+
+ if aired or dateadded:
+ episode_dates[key] = (aired, dateadded, source)
+
+ # Fill remaining gaps with external APIs
+ remaining_keys = missing_keys - set(episode_dates.keys())
+ if remaining_keys and self.external_clients.tmdb.enabled:
+ tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id)
+ if tmdb_movie:
+ tv_id = tmdb_movie.get("id")
+ if tv_id:
+ seasons_needed = set(season for season, episode in remaining_keys)
+ for season_num in seasons_needed:
+ tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num)
+ for ep_num, air_date in tmdb_episodes.items():
+ key = (season_num, ep_num)
+ if key in remaining_keys:
+ aired = self._parse_date_to_iso(air_date)
+ episode_dates[key] = (aired, aired, "tmdb:air_date")
+
+ return episode_dates
+
+ def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
+ """Parse date string to ISO format"""
+ if not date_str:
+ return None
+ try:
+ if len(date_str) == 10 and date_str[4] == "-":
+ dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
+ else:
+ dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
+ return dt.isoformat(timespec="seconds")
+ except Exception:
+ return None
+
+ def process_season(self, series_path: Path, season_path: Path) -> None:
+ """Process a specific TV season directory"""
+ imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID found in series path: {series_path}")
+ return
+
+ # Extract season number from path
+ season_match = re.search(r'season\s*(\d+)', season_path.name, re.IGNORECASE)
+ if not season_match:
+ _log("ERROR", f"Could not extract season number from: {season_path.name}")
+ return
+
+ season_num = int(season_match.group(1))
+ _log("INFO", f"Processing TV season {season_num}: {season_path}")
+
+ # Update database
+ self.db.upsert_series(imdb_id, str(series_path))
+
+ # Find video files in this season only
+ disk_episodes = self._find_season_episodes(season_path, season_num)
+ _log("INFO", f"Found {len(disk_episodes)} episodes in season {season_num}")
+
+ # Get enhanced metadata from Sonarr
+ series_metadata = self._get_sonarr_series_metadata(imdb_id)
+
+ # Get episode dates for this season
+ episode_dates = self._gather_season_episode_dates(series_path, imdb_id, season_num, disk_episodes, series_metadata)
+
+ # Process episodes
+ for (season, episode), (aired, dateadded, source) in episode_dates.items():
+ if (season, episode) in disk_episodes:
+ # Get enhanced episode metadata
+ enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None
+
+ # Create NFO
+ if config.manage_nfo:
+ self.nfo_manager.create_episode_nfo(
+ season_path,
+ season, episode, aired, dateadded, source, config.lock_metadata,
+ enhanced_metadata
+ )
+
+ # Update file mtimes
+ if config.fix_dir_mtimes and dateadded:
+ video_files = disk_episodes[(season, episode)]
+ for video_file in video_files:
+ self.nfo_manager.set_file_mtime(video_file, dateadded)
+
+ # Save to database
+ self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
+
+ # Create season NFO
+ if config.manage_nfo:
+ self.nfo_manager.create_season_nfo(season_path, season_num)
+ # Get TVDB ID for better Emby compatibility
+ tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
+ self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
+
+ _log("INFO", f"Completed processing season {season_num}")
+
+ def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None:
+ """Process a single TV episode file"""
+ imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID found in series path: {series_path}")
+ return
+
+ # Parse episode info from filename
+ episode_info = self._parse_episode_from_filename(episode_file.name)
+ if not episode_info:
+ _log("ERROR", f"Could not parse episode info from: {episode_file.name}")
+ return
+
+ season_num, episode_num = episode_info
+ _log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}")
+
+ # Update database
+ self.db.upsert_series(imdb_id, str(series_path))
+
+ # Get enhanced metadata from Sonarr
+ series_metadata = self._get_sonarr_series_metadata(imdb_id)
+ enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None
+
+ # Get episode date
+ aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata)
+
+ # Create NFO
+ if config.manage_nfo and dateadded:
+ self.nfo_manager.create_episode_nfo(
+ season_path,
+ season_num, episode_num, aired, dateadded, source, config.lock_metadata,
+ enhanced_metadata
+ )
+
+ # Update file mtime
+ if config.fix_dir_mtimes and dateadded:
+ self.nfo_manager.set_file_mtime(episode_file, dateadded)
+
+ # Save to database
+ self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
+
+ _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}")
+
+ def _find_season_episodes(self, season_path: Path, season_num: int) -> Dict[Tuple[int, int], List[Path]]:
+ """Find all episode video files in a single season"""
+ disk_episodes = {}
+ video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".m2ts")
+
+ for file_path in season_path.iterdir():
+ if not file_path.is_file() or file_path.suffix.lower() not in video_exts:
+ continue
+
+ episode_info = self._parse_episode_from_filename(file_path.name)
+ if episode_info and episode_info[0] == season_num:
+ season, episode = episode_info
+ key = (season, episode)
+ if key not in disk_episodes:
+ disk_episodes[key] = []
+ disk_episodes[key].append(file_path)
+
+ return disk_episodes
+
+ def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
+ """Parse season and episode numbers from filename"""
+ # Try SxxExx format
+ match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE)
+ if match:
+ return int(match.group(1)), int(match.group(2))
+
+ # Try season.episode format
+ match = re.search(r'(\d{1,2})\.(\d{1,2})', filename)
+ if match:
+ return int(match.group(1)), int(match.group(2))
+
+ return None
+
+ def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]:
+ """Get enhanced series metadata from Sonarr API"""
+ try:
+ if not self.sonarr.enabled:
+ return None
+
+ series = self.sonarr.series_by_imdb(imdb_id)
+ if not series:
+ _log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}")
+ return None
+
+ # Get episodes for the series
+ series_id = series.get("id")
+ if series_id:
+ episodes = self.sonarr.episodes_for_series(series_id)
+ series["episodes"] = episodes
+ _log("DEBUG", f"Got {len(episodes)} episodes from Sonarr for series {series.get('title')}")
+
+ return series
+
+ except Exception as e:
+ _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}")
+ return None
+
+ def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]:
+ """Extract specific episode metadata from series data"""
+ if not series_metadata or "episodes" not in series_metadata:
+ return None
+
+ for episode in series_metadata["episodes"]:
+ if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num:
+ return {
+ "title": episode.get("title"),
+ "overview": episode.get("overview"),
+ "runtime": episode.get("runtime"),
+ "ratings": episode.get("ratings", {})
+ }
+
+ return None
+
+ def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
+ """Get episode dates for a specific season"""
+ episode_dates = {}
+
+ for (season, episode) in disk_episodes.keys():
+ if season != season_num:
+ continue
+
+ aired, dateadded, source = self._get_single_episode_date(imdb_id, season, episode, series_metadata)
+ episode_dates[(season, episode)] = (aired, dateadded, source)
+
+ return episode_dates
+
+ def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
+ """
+ Get date info for a single episode during backfill scans.
+ Priority: 1) Database 2) Sonarr import history 3) Air dates
+ """
+ # Step 1: Try database first
+ existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
+ if existing and existing.get("dateadded"):
+ _log("DEBUG", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}")
+ return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing")
+
+ # Step 2: Try Sonarr import history (the real import date)
+ aired = None
+ import_date = None
+
+ if self.sonarr.enabled:
+ try:
+ series = self.sonarr.series_by_imdb(imdb_id)
+ if series:
+ episodes = self.sonarr.episodes_for_series(series["id"])
+ for ep in episodes:
+ if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num:
+ aired = ep.get("airDateUtc")
+ import_date = self.sonarr.get_episode_import_history(ep["id"])
+ if import_date:
+ # Convert import date to local timezone for NFO files
+ local_import_date = convert_utc_to_local(import_date)
+ _log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {local_import_date}")
+ return aired, local_import_date, "sonarr:history.import"
+ break
+ except Exception as e:
+ _log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}")
+
+ # Step 3: Try Sonarr metadata for air date
+ if not aired and series_metadata and "episodes" in series_metadata:
+ for episode in series_metadata["episodes"]:
+ if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num:
+ aired = episode.get("airDateUtc")
+ break
+
+ # Step 4: Only if we have an air date but no import history, use air date as fallback
+ if aired:
+ _log("WARNING", f"No import history found for S{season_num:02d}E{episode_num:02d}, using air date as fallback")
+ return aired, aired, "sonarr:episode.airDateUtc"
+
+ # Step 5: Last resort - current time in local timezone (shouldn't happen in normal operation)
+ local_tz = _get_local_timezone()
+ current_time = datetime.now(local_tz).isoformat(timespec="seconds")
+ _log("WARNING", f"No data found for S{season_num:02d}E{episode_num:02d}, using current time")
+ return None, current_time, "fallback:current_time"
+
+ def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None:
+ """Process only the specific episodes mentioned in a webhook (targeted mode)"""
+ imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID found in series path: {series_path}")
+ return
+
+ if not webhook_episodes:
+ _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}")
+ self.process_series(series_path)
+ return
+
+ _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})")
+
+ # Update database
+ self.db.upsert_series(imdb_id, str(series_path))
+
+ # Get enhanced metadata from Sonarr
+ series_metadata = self._get_sonarr_series_metadata(imdb_id)
+
+ episodes_processed = 0
+ for webhook_episode in webhook_episodes:
+ season_num = webhook_episode.get("seasonNumber")
+ episode_num = webhook_episode.get("episodeNumber")
+
+ if not season_num or not episode_num:
+ _log("WARNING", f"Invalid episode data in webhook: {webhook_episode}")
+ continue
+
+ # Check if episode file exists on disk
+ season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
+ if not season_dir.exists():
+ _log("WARNING", f"Season directory not found: {season_dir}")
+ continue
+
+ # Find matching episode files
+ episode_files = []
+ for file_path in season_dir.iterdir():
+ if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'):
+ parsed = self._parse_episode_from_filename(file_path.name)
+ if parsed and parsed == (season_num, episode_num):
+ episode_files.append(file_path)
+
+ if not episode_files:
+ _log("WARNING", f"No video files found for S{season_num:02d}E{episode_num:02d}")
+ continue
+
+ # Get episode date information - webhook processing prioritizes existing DB entries
+ _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}")
+ aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata)
+ enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None
+
+ # Create NFO
+ if config.manage_nfo:
+ self.nfo_manager.create_episode_nfo(
+ season_dir,
+ season_num, episode_num, aired, dateadded, source, config.lock_metadata,
+ enhanced_metadata
+ )
+
+ # Update file mtimes
+ if config.fix_dir_mtimes and dateadded:
+ for episode_file in episode_files:
+ self.nfo_manager.set_file_mtime(episode_file, dateadded)
+
+ # Save to database
+ self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
+
+ # Verify database entry was saved (debug)
+ verification = self.db.get_episode_date(imdb_id, season_num, episode_num)
+ if verification:
+ _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}")
+ else:
+ _log("ERROR", f"Failed to save episode to database: S{season_num:02d}E{episode_num:02d}")
+
+ episodes_processed += 1
+
+ # Create season/tvshow NFOs if any episodes were processed
+ if episodes_processed > 0 and config.manage_nfo:
+ seasons_processed = set()
+ for webhook_episode in webhook_episodes:
+ season_num = webhook_episode.get("seasonNumber")
+ if season_num and season_num not in seasons_processed:
+ season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
+ if season_dir.exists():
+ self.nfo_manager.create_season_nfo(season_dir, season_num)
+ seasons_processed.add(season_num)
+
+ # Get TVDB ID for better Emby compatibility
+ tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
+ self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
+
+ _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed")
+
+ def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
+ """
+ Get episode date for webhook processing with correct priority:
+ 1. Check existing NFOGuard database entry (preserve previous import dates)
+ 2. If not found, use current time (webhook = new download)
+ 3. Get aired date from Sonarr/TMDB for reference
+ """
+ # Step 1: Check if we already have this episode in our database
+ _log("DEBUG", f"Checking database for existing episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}")
+ existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
+ if existing:
+ _log("DEBUG", f"Found database entry: {existing}")
+ if existing.get("dateadded"):
+ _log("INFO", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']} (source: {existing.get('source', 'unknown')})")
+ return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing")
+ else:
+ _log("DEBUG", f"Database entry found but no dateadded value: {existing}")
+ else:
+ _log("DEBUG", f"No database entry found for IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}")
+
+ # Step 2: Webhook = Source of Truth - use current timestamp in local timezone
+ local_tz = _get_local_timezone()
+ current_time = datetime.now(local_tz).isoformat(timespec="seconds")
+ _log("INFO", f"Webhook episode processing - using current timestamp as source of truth: {current_time}")
+
+ # Step 3: Get aired date for reference (but don't use for dateadded)
+ aired = None
+
+ # Try Sonarr metadata first for air date
+ if series_metadata and "episodes" in series_metadata:
+ for episode in series_metadata["episodes"]:
+ if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num:
+ aired = episode.get("airDateUtc")
+ if aired:
+ break
+
+ # Fallback to Sonarr API for air date if not in metadata
+ if not aired and self.sonarr.enabled:
+ try:
+ series = self.sonarr.series_by_imdb(imdb_id)
+ if series:
+ episodes = self.sonarr.episodes_for_series(series["id"])
+ for ep in episodes:
+ if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num:
+ aired = ep.get("airDateUtc")
+ break
+ except Exception as e:
+ _log("DEBUG", f"Error getting air date from Sonarr: {e}")
+
+ return aired, current_time, "webhook:first_seen"
+
+
+class MovieProcessor:
+ """Handles movie processing"""
+
+ def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
+ self.db = db
+ self.nfo_manager = nfo_manager
+ self.path_mapper = path_mapper
+ self.radarr = RadarrClient(
+ os.environ.get("RADARR_URL", ""),
+ os.environ.get("RADARR_API_KEY", "")
+ )
+ self.external_clients = ExternalClientManager()
+
+ def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
+ """Find movie directory path"""
+ # Try webhook path first
+ if radarr_path:
+ container_path = self.path_mapper.radarr_path_to_container_path(radarr_path)
+ path_obj = Path(container_path)
+ if path_obj.exists():
+ return path_obj
+
+ # Search by IMDb ID or title
+ for media_path in config.movie_paths:
+ if not media_path.exists():
+ continue
+
+ # Search by IMDb ID
+ if imdb_id:
+ pattern = str(media_path / f"*[imdb-{imdb_id}]*")
+ matches = glob.glob(pattern)
+ if matches:
+ return Path(matches[0])
+
+ # Search by title
+ if movie_title:
+ title_clean = movie_title.lower().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("-", "")
+ if title_clean in item_clean:
+ return item
+
+ return None
+
+ def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None:
+ """Process a movie directory"""
+ imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
+ return
+
+ _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
+
+ # Update database
+ self.db.upsert_movie(imdb_id, str(movie_path))
+
+ # Check for video files
+ video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
+ has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir())
+
+ if not has_video:
+ _log("WARNING", f"No video files found in: {movie_path}")
+ self.db.upsert_movie_dates(imdb_id, None, None, None, False)
+ return
+
+ # Get existing dates
+ existing = self.db.get_movie_dates(imdb_id)
+ _log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
+
+ # Handle webhook mode - prioritize database, then use proper date logic
+ if webhook_mode:
+ _log("DEBUG", f"Webhook mode: existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else 'N/A'}")
+ if existing and existing.get("dateadded"):
+ _log("INFO", f"Webhook processing - using existing database entry: {existing['dateadded']} (source: {existing.get('source', 'unknown')})")
+ dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
+ else:
+ if existing:
+ _log("INFO", f"Webhook processing - database entry exists but no dateadded field: {existing}")
+ else:
+ _log("INFO", f"Webhook processing - no database entry found for {imdb_id}")
+ _log("INFO", f"Using full date decision logic")
+ # Use same logic as manual scan to check Radarr import dates, release dates, etc.
+ should_query = True # Always query for webhooks when no database entry exists
+ dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
+
+ # Only if ALL date sources fail, fall back to current timestamp
+ if dateadded is None:
+ local_tz = _get_local_timezone()
+ current_time = datetime.now(local_tz).isoformat(timespec="seconds")
+ _log("INFO", f"Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}")
+ dateadded, source = current_time, "webhook:fallback_timestamp"
+ else:
+ # Manual scan mode - determine if we should query APIs
+ should_query = (
+ config.movie_poll_mode == "always" or
+ (config.movie_poll_mode == "if_missing" and not existing) or
+ (config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime")
+ )
+
+ # Use existing movie date decision logic
+ dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
+
+ # Create NFO regardless of date availability (preserves existing metadata)
+ if config.manage_nfo:
+ self.nfo_manager.create_movie_nfo(
+ movie_path, imdb_id, dateadded, released, source, config.lock_metadata
+ )
+
+ # Skip remaining processing if no valid date found and file dates disabled
+ if dateadded is None:
+ _log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed")
+ self.db.upsert_movie_dates(imdb_id, released, None, source, True)
+ return
+
+ _log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
+
+ # Update file mtimes (only if we have a valid date)
+ if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
+ self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
+
+ _log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
+
+ # Save to database
+ _log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
+ try:
+ self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
+ _log("DEBUG", f"Database save completed for {imdb_id}")
+ except Exception as e:
+ _log("ERROR", f"Database save failed for {imdb_id}: {e}")
+ raise
+
+ _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
+
+ def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
+ """Decide movie dates based on configuration and available data"""
+ if not should_query and existing:
+ return existing["dateadded"], existing["source"], existing.get("released")
+
+ # Query Radarr for movie info
+ radarr_movie = None
+ if should_query and self.radarr.api_key:
+ radarr_movie = self.radarr.movie_by_imdb(imdb_id)
+
+ released = None
+ if radarr_movie:
+ released = self._parse_date_to_iso(radarr_movie.get("inCinemas"))
+
+ # Try import history first if configured
+ if config.movie_priority == "import_then_digital":
+ import_date, import_source = None, None
+ if radarr_movie:
+ movie_id = radarr_movie.get("id")
+ if movie_id:
+ import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback)
+ _log("INFO", f"Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}")
+
+ # Check for special case: rename-first scenario (should prefer release dates)
+ if import_source == "radarr:db.prefer_release_dates":
+ _log("INFO", f"๐ฏ Movie {imdb_id} has rename-first history - skipping import, preferring release dates")
+ # Fall through to release date logic below
+ # Check if we got a real import date or just file date fallback
+ elif import_date and import_source != "radarr:db.file.dateAdded":
+ # Convert import date to local timezone for NFO files
+ local_import_date = convert_utc_to_local(import_date)
+ _log("INFO", f"โ
Movie {imdb_id}: Using import date {local_import_date} from {import_source}")
+ return local_import_date, import_source, released
+
+ # Get digital release date for comparison/fallback
+ _log("INFO", f"๐ Movie {imdb_id}: Trying digital release date fallback...")
+ digital_date, digital_source = self._get_digital_release_date(imdb_id)
+ _log("INFO", f"Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}")
+
+ # If we only have file date and release date exists, prefer it if reasonable and enabled
+ if import_date and import_source == "radarr:db.file.dateAdded" and digital_date and config.prefer_release_dates_over_file_dates:
+ # Compare dates - prefer release date if it's reasonable
+ if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id):
+ _log("INFO", f"โ
Movie {imdb_id}: Preferring digital release date {digital_date} over file date")
+ return digital_date, digital_source, released
+ else:
+ # Convert file date to local timezone for NFO files
+ local_file_date = convert_utc_to_local(import_date)
+ _log("INFO", f"โ
Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable")
+ return local_file_date, import_source, released
+
+ # Use whichever we have
+ if import_date:
+ # Convert import date to local timezone for NFO files
+ local_import_date = convert_utc_to_local(import_date)
+ _log("INFO", f"โ
Movie {imdb_id}: Using import date {local_import_date} from {import_source}")
+ return local_import_date, import_source, released
+ elif digital_date:
+ _log("INFO", f"โ
Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}")
+ return digital_date, digital_source, released
+ else:
+ _log("WARNING", f"โ ๏ธ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
+
+ # Try Radarr's own NFO premiered date as fallback
+ radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
+ if radarr_premiered:
+ _log("INFO", f"โ
Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
+ return radarr_premiered, "radarr:nfo.premiered", released
+
+ else: # digital_then_import
+ # Try digital release first
+ digital_date, digital_source = self._get_digital_release_date(imdb_id)
+ if digital_date:
+ return digital_date, digital_source, released
+
+ # Fall back to import history
+ if radarr_movie:
+ movie_id = radarr_movie.get("id")
+ if movie_id:
+ import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback)
+ if import_date:
+ # Convert import date to local timezone for NFO files
+ local_import_date = convert_utc_to_local(import_date)
+ return local_import_date, import_source, released
+
+ # Last resort: file mtime (if allowed)
+ if config.allow_file_date_fallback:
+ return self._get_file_mtime_date(movie_path)
+ else:
+ _log("INFO", f"No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation")
+ return None, "no_valid_date_source", None
+
+ def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]:
+ """Get release date from external sources using configured priority"""
+ _log("INFO", f"๐ Calling external clients for {imdb_id}")
+ _log("INFO", f"Release date priority: {config.release_date_priority}")
+ _log("INFO", f"Smart validation enabled: {config.enable_smart_date_validation}")
+
+ try:
+ release_result = self.external_clients.get_release_date_by_priority(
+ imdb_id,
+ config.release_date_priority,
+ enable_smart_validation=config.enable_smart_date_validation
+ )
+ _log("INFO", f"External clients result for {imdb_id}: {release_result}")
+
+ if release_result:
+ _log("INFO", f"โ
Got release date: {release_result[0]} from {release_result[1]}")
+ return release_result[0], release_result[1]
+ else:
+ _log("WARNING", f"โ No release date found from external clients for {imdb_id}")
+ return None, "release:none"
+ except Exception as e:
+ _log("ERROR", f"External clients error for {imdb_id}: {e}")
+ return None, f"release:error:{str(e)}"
+
+ def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]:
+ """Extract premiered date from Radarr's existing movie.nfo file"""
+ try:
+ nfo_path = movie_path / "movie.nfo"
+ if not nfo_path.exists():
+ _log("DEBUG", f"No existing NFO file found at {nfo_path}")
+ return None
+
+ nfo_content = nfo_path.read_text(encoding='utf-8')
+
+ # Look for YYYY-MM-DD
+ import re
+ match = re.search(r'(\d{4}-\d{2}-\d{2})', nfo_content)
+ if match:
+ premiered_date = match.group(1)
+ # Convert to ISO format with timezone
+ iso_date = f"{premiered_date}T00:00:00+00:00"
+ _log("INFO", f"โ
Found Radarr NFO premiered date: {premiered_date}")
+ return iso_date
+ else:
+ _log("DEBUG", f"No tag found in existing NFO")
+ return None
+
+ except Exception as e:
+ _log("ERROR", f"Error reading Radarr NFO file: {e}")
+ return None
+
+ def _get_file_mtime_date(self, movie_path: Path) -> Tuple[str, str, Optional[str]]:
+ """Get date from file modification time as last resort"""
+ video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
+ newest_mtime = None
+
+ for file_path in movie_path.iterdir():
+ if file_path.is_file() and file_path.suffix.lower() in video_exts:
+ try:
+ mtime = file_path.stat().st_mtime
+ if newest_mtime is None or mtime > newest_mtime:
+ newest_mtime = mtime
+ except Exception:
+ continue
+
+ if newest_mtime:
+ try:
+ # Use local timezone for file modification times
+ local_tz = _get_local_timezone()
+ iso_date = datetime.fromtimestamp(newest_mtime, tz=local_tz).isoformat(timespec="seconds")
+ return iso_date, "file:mtime", None
+ except Exception:
+ pass
+
+ return "MANUAL_REVIEW_NEEDED", "manual_review_required", None
+
+ def _should_prefer_release_over_file_date(self, release_date: str, release_source: str, theatrical_release: Optional[str], imdb_id: str) -> bool:
+ """
+ Decide if release date should be preferred over file date
+
+ Logic:
+ - For theatrical dates: Always prefer over file dates (they're authoritative)
+ - For physical dates: Usually prefer over file dates
+ - For digital dates: Prefer if reasonable (not decades before theatrical)
+ """
+ try:
+ release_dt = datetime.fromisoformat(release_date.replace("Z", "+00:00"))
+
+ # Always prefer theatrical and physical releases over file dates
+ if any(release_type in release_source for release_type in ["theatrical", "physical"]):
+ _log("INFO", f"Release date {release_date} ({release_source}) for {imdb_id}, preferring over file date")
+ return True
+
+ # If we have theatrical release date, compare digital against it
+ if theatrical_release:
+ theatrical_dt = datetime.fromisoformat(theatrical_release.replace("Z", "+00:00"))
+ year_diff = release_dt.year - theatrical_dt.year
+
+ # If digital is more than 10 years before theatrical, it's probably wrong
+ if year_diff < -10:
+ _log("INFO", f"Release date {release_date} is {abs(year_diff)} years before theatrical {theatrical_release} for {imdb_id}, using file date instead")
+ return False
+
+ # If digital is within reasonable range (theatrical to +20 years), use it
+ if -2 <= year_diff <= 20:
+ _log("INFO", f"Release date {release_date} is reasonable for {imdb_id} (theatrical: {theatrical_release}), preferring over file date")
+ return True
+
+ # If no theatrical date, use digital if it's not absurdly old
+ if release_dt.year >= 1990: # Reasonable minimum for digital releases
+ _log("INFO", f"Release date {release_date} seems reasonable for {imdb_id}, preferring over file date")
+ return True
+
+ _log("INFO", f"Release date {release_date} seems too old for {imdb_id}, using file date instead")
+ return False
+
+ except Exception as e:
+ _log("WARNING", f"Error comparing dates for {imdb_id}: {e}")
+ return False
+
+ def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
+ """Parse date string to ISO format"""
+ if not date_str:
+ return None
+ try:
+ if len(date_str) == 10 and date_str[4] == "-":
+ dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
+ else:
+ dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
+ return dt.isoformat(timespec="seconds")
+ except Exception:
+ return None
+
+
+# ---------------------------
+# Batching System
+# ---------------------------
+
+class WebhookBatcher:
+ """Batches webhook events to avoid processing storms"""
+
+ def __init__(self):
+ self.pending: 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)
+
+ def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
+ """Add webhook to batch queue"""
+ with self.lock:
+ if key in self.timers:
+ self.timers[key].cancel()
+
+ webhook_data['media_type'] = media_type
+ self.pending[key] = webhook_data
+ _log("INFO", f"Batched {media_type} webhook for {key}")
+ _log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {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):
+ """Process a batched item"""
+ with self.lock:
+ if key in self.processing or key not in self.pending:
+ return
+ self.processing.add(key)
+ webhook_data = self.pending.pop(key)
+ self.timers.pop(key, None)
+
+ try:
+ self.executor.submit(self._process_sync, key, webhook_data)
+ except Exception as e:
+ _log("ERROR", f"Error submitting processing for {key}: {e}")
+ with self.lock:
+ self.processing.discard(key)
+
+ def _process_sync(self, key: str, webhook_data: Dict):
+ """Synchronous processing of webhook data with validation"""
+ try:
+ media_type = webhook_data.get('media_type')
+ path_str = webhook_data.get('path')
+
+ _log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}")
+
+ if not path_str:
+ _log("ERROR", f"No path found for {media_type} {key}")
+ return
+
+ path_obj = Path(path_str)
+ if not path_obj.exists():
+ _log("ERROR", f"BATCH PROCESSING FAILED: Path does not exist: {path_obj}")
+ _log("ERROR", f"This indicates a path mapping issue - webhook rejected to prevent wrong processing")
+ return
+
+ # CRITICAL: Validate that the path contains the expected IMDb ID for movies
+ if media_type == 'movie':
+ expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
+ if expected_imdb not in path_str.lower():
+ _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str}")
+ _log("ERROR", f"This prevents processing wrong movies due to batch corruption")
+ return
+ _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path")
+
+ # Process based on media type
+ if media_type == 'tv':
+ # Check processing mode for TV webhooks
+ processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode)
+ episodes_data = webhook_data.get('episodes', [])
+
+ if processing_mode == 'targeted' and episodes_data:
+ _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes")
+ tv_processor.process_webhook_episodes(path_obj, episodes_data)
+ else:
+ _log("INFO", f"Using series processing mode (fallback or configured)")
+ tv_processor.process_series(path_obj)
+ elif media_type == 'movie':
+ movie_processor.process_movie(path_obj, webhook_mode=True)
+ else:
+ _log("ERROR", f"Unknown media type: {media_type}")
+
+ except Exception as e:
+ _log("ERROR", f"Error processing {media_type} {key}: {e}")
+ finally:
+ with self.lock:
+ self.processing.discard(key)
+
+ def get_status(self) -> Dict:
+ """Get batch queue status"""
+ with self.lock:
+ return {
+ "pending_items": list(self.pending.keys()),
+ "processing_items": list(self.processing),
+ "pending_count": len(self.pending),
+ "processing_count": len(self.processing)
+ }
+
+
+# ---------------------------
+# FastAPI Application
+# ---------------------------
+
+# Get version
+try:
+ version = (Path(__file__).parent / "VERSION").read_text().strip()
+except:
+ version = "0.1.0"
+
+# Check if running from dev branch (detect at runtime)
+try:
+ # Try to read git branch from .git/HEAD
+ git_head_path = Path(__file__).parent / ".git" / "HEAD"
+ if git_head_path.exists():
+ head_content = git_head_path.read_text().strip()
+ if "ref: refs/heads/dev" in head_content:
+ version = f"{version}-dev"
+ elif head_content.startswith("ref: refs/heads/"):
+ # Extract branch name for other branches
+ branch = head_content.split("refs/heads/")[-1]
+ if branch != "main":
+ version = f"{version}-{branch}"
+except Exception:
+ # If git detection fails, that's fine - use base version
+ pass
+
+app = FastAPI(
+ title="NFOGuard",
+ description="Webhook server for preserving media import dates",
+ version=version
+)
+
+start_time = datetime.now(timezone.utc)
+
+# Initialize components
+db = NFOGuardDatabase(config.db_path)
+nfo_manager = NFOManager(config.manager_brand)
+path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper
+tv_processor = TVProcessor(db, nfo_manager, path_mapper)
+movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
+batcher = WebhookBatcher()
+
+# ---------------------------
+# Webhook Handlers
+# ---------------------------
+
+async def _read_payload(request: Request) -> dict:
+ """Read webhook payload from request"""
+ content_type = (request.headers.get("content-type") or "").lower()
+ try:
+ if "application/json" in content_type:
+ 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 {}
+
+@app.post("/webhook/sonarr")
+async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks):
+ """Handle Sonarr webhooks"""
+ try:
+ payload = await _read_payload(request)
+ if not payload:
+ raise HTTPException(status_code=422, detail="Empty Sonarr 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"}
+
+ if not webhook.series:
+ return {"status": "ignored", "reason": "No series data"}
+
+ series_info = webhook.series
+ series_title = series_info.get("title", "")
+ imdb_id = series_info.get("imdbId", "").replace("tt", "").strip()
+ if imdb_id:
+ imdb_id = f"tt{imdb_id}"
+ sonarr_path = series_info.get("path", "")
+
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID for series: {series_title}")
+ return {"status": "error", "reason": "No IMDb ID"}
+
+ # Find series path
+ series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path)
+ if not series_path:
+ _log("ERROR", f"Could not find series directory: {series_title} ({imdb_id})")
+ return {"status": "error", "reason": "Series directory not found"}
+
+ # Add to batch queue with TV-prefixed key to avoid movie conflicts
+ tv_batch_key = f"tv:{imdb_id}"
+ webhook_dict = {
+ 'path': str(series_path),
+ 'series_info': series_info,
+ 'event_type': webhook.eventType,
+ 'episodes': webhook.episodes or [], # Include episode data for targeted processing
+ 'processing_mode': config.tv_webhook_processing_mode
+ }
+ batcher.add_webhook(tv_batch_key, webhook_dict, 'tv')
+
+ return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"}
+
+ except Exception as e:
+ _log("ERROR", f"Sonarr webhook error: {e}")
+ raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
+
+@app.post("/webhook/radarr")
+async def radarr_webhook(request: Request, background_tasks: BackgroundTasks):
+ """Handle Radarr webhooks"""
+ try:
+ payload = await _read_payload(request)
+ _log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
+ _log("DEBUG", f"Full Radarr webhook payload: {payload}")
+
+ # Filter supported event types (same as Sonarr: Download, Upgrade, Rename)
+ event_type = payload.get('eventType', '')
+ if event_type not in ["Download", "Upgrade", "Rename"]:
+ return {"status": "ignored", "reason": f"Event type {event_type} not processed"}
+
+ # Extract movie info
+ movie_data = payload.get("movie", {})
+ if not movie_data:
+ _log("WARNING", "No movie data in Radarr webhook")
+ return {"status": "error", "message": "No movie data"}
+
+ # Get IMDb ID for batching key
+ imdb_id = movie_data.get("imdbId", "").lower()
+ if not imdb_id:
+ _log("WARNING", "No IMDb ID in Radarr webhook movie data")
+ return {"status": "error", "message": "No IMDb ID"}
+
+ # Get movie path and map it
+ movie_path = movie_data.get("folderPath") or movie_data.get("path", "")
+ if not movie_path:
+ _log("ERROR", "No movie path in Radarr webhook")
+ return {"status": "error", "message": "No movie path provided"}
+
+ # Map the path to container path
+ container_path = path_mapper.radarr_path_to_container_path(movie_path)
+ _log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}")
+
+ # CRITICAL: Verify the mapped path actually exists
+ from pathlib import Path
+ if not Path(container_path).exists():
+ _log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
+ _log("ERROR", f"This prevents processing wrong movies due to path mapping issues")
+ return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"}
+
+ # Verify the path contains the expected IMDb ID
+ if imdb_id not in container_path.lower():
+ _log("WARNING", f"IMDb ID {imdb_id} not found in container path {container_path}")
+
+ # Create movie-specific webhook data with proper path validation
+ movie_webhook_data = {
+ 'path': container_path, # Use verified container path
+ 'movie_info': movie_data,
+ 'event_type': payload.get('eventType'),
+ 'original_payload': payload
+ }
+
+ # Add to batch queue with movie-prefixed key to avoid TV conflicts
+ movie_batch_key = f"movie:{imdb_id}"
+ _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}")
+ batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie")
+
+ return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"}
+
+ except Exception as e:
+ _log("ERROR", f"Radarr webhook error: {e}")
+ return {"status": "error", "message": str(e)}
+
+# ---------------------------
+# API Endpoints
+# ---------------------------
+
+@app.get("/health")
+async def health() -> HealthResponse:
+ """Health check endpoint with Radarr database status"""
+ uptime = datetime.now(timezone.utc) - start_time
+
+ # Check NFOGuard database
+ try:
+ with db.get_connection() as conn:
+ conn.execute("SELECT 1").fetchone()
+ db_status = "healthy"
+ except Exception as e:
+ db_status = f"error: {e}"
+
+ # Check Radarr database if available
+ radarr_db_health = None
+ overall_status = "healthy" if db_status == "healthy" else "degraded"
+
+ # Get Radarr client with database access from movie processor
+ try:
+ if hasattr(movie_processor, 'radarr') and movie_processor.radarr:
+ radarr_client = movie_processor.radarr
+ if hasattr(radarr_client, 'db_client') and radarr_client.db_client:
+ try:
+ radarr_db_health = radarr_client.db_client.health_check()
+ if radarr_db_health["status"] != "healthy":
+ overall_status = "degraded"
+ except Exception as e:
+ radarr_db_health = {
+ "status": "error",
+ "error": str(e),
+ "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
+ }
+ overall_status = "degraded"
+ except Exception as e:
+ # If movie processor isn't available, skip database health check
+ _log("DEBUG", f"Skipping Radarr database health check: {e}")
+
+ return HealthResponse(
+ status=overall_status,
+ version=version,
+ uptime=str(uptime),
+ database_status=db_status,
+ radarr_database=radarr_db_health
+ )
+
+@app.get("/stats")
+async def get_stats():
+ """Get database statistics"""
+ try:
+ return db.get_stats()
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/batch/status")
+async def batch_status():
+ """Get batch queue 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 (os.environ.get("RADARR_URL") and os.environ.get("RADARR_API_KEY")):
+ return {
+ "error": "Radarr not configured",
+ "imdb_id": imdb_id,
+ "radarr_configured": False
+ }
+
+ # Create Radarr client
+ from clients.radarr_client import RadarrClient
+ radarr_client = RadarrClient(
+ os.environ.get("RADARR_URL"),
+ os.environ.get("RADARR_API_KEY")
+ )
+
+ # Look up movie
+ movie_obj = radarr_client.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})")
+
+ # Test the FULL movie processing pipeline (not just database lookup)
+ _log("INFO", f"=== TESTING FULL MOVIE PROCESSING PIPELINE ===")
+
+ # Create a dummy path for testing the decision logic
+ from pathlib import Path
+ dummy_path = Path("/tmp/test")
+
+ try:
+ # Use the global movie processor instance to test full decision logic
+ global movie_processor
+ if movie_processor:
+ # First check external clients configuration
+ _log("INFO", f"=== CHECKING EXTERNAL CLIENTS CONFIG ===")
+ try:
+ tmdb_key = os.environ.get("TMDB_API_KEY", "")
+ _log("INFO", f"TMDB API Key configured: {'โ
YES' if tmdb_key else 'โ NO'}")
+ if tmdb_key:
+ _log("INFO", f"TMDB API Key length: {len(tmdb_key)} chars")
+
+ # Check if external clients exist
+ external_clients_available = hasattr(movie_processor, 'external_clients') and movie_processor.external_clients
+ _log("INFO", f"External clients initialized: {'โ
YES' if external_clients_available else 'โ NO'}")
+
+ except Exception as e:
+ _log("ERROR", f"Error checking external clients config: {e}")
+
+ # Test the full decision logic (including TMDB fallback)
+ final_date, final_source, released = movie_processor._decide_movie_dates(
+ imdb_id, dummy_path, should_query=True, existing=None
+ )
+
+ _log("INFO", f"=== FULL PIPELINE RESULT ===")
+ _log("INFO", f"Final date: {final_date}")
+ _log("INFO", f"Final source: {final_source}")
+ _log("INFO", f"Released (theater): {released}")
+
+ return {
+ "imdb_id": imdb_id,
+ "radarr_configured": True,
+ "movie_found": True,
+ "movie_title": movie_title,
+ "movie_id": movie_id,
+ "full_pipeline_test": {
+ "final_date": final_date,
+ "final_source": final_source,
+ "theater_release": released,
+ "decision_logic": "โ
TESTED FULL PIPELINE INCLUDING TMDB FALLBACK"
+ },
+ "database_only_test": {
+ "radarr_db_result": radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True),
+ "note": "This is just the database part - fallback happens in full pipeline"
+ },
+ "debug_info": {
+ "radarr_url": os.environ.get("RADARR_URL"),
+ "movie_digital_release": movie_obj.get("digitalRelease"),
+ "movie_in_cinemas": movie_obj.get("inCinemas"),
+ "movie_physical_release": movie_obj.get("physicalRelease"),
+ "movie_folder_path": movie_obj.get("folderPath")
+ }
+ }
+ else:
+ _log("ERROR", "Movie processor not available - testing database only")
+ # Fallback to database-only testing
+ import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True)
+ return {
+ "error": "Movie processor not available - only database test performed",
+ "imdb_id": imdb_id,
+ "radarr_configured": True,
+ "movie_found": True,
+ "movie_title": movie_title,
+ "movie_id": movie_id,
+ "detected_import_date": import_date,
+ "import_source": source,
+ "debug_info": {
+ "note": "FULL PIPELINE TEST FAILED - movie processor not initialized"
+ }
+ }
+
+ except Exception as pipeline_error:
+ _log("ERROR", f"Full pipeline test failed: {pipeline_error}")
+ # Fallback to database-only testing
+ import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True)
+ return {
+ "pipeline_error": str(pipeline_error),
+ "imdb_id": imdb_id,
+ "radarr_configured": True,
+ "movie_found": True,
+ "movie_title": movie_title,
+ "movie_id": movie_id,
+ "detected_import_date": import_date,
+ "import_source": source,
+ "debug_info": {
+ "note": "FULL PIPELINE TEST FAILED - showing database-only result"
+ }
+ }
+
+ except Exception as e:
+ _log("ERROR", f"Debug endpoint error for {imdb_id}: {e}")
+ return {
+ "error": str(e),
+ "imdb_id": imdb_id,
+ "success": False
+ }
+
+@app.get("/debug/movie/{imdb_id}/history")
+async def debug_movie_history(imdb_id: str):
+ """Detailed history analysis for a movie"""
+ try:
+ if not imdb_id.startswith("tt"):
+ imdb_id = f"tt{imdb_id}"
+
+ _log("INFO", f"=== DETAILED HISTORY ANALYSIS: {imdb_id} ===")
+
+ # Use database-only mode for consistency
+ if not movie_processor.radarr.db_client:
+ return {"error": "Radarr database not configured"}
+
+ radarr_client = movie_processor.radarr
+
+ # Look up movie
+ movie_obj = radarr_client.movie_by_imdb(imdb_id)
+ if not movie_obj:
+ return {"error": f"Movie not found for {imdb_id}"}
+
+ movie_id = movie_obj.get("id")
+ movie_title = movie_obj.get("title")
+
+ # Get history from database instead of API
+ if not radarr_client.db_client:
+ return {"error": "Database-only mode required"}
+
+ # TODO: Implement database-only history retrieval
+ return {
+ "error": "History endpoint temporarily disabled - use /debug/movie/{imdb_id}/priority for date analysis",
+ "imdb_id": imdb_id,
+ "movie_id": movie_id,
+ "movie_title": movie_title,
+ "note": "This endpoint needs database-only implementation to avoid showing wrong movie events"
+ }
+
+ # Analyze each event
+ analyzed_events = []
+ for event in all_history:
+ event_type = event.get("eventType", "")
+ date_str = event.get("date", "")
+ event_data = event.get("data", {})
+
+ # Get source path info
+ source_path = (
+ event_data.get('droppedPath', '') or
+ event_data.get('sourcePath', '') or
+ event_data.get('path', '') or
+ event_data.get('sourceTitle', '')
+ )
+
+ # Analyze if this is a real import
+ is_real, reason, date_iso = radarr_client._analyze_event_for_import(event)
+
+ analyzed_events.append({
+ "event_type": event_type,
+ "date": date_str,
+ "source_path": source_path,
+ "is_real_import": is_real,
+ "analysis_reason": reason,
+ "parsed_date": date_iso,
+ "full_data": event_data
+ })
+
+ # Find what our algorithm would pick - DATABASE ONLY
+ picked_date, _ = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True)
+
+ return {
+ "imdb_id": imdb_id,
+ "movie_title": movie_title,
+ "movie_id": movie_id,
+ "total_history_events": len(all_history),
+ "our_algorithm_picked": picked_date,
+ "all_events": analyzed_events,
+ "expected_july_date": "2025-07-07" in (picked_date or "")
+ }
+
+ except Exception as e:
+ _log("ERROR", f"History debug error for {imdb_id}: {e}")
+ return {"error": str(e)}
+
+@app.post("/manual/scan")
+async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"):
+ """Manual scan endpoint"""
+ if scan_type not in ["both", "tv", "movies"]:
+ raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'")
+
+ async def run_scan():
+ paths_to_scan = []
+ if path:
+ paths_to_scan = [Path(path)]
+ else:
+ if scan_type in ["both", "tv"]:
+ paths_to_scan.extend(config.tv_paths)
+ if scan_type in ["both", "movies"]:
+ paths_to_scan.extend(config.movie_paths)
+
+ for scan_path in paths_to_scan:
+ if not scan_path.exists():
+ continue
+
+ if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path):
+ # Handle specific season/episode path
+ if path and scan_path.name.lower().startswith('season'):
+ # Single season processing
+ series_path = scan_path.parent
+ if nfo_manager.parse_imdb_from_path(series_path):
+ _log("INFO", f"Processing single season: {scan_path}")
+ try:
+ tv_processor.process_season(series_path, scan_path)
+ except Exception as e:
+ _log("ERROR", f"Failed processing season {scan_path}: {e}")
+ elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'):
+ # Single episode processing
+ season_path = scan_path.parent
+ series_path = season_path.parent
+ if nfo_manager.parse_imdb_from_path(series_path):
+ _log("INFO", f"Processing single episode: {scan_path}")
+ try:
+ tv_processor.process_episode_file(series_path, season_path, scan_path)
+ except Exception as e:
+ _log("ERROR", f"Failed processing episode {scan_path}: {e}")
+ else:
+ # Full series processing
+ for item in scan_path.iterdir():
+ if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
+ try:
+ tv_processor.process_series(item)
+ except Exception as e:
+ _log("ERROR", f"Failed processing TV series {item}: {e}")
+
+ if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
+ _log("INFO", f"Scanning movies in: {scan_path}")
+ movie_count = 0
+ for item in scan_path.iterdir():
+ if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
+ movie_count += 1
+ _log("INFO", f"Processing movie: {item.name}")
+ try:
+ movie_processor.process_movie(item)
+ except Exception as e:
+ _log("ERROR", f"Failed processing movie {item}: {e}")
+ _log("INFO", f"Completed movie scan: {movie_count} movies processed in {scan_path}")
+
+ background_tasks.add_task(run_scan)
+ return {"status": "started", "message": f"Manual {scan_type} scan started"}
+
+@app.post("/tv/scan-season")
+async def scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest):
+ """Scan a specific TV season - URL-safe endpoint"""
+ try:
+ series_dir = Path(request.series_path)
+ season_dir = series_dir / request.season_name
+
+ if not series_dir.exists():
+ raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}")
+ if not season_dir.exists():
+ raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}")
+
+ imdb_id = nfo_manager.parse_imdb_from_path(series_dir)
+ if not imdb_id:
+ raise HTTPException(status_code=400, detail="No IMDb ID found in series path")
+
+ async def process_season():
+ _log("INFO", f"Processing TV season: {season_dir}")
+ try:
+ tv_processor.process_season(series_dir, season_dir)
+ except Exception as e:
+ _log("ERROR", f"Failed processing season {season_dir}: {e}")
+
+ background_tasks.add_task(process_season)
+ return {"status": "started", "message": f"Season scan started for {request.season_name}"}
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/tv/scan-episode")
+async def scan_tv_episode(background_tasks: BackgroundTasks, request: TVEpisodeRequest):
+ """Scan a specific TV episode - URL-safe endpoint"""
+ try:
+ series_dir = Path(request.series_path)
+ season_dir = series_dir / request.season_name
+ episode_file = season_dir / request.episode_name
+
+ if not series_dir.exists():
+ raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}")
+ if not episode_file.exists():
+ raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}")
+
+ imdb_id = nfo_manager.parse_imdb_from_path(series_dir)
+ if not imdb_id:
+ raise HTTPException(status_code=400, detail="No IMDb ID found in series path")
+
+ async def process_episode():
+ _log("INFO", f"Processing TV episode: {episode_file}")
+ try:
+ tv_processor.process_episode_file(series_dir, season_dir, episode_file)
+ except Exception as e:
+ _log("ERROR", f"Failed processing episode {episode_file}: {e}")
+
+ background_tasks.add_task(process_episode)
+ return {"status": "started", "message": f"Episode scan started for {request.episode_name}"}
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/test/bulk-update")
+async def test_bulk_update():
+ """Test bulk update functionality without modifying data"""
+ try:
+ from clients.radarr_db_client import RadarrDbClient
+
+ # Test Radarr database
+ radarr_db = RadarrDbClient.from_env()
+ if not radarr_db:
+ return {"status": "error", "message": "Radarr database connection failed"}
+
+ # Test query execution
+ query = 'SELECT COUNT(*) FROM "Movies" m JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" WHERE mm."ImdbId" IS NOT NULL'
+ with radarr_db._get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(query)
+ movie_count = cursor.fetchone()[0]
+
+ return {
+ "status": "success",
+ "message": "Bulk update test passed",
+ "movies_with_imdb": movie_count,
+ "database_type": radarr_db.db_type
+ }
+ except Exception as e:
+ return {"status": "error", "message": f"Bulk update test failed: {e}"}
+
+@app.post("/test/movie-scan")
+async def test_movie_scan():
+ """Test movie directory scanning logic"""
+ try:
+ results = []
+ for path in config.movie_paths:
+ path_result = {
+ "path": str(path),
+ "exists": path.exists(),
+ "movies_found": 0
+ }
+
+ if path.exists():
+ for item in path.iterdir():
+ if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
+ path_result["movies_found"] += 1
+
+ results.append(path_result)
+
+ total_movies = sum(r["movies_found"] for r in results)
+ return {
+ "status": "success",
+ "message": f"Movie scan test found {total_movies} movies",
+ "path_results": results
+ }
+ except Exception as e:
+ return {"status": "error", "message": f"Movie scan test failed: {e}"}
+
+@app.post("/bulk/update")
+async def trigger_bulk_update(background_tasks: BackgroundTasks):
+ """Trigger bulk update of all movies"""
+ async def run_bulk_update():
+ try:
+ from bulk_update_movies import bulk_update_all_movies
+ success = bulk_update_all_movies()
+ _log("INFO", f"Bulk update completed: {'success' if success else 'failed'}")
+ except Exception as e:
+ _log("ERROR", f"Bulk update error: {e}")
+
+ background_tasks.add_task(run_bulk_update)
+ return {"status": "started", "message": "Bulk update started"}
+
+@app.get("/debug/movie/{imdb_id}/priority")
+async def debug_movie_priority_logic(imdb_id: str):
+ """Debug endpoint showing how MOVIE_PRIORITY affects date selection"""
+ try:
+ if not imdb_id.startswith("tt"):
+ imdb_id = f"tt{imdb_id}"
+
+ result = {
+ "imdb_id": imdb_id,
+ "movie_priority": config.movie_priority,
+ "release_date_priority": config.release_date_priority,
+ "priority_explanation": "",
+ "date_sources": {},
+ "selected_date": None,
+ "selected_source": None
+ }
+
+ # Get Radarr import date
+ if movie_processor.radarr.api_key:
+ radarr_movie = movie_processor.radarr.movie_by_imdb(imdb_id)
+ if radarr_movie:
+ movie_id = radarr_movie.get("id")
+ if movie_id:
+ import_date, import_source = movie_processor.radarr.get_movie_import_date(movie_id)
+ if import_date:
+ result["date_sources"]["radarr_import"] = {
+ "date": import_date,
+ "source": import_source
+ }
+
+ # Get digital release dates with detailed logging
+ digital_date, digital_source = movie_processor._get_digital_release_date(imdb_id)
+ if digital_date:
+ result["date_sources"]["digital_release"] = {
+ "date": digital_date,
+ "source": digital_source
+ }
+ else:
+ # Add debug info about why digital date wasn't found
+ candidates = movie_processor.external_clients.get_digital_release_candidates(imdb_id)
+ result["date_sources"]["digital_release_debug"] = {
+ "candidates_found": len(candidates),
+ "candidates": candidates[:3] if candidates else [], # Show first 3
+ "reason": digital_source if digital_source else "no_digital_dates_found"
+ }
+
+ # Show priority logic
+ if config.movie_priority == "import_then_digital":
+ priority_list = " โ ".join(config.release_date_priority)
+ result["priority_explanation"] = f"1st: Radarr import history, 2nd: Release dates ({priority_list}), 3rd: file mtime. Note: If import is only file date, prefer reasonable release dates."
+
+ radarr_import = result["date_sources"].get("radarr_import")
+ digital_release = result["date_sources"].get("digital_release")
+
+ # Check for file date fallback logic
+ if radarr_import and radarr_import["source"] == "radarr:db.file.dateAdded" and digital_release:
+ # Test the smart logic
+ would_prefer_digital = movie_processor._should_prefer_release_over_file_date(
+ digital_release["date"],
+ digital_release["source"],
+ None, # We don't have theatrical date in this debug context
+ imdb_id
+ )
+ result["file_date_detected"] = True
+ result["would_prefer_digital"] = would_prefer_digital
+
+ if would_prefer_digital:
+ result["selected_date"] = digital_release["date"]
+ result["selected_source"] = digital_release["source"] + " (preferred over file date)"
+ else:
+ result["selected_date"] = radarr_import["date"]
+ result["selected_source"] = radarr_import["source"] + " (digital too old)"
+ elif radarr_import and radarr_import["source"] != "radarr:db.file.dateAdded":
+ result["selected_date"] = radarr_import["date"]
+ result["selected_source"] = radarr_import["source"]
+ elif digital_release:
+ result["selected_date"] = digital_release["date"]
+ result["selected_source"] = digital_release["source"]
+ else: # digital_then_import
+ result["priority_explanation"] = "1st: TMDB/OMDb digital release, 2nd: Radarr import history, 3rd: file mtime"
+ if result["date_sources"].get("digital_release"):
+ result["selected_date"] = result["date_sources"]["digital_release"]["date"]
+ result["selected_source"] = result["date_sources"]["digital_release"]["source"]
+ elif result["date_sources"].get("radarr_import"):
+ result["selected_date"] = result["date_sources"]["radarr_import"]["date"]
+ result["selected_source"] = result["date_sources"]["radarr_import"]["source"]
+
+ # Show external API status
+ result["external_apis"] = {
+ "tmdb_enabled": movie_processor.external_clients.tmdb.enabled,
+ "omdb_enabled": movie_processor.external_clients.omdb.enabled,
+ "jellyseerr_enabled": movie_processor.external_clients.jellyseerr.enabled
+ }
+
+ return result
+
+ except Exception as e:
+ return {"error": str(e), "imdb_id": imdb_id}
+
+@app.get("/debug/tmdb/{imdb_id}")
+async def debug_tmdb_lookup(imdb_id: str):
+ """Debug TMDB API lookup for a specific movie"""
+ try:
+ if not imdb_id.startswith("tt"):
+ imdb_id = f"tt{imdb_id}"
+
+ result = {
+ "imdb_id": imdb_id,
+ "tmdb_api_enabled": movie_processor.external_clients.tmdb.enabled,
+ "tmdb_api_key_configured": bool(movie_processor.external_clients.tmdb.api_key),
+ "steps": {}
+ }
+
+ if not movie_processor.external_clients.tmdb.enabled:
+ result["error"] = "TMDB API not enabled - check TMDB_API_KEY environment variable"
+ return result
+
+ # Step 1: Find movie by IMDb ID
+ _log("INFO", f"TMDB Debug: Looking up {imdb_id}")
+ tmdb_movie = movie_processor.external_clients.tmdb.find_by_imdb(imdb_id)
+ result["steps"]["1_find_by_imdb"] = {
+ "found": bool(tmdb_movie),
+ "tmdb_movie": tmdb_movie if tmdb_movie else None
+ }
+
+ if not tmdb_movie:
+ result["error"] = f"Movie {imdb_id} not found in TMDB"
+ return result
+
+ tmdb_id = tmdb_movie.get("id")
+ result["tmdb_id"] = tmdb_id
+
+ # Step 2: Get release dates
+ if tmdb_id:
+ _log("INFO", f"TMDB Debug: Getting release dates for TMDB ID {tmdb_id}")
+ release_dates_result = movie_processor.external_clients.tmdb._get(f"/movie/{tmdb_id}/release_dates")
+ result["steps"]["2_release_dates"] = {
+ "raw_response": release_dates_result,
+ "has_results": bool(release_dates_result and release_dates_result.get("results"))
+ }
+
+ # Step 3: Look for US digital releases
+ if release_dates_result and release_dates_result.get("results"):
+ us_releases = []
+ for country_data in release_dates_result["results"]:
+ if country_data.get("iso_3166_1") == "US":
+ us_releases = country_data.get("release_dates", [])
+ break
+
+ result["steps"]["3_us_releases"] = {
+ "found_us_data": bool(us_releases),
+ "us_releases": us_releases
+ }
+
+ # Step 4: Look for digital releases (type 4)
+ digital_releases = [r for r in us_releases if r.get("type") == 4]
+ result["steps"]["4_digital_releases"] = {
+ "digital_count": len(digital_releases),
+ "digital_releases": digital_releases
+ }
+
+ # Step 5: Test the full digital release function
+ digital_date = movie_processor.external_clients.tmdb.get_digital_release_date(imdb_id)
+ result["steps"]["5_final_result"] = {
+ "digital_date": digital_date,
+ "success": bool(digital_date)
+ }
+
+ return result
+
+ except Exception as e:
+ return {"error": str(e), "imdb_id": imdb_id, "traceback": str(e)}
+
+# ---------------------------
+# Main
+# ---------------------------
+
+if __name__ == "__main__":
+ _log("INFO", "Starting NFOGuard")
+ _log("INFO", f"Version: {version}")
+ _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: {config.db_path}")
+ _log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}")
+ _log("INFO", f"Movie priority: {config.movie_priority}")
+
+ uvicorn.run(
+ app,
+ host="0.0.0.0",
+ port=int(os.environ.get("PORT", "8080")),
+ reload=False
+ )
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..043f2eb
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+pydantic==2.5.0
+psycopg2-binary==2.9.7
+requests==2.31.0
+python-multipart==0.0.6
+aiofiles==23.2.1
+python-dotenv==1.0.0