From 2c13fd778a77b9da241efad7d1607315ae852067 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:39:22 -0400
Subject: [PATCH 01/22] Initial commit
---
LICENSE | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 LICENSE
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ffbfa9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 sbcrumb
+
+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.
From b7d695ab621c3e65da6fd6840e2523b74cd60943 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:41:27 -0400
Subject: [PATCH 02/22] Create README.md for NFOGuard project
---
README.md | 223 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 223 insertions(+)
create mode 100644 README.md
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2435254
--- /dev/null
+++ b/README.md
@@ -0,0 +1,223 @@
+# NFOGuard
+
+**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
+
+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/your-username/NFOguard/main/.env.template
+wget https://raw.githubusercontent.com/your-username/NFOguard/main/.env.secrets.template
+wget https://raw.githubusercontent.com/your-username/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
+
+## 📁 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
+
+## 🆘 Support
+
+- **Issues**: [GitHub Issues](https://github.com/your-username/NFOguard/issues)
+- **Documentation**: See `SETUP_GUIDE.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! 🎯
From cd31caf313462e017b43ccb9090282fd3aac8d0b Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:43:10 -0400
Subject: [PATCH 03/22] Update download links in README.md
---
README.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 2435254..9d87320 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,9 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d
### 1. Download Configuration Files
```bash
-wget https://raw.githubusercontent.com/your-username/NFOguard/main/.env.template
-wget https://raw.githubusercontent.com/your-username/NFOguard/main/.env.secrets.template
-wget https://raw.githubusercontent.com/your-username/NFOguard/main/docker-compose.example.yml
+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
@@ -72,8 +72,8 @@ curl http://localhost:8080/health
**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
+MOVIE_PATHS=/media/Movies/movies
+TV_PATHS=/media/TV/tv
# *arr application paths (what your apps see)
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies
From 0e9b117ff4b8ecaa2d240cf5201aa98fd952845d Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:47:30 -0400
Subject: [PATCH 04/22] Revise LICENSE with new copyright and terms
---
LICENSE | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/LICENSE b/LICENSE
index 0ffbfa9..eab0617 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 sbcrumb
+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
@@ -19,3 +19,18 @@ 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.
From fab3da938f78aa56dde3d97ba31fbb0252d2ce45 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:49:53 -0400
Subject: [PATCH 05/22] Update README.md
---
README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 46 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 9d87320..b2654ba 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# 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**
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.
@@ -72,8 +76,8 @@ curl http://localhost:8080/health
**Media Paths** (Required):
```bash
# Container paths (what NFOGuard sees)
-MOVIE_PATHS=/media/Movies/movies
-TV_PATHS=/media/TV/tv
+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
@@ -117,6 +121,45 @@ Configure these webhook URLs in your applications:
**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:
@@ -208,7 +251,7 @@ curl http://localhost:8080/health
## 🆘 Support
-- **Issues**: [GitHub Issues](https://github.com/your-username/NFOguard/issues)
+- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
- **Documentation**: See `SETUP_GUIDE.md` for detailed instructions
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
From faa320d2996039b545c4c4e5b063852c5a0d8c32 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:56:51 -0400
Subject: [PATCH 06/22] Clarify Radarr database connection requirements
Updated Radarr database connection comments for clarity and requirements.
---
.env.template | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 155 insertions(+)
create mode 100644 .env.template
diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..dd8abce
--- /dev/null
+++ b/.env.template
@@ -0,0 +1,155 @@
+# ===========================================
+# NFOGuard Configuration Template
+# ===========================================
+# Copy this file to .env and customize for your setup
+# Sensitive data (API keys, passwords) go in .env.secrets
+
+# ===========================================
+# MEDIA PATHS (REQUIRED) - CONFIGURE THESE FIRST
+# ===========================================
+# Container paths (what NFOGuard sees inside the Docker container)
+MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
+TV_PATHS=/media/TV/tv,/media/TV/tv6
+
+# Radarr paths (what Radarr sees on your host system)
+RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
+
+# Sonarr paths (what Sonarr sees on your host system)
+SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
+
+# Download detection paths (for identifying downloads vs existing files)
+DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
+
+# ===========================================
+# DATABASE CONFIGURATION
+# ===========================================
+# NFOGuard SQLite database location
+DB_PATH=/app/data/media_dates.db
+
+# Log file directory
+LOG_DIR=/app/data/logs
+
+# ===========================================
+# RADARR DATABASE CONNECTION (REQUIRED)
+# ===========================================
+# NFOGuard requires direct access to Radarr's database for movie processing
+# Radarr does not provide API access for the data NFOGuard needs
+
+# Database type - postgresql or sqlite
+RADARR_DB_TYPE=postgresql
+
+# PostgreSQL settings (most common for modern Radarr installations)
+RADARR_DB_HOST=192.168.1.100
+RADARR_DB_PORT=5432
+RADARR_DB_NAME=radarr-main
+RADARR_DB_USER=postgres
+# Password goes in .env.secrets as RADARR_DB_PASSWORD
+
+# SQLite settings (for older Radarr installations using SQLite)
+# Uncomment and set the path to your radarr.db file if using SQLite:
+# RADARR_DB_TYPE=sqlite
+# RADARR_DB_PATH=/path/to/radarr.db
+# For Docker: Mount Radarr's AppData directory and point to the .db file
+# Common paths:
+# - Docker: /config/radarr.db (if Radarr config is mounted)
+# - Windows: C:\ProgramData\Radarr\radarr.db
+# - Linux: ~/.config/Radarr/radarr.db
+
+# ===========================================
+# API CONNECTIONS (OPTIONAL)
+# ===========================================
+# API keys are stored in .env.secrets for security
+RADARR_URL=http://radarr:7878
+SONARR_URL=http://sonarr:8989
+JELLYSEERR_URL=http://jellyseerr:5055
+
+# ===========================================
+# RELEASE DATE PROCESSING
+# ===========================================
+# Priority order for release date fallbacks
+RELEASE_DATE_PRIORITY=digital,physical,theatrical
+# Alternative: RELEASE_DATE_PRIORITY=digital,theatrical,physical
+
+# Enable smart date validation
+ENABLE_SMART_DATE_VALIDATION=true
+
+# Maximum years between release dates (prevents invalid dates)
+MAX_RELEASE_DATE_GAP_YEARS=10
+
+# Prefer API release dates over file modification dates for manual imports
+PREFER_RELEASE_DATES_OVER_FILE_DATES=true
+
+# Disable file date fallback completely (recommended for clean imports)
+ALLOW_FILE_DATE_FALLBACK=false
+
+# TMDB country for regional release date preferences
+TMDB_COUNTRY=US
+
+# ===========================================
+# NFO FILE MANAGEMENT
+# ===========================================
+# Create/update .nfo files
+MANAGE_NFO=true
+
+# Update file modification times to match import dates
+FIX_DIR_MTIMES=true
+
+# Add lockdata tags to prevent metadata overwrites
+LOCK_METADATA=true
+
+# Brand name in NFO comments
+MANAGER_BRAND=NFOGuard
+
+# ===========================================
+# PROCESSING BEHAVIOR
+# ===========================================
+# Movie date update strategy (overwrite, preserve, smart)
+MOVIE_DATE_UPDATE_MODE=overwrite
+
+# Preserve existing dates when found
+MOVIE_PRESERVE_EXISTING=false
+
+# When to update existing dates (always, missing_only, never)
+UPDATE_MODE=always
+
+# File modification time behavior (update, leave_alone)
+MTIME_BEHAVIOR=update
+
+# ===========================================
+# PERFORMANCE & BATCHING
+# ===========================================
+# Delay before processing batched events (seconds)
+BATCH_DELAY=5.0
+
+# Maximum concurrent series processing
+MAX_CONCURRENT_SERIES=3
+
+# API timeout in seconds
+TIMEOUT_SECONDS=45
+
+# ===========================================
+# DEBUGGING & LOGGING
+# ===========================================
+# Enable verbose logging (true/false)
+DEBUG=false
+
+# Enable path mapping debug output (true/false)
+PATH_DEBUG=false
+
+# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
+SUPPRESS_TVDB_WARNINGS=true
+
+# ===========================================
+# SERVER CONFIGURATION
+# ===========================================
+# Port for webhook server
+PORT=8080
+
+# ===========================================
+# EXAMPLE CONFIGURATION NOTES
+# ===========================================
+# 1. Copy this file to .env
+# 2. Update MOVIE_PATHS and TV_PATHS to match your container mount points
+# 3. Update RADARR_ROOT_FOLDERS and SONARR_ROOT_FOLDERS to match your *arr apps' paths
+# 4. Copy .env.secrets.template to .env.secrets and add your API keys/passwords
+# 5. Adjust other settings as needed for your setup
From aae36ac1455705fec73025fbb43d2eb20a3bcb29 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 16:58:55 -0400
Subject: [PATCH 07/22] Add .env.secrets template for configuration
---
.env.secrect.template | 43 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 .env.secrect.template
diff --git a/.env.secrect.template b/.env.secrect.template
new file mode 100644
index 0000000..08601cd
--- /dev/null
+++ b/.env.secrect.template
@@ -0,0 +1,43 @@
+# ===========================================
+# NFOGuard Secrets Template
+# ===========================================
+# Copy this file to .env.secrets and add your actual credentials
+# This file contains sensitive data - do NOT commit to version control
+# Add .env.secrets to your .gitignore file
+
+# ===========================================
+# RADARR DATABASE PASSWORD (if using direct DB access)
+# ===========================================
+RADARR_DB_PASSWORD=your_postgres_password_here
+
+# Sonarr API key (found in Sonarr Settings > General > Security)
+SONARR_API_KEY=your_sonarr_api_key_here
+
+# ===========================================
+# EXTERNAL API KEYS (OPTIONAL - for metadata enhancement)
+# ===========================================
+# TMDB API key (for enhanced movie metadata)
+# Get from: https://www.themoviedb.org/settings/api
+TMDB_API_KEY=your_tmdb_api_key_here
+
+# TVDB API key (for enhanced TV metadata)
+# Get from: https://thetvdb.com/api-information
+TVDB_API_KEY=your_tvdb_api_key_here
+
+# ===========================================
+# SECURITY NOTES
+# ===========================================
+# - This file should have restricted permissions (600)
+# - Never commit this file to version control
+# - Ensure .env.secrets is in your .gitignore
+# - Rotate API keys periodically for security
+# - Only provide keys for services you want to integrate with
+
+# ===========================================
+# SETUP INSTRUCTIONS
+# ===========================================
+# 1. Copy this file to .env.secrets
+# 2. Replace placeholder values with your actual credentials
+# 3. Set file permissions: chmod 600 .env.secrets
+# 4. Verify .env.secrets is in .gitignore
+# 5. Only fill in the keys for services you're using - others can be left empty
From f272baf2a4d292db2a375181307a3f788c14de33 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 17:01:12 -0400
Subject: [PATCH 08/22] Add sample Docker Compose configuration for NFOGuard
This file contains a sample Docker Compose configuration for NFOGuard, including service definitions, environment variables, volume mappings, health checks, and setup instructions.
---
docker-compose.example | 162 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 docker-compose.example
diff --git a/docker-compose.example b/docker-compose.example
new file mode 100644
index 0000000..89052bf
--- /dev/null
+++ b/docker-compose.example
@@ -0,0 +1,162 @@
+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
+
+ # 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
From 44fdc1cdf047ca3ddc922c83b590b836b4846854 Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 17:03:46 -0400
Subject: [PATCH 09/22] Update README with alpha notice and Discord link
Added alpha software notice and Discord plugin information to README.
---
README.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/README.md b/README.md
index b2654ba..3bd39e8 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,20 @@
[](https://hub.docker.com/r/sbcrumb/nfoguard)
[](https://hub.docker.com/r/sbcrumb/nfoguard)
+---
+
+> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
+>
+> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
+>
+> **🔌 Emby Plugin Available**: To take full advantage of NFOGuard's capabilities, join our Discord server to get access to the companion Emby plugin:
+>
+> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
+>
+> *If the Discord link has expired, please [open an issue](https://github.com/your-username/NFOguard/issues) and we'll provide an updated link.*
+
+---
+
**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
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.
From 86235142a003dc9adba402ac54d50735b5e27c4b Mon Sep 17 00:00:00 2001
From: sbcrumb <5639759+sbcrumb@users.noreply.github.com>
Date: Tue, 16 Sep 2025 20:42:36 -0400
Subject: [PATCH 10/22] Fix Discord link and improve README content
Updated README to enhance clarity and correct links.
---
README.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 111 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 3bd39e8..419d0b4 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@
[](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 ⚠️**
@@ -14,12 +16,10 @@
>
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
>
-> *If the Discord link has expired, please [open an issue](https://github.com/your-username/NFOguard/issues) and we'll provide an updated link.*
+> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
---
-**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
-
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
@@ -263,6 +263,114 @@ curl http://localhost:8080/health
- 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)
From cbfd21c4eead1754c1fb6ee0eb1176d2e31e12b3 Mon Sep 17 00:00:00 2001
From: SBCrumb
Date: Wed, 17 Sep 2025 21:43:45 -0400
Subject: [PATCH 11/22] feat: upload intial code
Uploading intial Project Code
---
Dockerfile | 41 +
SETUP.md | 153 +++
VERSION | 1 +
clients/external_clients.py | 597 ++++++++++
clients/radarr_client.py | 534 +++++++++
clients/radarr_db_client.py | 646 ++++++++++
clients/sonarr_client.py | 286 +++++
core/database.py | 268 +++++
core/logging.py | 53 +
core/nfo_manager.py | 415 +++++++
core/path_mapper.py | 105 ++
nfoguard.py | 2208 +++++++++++++++++++++++++++++++++++
requirements.txt | 8 +
13 files changed, 5315 insertions(+)
create mode 100644 Dockerfile
create mode 100644 SETUP.md
create mode 100644 VERSION
create mode 100644 clients/external_clients.py
create mode 100644 clients/radarr_client.py
create mode 100644 clients/radarr_db_client.py
create mode 100644 clients/sonarr_client.py
create mode 100644 core/database.py
create mode 100644 core/logging.py
create mode 100644 core/nfo_manager.py
create mode 100644 core/path_mapper.py
create mode 100644 nfoguard.py
create mode 100644 requirements.txt
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5514048
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,41 @@
+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 . .
+
+# 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
+CMD ["python", "nfoguard.py"]
\ No newline at end of file
diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 0000000..c634feb
--- /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: ghcr.io/your-org/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/VERSION b/VERSION
new file mode 100644
index 0000000..9f05f9f
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.6.5
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..06fafc9
--- /dev/null
+++ b/clients/sonarr_client.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""
+Enhanced Sonarr API client extracted from media_date_cache.py
+"""
+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..33f2905
--- /dev/null
+++ b/core/database.py
@@ -0,0 +1,268 @@
+#!/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"""
+ 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()))
+
+ 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..71f45c7
--- /dev/null
+++ b/core/nfo_manager.py
@@ -0,0 +1,415 @@
+#!/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 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:
+ tree = ET.parse(nfo_path)
+ movie = tree.getroot()
+
+ # 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 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
+
+ try:
+ # Try to load existing NFO file
+ if nfo_path.exists():
+ try:
+ tree = ET.parse(nfo_path)
+ episode = tree.getroot()
+
+ # Ensure root element is
+ if episode.tag != "episodedetails":
+ raise ValueError("Root element is not ")
+
+ # 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}")
+
+ 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/nfoguard.py b/nfoguard.py
new file mode 100644
index 0000000..f6273ec
--- /dev/null
+++ b/nfoguard.py
@@ -0,0 +1,2208 @@
+#!/usr/bin/env python3
+"""
+NFOGuard - Consolidated webhook server for TV and Movie import date management
+Replaces webhook_server.py and media_date_cache.py with modular architecture
+"""
+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.parse_imdb_from_path(movie_path)
+ if not imdb_id:
+ _log("ERROR", f"No IMDb ID found in movie path: {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)
+
+ # 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
+
+ # Create NFO
+ if config.manage_nfo:
+ self.nfo_manager.create_movie_nfo(
+ movie_path, imdb_id, dateadded, released, source, config.lock_metadata
+ )
+
+ # Update file mtimes
+ if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
+ self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
+
+ # Save to database
+ self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
+
+ _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.parse_imdb_from_path(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.parse_imdb_from_path(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
From b1c5c825f8127b133f640938ee8e4f3a7d58b7cb Mon Sep 17 00:00:00 2001
From: SBCrumb
Date: Wed, 17 Sep 2025 21:50:27 -0400
Subject: [PATCH 12/22] chore: cleanup old comment lines
clean up older comment lines from old code base.
---
.env.secrect.template => .env.secrets.template | 0
README.md | 2 +-
SETUP.md | 2 +-
clients/sonarr_client.py | 2 +-
nfoguard.py | 4 ++--
5 files changed, 5 insertions(+), 5 deletions(-)
rename .env.secrect.template => .env.secrets.template (100%)
diff --git a/.env.secrect.template b/.env.secrets.template
similarity index 100%
rename from .env.secrect.template
rename to .env.secrets.template
diff --git a/README.md b/README.md
index 419d0b4..eb679ba 100644
--- a/README.md
+++ b/README.md
@@ -374,7 +374,7 @@ NFOGuard will ignore:
## 🆘 Support
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
-- **Documentation**: See `SETUP_GUIDE.md` for detailed instructions
+- **Documentation**: See `SETUP.md` for detailed instructions
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
## 📄 License
diff --git a/SETUP.md b/SETUP.md
index c634feb..0798ec5 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -122,7 +122,7 @@ If you have an existing `.env` with API keys:
version: '3.8'
services:
nfoguard:
- image: ghcr.io/your-org/nfoguard:latest
+ image: sbcrumb/nfoguard:latest
container_name: nfoguard
ports:
- "8080:8080"
diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py
index 06fafc9..48c754e 100644
--- a/clients/sonarr_client.py
+++ b/clients/sonarr_client.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-Enhanced Sonarr API client extracted from media_date_cache.py
+Enhanced Sonarr API client for TV show metadata and episode management
"""
import json
import time
diff --git a/nfoguard.py b/nfoguard.py
index f6273ec..1940419 100644
--- a/nfoguard.py
+++ b/nfoguard.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
-NFOGuard - Consolidated webhook server for TV and Movie import date management
-Replaces webhook_server.py and media_date_cache.py with modular architecture
+NFOGuard - Automated NFO file management for Radarr and Sonarr
+Modular architecture with webhook processing and intelligent date handling
"""
import os
import json
From 82310b5f4308261b301be8cfecefc1c52cd814d5 Mon Sep 17 00:00:00 2001
From: SBCrumb
Date: Fri, 19 Sep 2025 08:22:04 -0400
Subject: [PATCH 13/22] feat: adding .dll file to docker image
---
.env.example | 28 +++++++++++++++++++++
Dockerfile | 23 +++++++++++++++--
Emby-DLL/NFOGuard.Emby.Plugin.dll | Bin 0 -> 1027072 bytes
docker-compose.example.yml | 40 ++++++++++++++++++++++++++++++
4 files changed, 89 insertions(+), 2 deletions(-)
create mode 100644 .env.example
create mode 100644 Emby-DLL/NFOGuard.Emby.Plugin.dll
create mode 100644 docker-compose.example.yml
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..78ce602
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+# NFOGuard Environment Variables
+
+# User and Group IDs
+PUID=1000
+PGID=1000
+
+# Timezone
+TZ=UTC
+
+# Media path (for read-only access to scan NFO files)
+MEDIA_PATH=/path/to/your/media
+
+# Database settings (if using PostgreSQL)
+# DB_USER=nfoguard
+# DB_PASSWORD=your_secure_password
+
+# Emby Plugin Deployment - Bind Mount Method (Recommended)
+# Set this to the path where Emby plugins are installed on your host
+# Common paths:
+# - /var/lib/emby/plugins (native Emby install)
+# - /path/to/emby/config/plugins (Docker Emby)
+# - /config/plugins (linuxserver.io Emby)
+EMBY_PLUGINS_PATH=/path/to/emby/plugins
+
+# NFOGuard specific settings
+DEBUG=false
+PATH_DEBUG=false
+SUPPRESS_TVDB_WARNINGS=true
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 5514048..868805b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -24,6 +24,25 @@ RUN pip install --no-cache-dir --upgrade pip && \
# 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
@@ -37,5 +56,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
# Expose port
EXPOSE 8080
-# Run the application
-CMD ["python", "nfoguard.py"]
\ No newline at end of file
+# 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 0000000000000000000000000000000000000000..aa3a7360601105e65e6589e2dd21612bfb3e6d44
GIT binary patch
literal 1027072
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