NFOGuard v2.6.7 - Production ready release
Complete media management solution with PostgreSQL, web interface, and 88% scan optimization for efficient library management. Key Features: - PostgreSQL database with production-grade performance - Web interface for episode and movie management - Smart scan optimization reducing scan time by 88% - Database cleanup tools for orphaned episodes/movies - Graceful Docker shutdown for container orchestration - Comprehensive configuration validation - Real-time health monitoring and metrics
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# ===========================================
|
||||||
|
# NFOGuard Configuration - CORRECTED VERSION
|
||||||
|
# ===========================================
|
||||||
|
# Main configuration file - safe to share for debugging
|
||||||
|
# Sensitive data (API keys, passwords) are in .env.secrets
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MEDIA PATHS (REQUIRED) - FIXED
|
||||||
|
# ===========================================
|
||||||
|
# Container paths (what NFOGuard sees inside container)
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
|
||||||
|
# Radarr paths (what Radarr sees on the host system)
|
||||||
|
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
|
||||||
|
|
||||||
|
# Sonarr paths (what Sonarr sees on the host system) - FIXED VARIABLE NAME AND PATHS
|
||||||
|
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 Database Configuration (PostgreSQL - v2.6+)
|
||||||
|
# NOTE: For new installations, PostgreSQL is strongly recommended
|
||||||
|
# Set these values in .env.secrets for security:
|
||||||
|
# - DB_PASSWORD=your_secure_password
|
||||||
|
# - DB_USER=nfoguard (default)
|
||||||
|
|
||||||
|
# PostgreSQL Database Settings
|
||||||
|
DB_TYPE=postgresql
|
||||||
|
DB_HOST=nfoguard-db # Container name from docker-compose
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=nfoguard
|
||||||
|
|
||||||
|
# Legacy SQLite Configuration (Deprecated in v2.6+)
|
||||||
|
# Only use for existing SQLite installations
|
||||||
|
# DB_PATH=/app/data/media_dates.db
|
||||||
|
|
||||||
|
# Log file directory
|
||||||
|
LOG_DIR=/app/data/logs
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR DATABASE CONNECTION (RECOMMENDED)
|
||||||
|
# ===========================================
|
||||||
|
# Direct database access for better performance
|
||||||
|
RADARR_DB_TYPE=postgresql
|
||||||
|
RADARR_DB_HOST=192.168.255.50
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr-main
|
||||||
|
RADARR_DB_USER=postgres
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 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 (digital, physical, theatrical)
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
#RELEASE_DATE_PRIORITY=digital,theatrical,physical
|
||||||
|
ENABLE_SMART_DATE_VALIDATION=true
|
||||||
|
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
|
||||||
|
MOVIE_DATE_UPDATE_MODE=overwrite
|
||||||
|
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
|
||||||
|
|
||||||
|
# Manual scan priority: use existing NFO dates for speed vs check APIs for accuracy
|
||||||
|
# false (default) = Check external APIs first, use NFO as fallback (slower but accurate)
|
||||||
|
# true = Use NFO dates immediately without API checks (faster but may use wrong dates)
|
||||||
|
MANUAL_SCAN_PRIORITIZE_NFO=false
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 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
|
||||||
|
# ===========================================
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# NFOGuard Secrets Configuration Template
|
||||||
|
# Copy this to .env.secrets and add your actual API keys
|
||||||
|
# This file should NOT be committed to version control
|
||||||
|
# Add .env.secrets to your .gitignore
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# NFOGUARD DATABASE CREDENTIALS (REQUIRED)
|
||||||
|
# ===========================================
|
||||||
|
# NFOGuard PostgreSQL database password (REQUIRED for v2.6+)
|
||||||
|
DB_PASSWORD=your_secure_nfoguard_password
|
||||||
|
# Optional: Override default database user (defaults to 'nfoguard')
|
||||||
|
# DB_USER=nfoguard
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR DATABASE CREDENTIALS (OPTIONAL)
|
||||||
|
# ===========================================
|
||||||
|
# Database password for external Radarr PostgreSQL connection (optional optimization)
|
||||||
|
RADARR_DB_PASSWORD=your_radarr_db_password
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# API KEYS (SENSITIVE DATA)
|
||||||
|
# ===========================================
|
||||||
|
# TMDB API key for digital release dates
|
||||||
|
TMDB_API_KEY=your_tmdb_api_key
|
||||||
|
|
||||||
|
# OMDb API key for DVD/digital release dates (optional)
|
||||||
|
OMDB_API_KEY=your_omdb_api_key
|
||||||
|
|
||||||
|
# Radarr API key (optional - for fallback only)
|
||||||
|
RADARR_API_KEY=your_radarr_api_key
|
||||||
|
|
||||||
|
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||||
|
SONARR_API_KEY=your_sonarr_api_key
|
||||||
|
|
||||||
|
# Jellyseerr API key (optional)
|
||||||
|
JELLYSEERR_API_KEY=your_jellyseerr_api_key
|
||||||
|
|
||||||
|
# TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility)
|
||||||
|
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
|
||||||
|
# Get free API key at: https://thetvdb.com/api-information
|
||||||
|
TVDB_API_KEY=your_tvdb_api_key
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# NFOGuard Secrets Configuration Template
|
||||||
|
# Copy this to .env.secrets and add your actual API keys
|
||||||
|
# This file should NOT be committed to version control
|
||||||
|
# Add .env.secrets to your .gitignore
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR DATABASE CREDENTIALS
|
||||||
|
# ===========================================
|
||||||
|
# Database password for PostgreSQL connection
|
||||||
|
RADARR_DB_PASSWORD=your_radarr_db_password
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# API KEYS (SENSITIVE DATA)
|
||||||
|
# ===========================================
|
||||||
|
# TMDB API key for digital release dates
|
||||||
|
TMDB_API_KEY=your_tmdb_api_key
|
||||||
|
|
||||||
|
# OMDb API key for DVD/digital release dates (optional)
|
||||||
|
OMDB_API_KEY=your_omdb_api_key
|
||||||
|
|
||||||
|
# Radarr API key (optional - for fallback only)
|
||||||
|
RADARR_API_KEY=your_radarr_api_key
|
||||||
|
|
||||||
|
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||||
|
SONARR_API_KEY=your_sonarr_api_key
|
||||||
|
|
||||||
|
# Jellyseerr API key (optional)
|
||||||
|
JELLYSEERR_API_KEY=your_jellyseerr_api_key
|
||||||
|
|
||||||
|
# TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility)
|
||||||
|
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
|
||||||
|
# Get free API key at: https://thetvdb.com/api-information
|
||||||
|
TVDB_API_KEY=your_tvdb_api_key
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# ===========================================
|
||||||
|
# NFOGuard Configuration - CORRECTED VERSION
|
||||||
|
# ===========================================
|
||||||
|
# Main configuration file - safe to share for debugging
|
||||||
|
# Sensitive data (API keys, passwords) are in .env.secrets
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MEDIA PATHS (REQUIRED) - FIXED
|
||||||
|
# ===========================================
|
||||||
|
# Container paths (what NFOGuard sees inside container)
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
|
||||||
|
# Radarr paths (what Radarr sees on the host system)
|
||||||
|
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
|
||||||
|
|
||||||
|
# Sonarr paths (what Sonarr sees on the host system) - FIXED VARIABLE NAME AND PATHS
|
||||||
|
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 (RECOMMENDED)
|
||||||
|
# ===========================================
|
||||||
|
# Direct database access for better performance
|
||||||
|
RADARR_DB_TYPE=postgresql
|
||||||
|
RADARR_DB_HOST=192.168.255.50
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr-main
|
||||||
|
RADARR_DB_USER=postgres
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 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 (digital, physical, theatrical)
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
#RELEASE_DATE_PRIORITY=digital,theatrical,physical
|
||||||
|
ENABLE_SMART_DATE_VALIDATION=true
|
||||||
|
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
|
||||||
|
MOVIE_DATE_UPDATE_MODE=overwrite
|
||||||
|
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
|
||||||
|
# ===========================================
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help fixing issues
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots & Logs**
|
||||||
|
If applicable, add screenshots/logs to help explain your problem.
|
||||||
|
|
||||||
|
**Please complete the following information:**
|
||||||
|
- OS: [e.g. Fedora Linux 42 x86_64]
|
||||||
|
- Architecture [e.g. amd64, arm64]
|
||||||
|
- Docker Version
|
||||||
|
- Remediarr Version [e.g. 0.1.9]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here. Such as logs from Remediarr , Sonarr, Radarr. .env file(with API removed) docker compose file as well.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"template": "# Changelog for {{currentTag}}\n\n{{#conventionalCommits}}\n- {{#scope}}**{{scope}}:** {{/scope}}{{subject}}\n{{/conventionalCommits}}\n",
|
||||||
|
"categories": [
|
||||||
|
{ "title": "### 🚀 Features", "labels": ["feat", "feature"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 🐛 Fixes", "labels": ["fix", "bug"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 🧹 Chore", "labels": ["chore"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 📝 Docs", "labels": ["docs"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 🔧 Refactor", "labels": ["refactor"], "collapseAfter": 0 }
|
||||||
|
],
|
||||||
|
"replacers": [],
|
||||||
|
"sort": {
|
||||||
|
"order": "ASC",
|
||||||
|
"onProperty": "scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name-template: 'v$NEXT_PATCH_VERSION'
|
||||||
|
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||||
|
categories:
|
||||||
|
- title: '🚀 Features'
|
||||||
|
labels: ['feature', 'enhancement']
|
||||||
|
- title: '🐛 Fixes'
|
||||||
|
labels: ['fix', 'bug']
|
||||||
|
- title: '🧰 Maintenance'
|
||||||
|
labels: ['chore', 'maintenance', 'deps']
|
||||||
|
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
|
||||||
|
no-changes-template: 'Minor internal improvements.'
|
||||||
|
template: |
|
||||||
|
|
||||||
|
## Container Images
|
||||||
|
- `sbcrumb/nfoguard:latest`
|
||||||
|
- `sbcrumb/nfoguard:$NEXT_PATCH_VERSION`
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Generate Changelog on Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Build changelog from commits
|
||||||
|
id: build
|
||||||
|
uses: mikepenz/release-changelog-builder-action@v4
|
||||||
|
with:
|
||||||
|
configuration: .github/changelog-config.json
|
||||||
|
ignorePreReleases: false
|
||||||
|
toTag: ${{ github.ref_name }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create / Update GitHub Release notes
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
body: ${{ steps.build.outputs.changelog }}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Build & Push DEV to DockerHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ dev ]
|
||||||
|
push:
|
||||||
|
branches: [ dev ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# PRs: single-arch build (faster). Push to dev: multi-arch.
|
||||||
|
PLATFORMS: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read VERSION and compute DEV tags
|
||||||
|
run: |
|
||||||
|
if [[ ! -f VERSION ]]; then echo "VERSION file missing"; exit 1; fi
|
||||||
|
RAW=$(tr -d ' \n' < VERSION)
|
||||||
|
if [[ -z "$RAW" ]]; then echo "VERSION empty"; exit 1; fi
|
||||||
|
# Remove 'v' prefix if present for version number
|
||||||
|
if [[ "$RAW" =~ ^v ]]; then NUM="${RAW#v}"; else NUM="$RAW"; fi
|
||||||
|
|
||||||
|
# Get repository name (assumes format: owner/repo)
|
||||||
|
REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d'/' -f2)
|
||||||
|
|
||||||
|
echo "VERSION_NUM=$NUM" >> $GITHUB_ENV
|
||||||
|
echo "DEV_VERSION=${NUM}-dev" >> $GITHUB_ENV
|
||||||
|
echo "DOCKER_REPO=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}" >> $GITHUB_ENV
|
||||||
|
echo "DEV_TAGS=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:${NUM}-dev,${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:dev" >> $GITHUB_ENV
|
||||||
|
echo "Computed DEV_VERSION=${NUM}-dev"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
if: ${{ env.PLATFORMS == 'linux/amd64,linux/arm64' }}
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to DockerHub
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & (maybe) push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ env.DEV_TAGS }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.DEV_VERSION }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: Build & Publish to DockerHub (from VERSION + Release)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # create tag + release
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
PLATFORMS: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read VERSION
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
if [[ ! -f VERSION ]]; then echo "VERSION missing"; exit 1; fi
|
||||||
|
RAW=$(tr -d ' \n' < VERSION)
|
||||||
|
if [[ -z "$RAW" ]]; then echo "VERSION empty"; exit 1; fi
|
||||||
|
if [[ "$RAW" =~ ^v ]]; then TAG="$RAW"; NUM="${RAW#v}"; else TAG="v${RAW}"; NUM="$RAW"; fi
|
||||||
|
|
||||||
|
# Get repository name for DockerHub
|
||||||
|
REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d'/' -f2)
|
||||||
|
|
||||||
|
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
echo "NUM=$NUM" >> $GITHUB_ENV
|
||||||
|
echo "DOCKER_REPO=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}" >> $GITHUB_ENV
|
||||||
|
echo "DOCKER_TAGS=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:${NUM},${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:latest" >> $GITHUB_ENV
|
||||||
|
echo "Resolved TAG=$TAG NUM=$NUM"
|
||||||
|
|
||||||
|
- name: Create tag (if missing)
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const tag = process.env.TAG;
|
||||||
|
const sha = context.sha;
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
try {
|
||||||
|
await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` });
|
||||||
|
core.info(`Tag ${tag} exists`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
core.info(`Creating tag ${tag} -> ${sha}`);
|
||||||
|
await github.rest.git.createRef({ owner, repo, ref: `refs/tags/${tag}`, sha });
|
||||||
|
} else { throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Update draft release notes
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
id: drafter
|
||||||
|
uses: release-drafter/release-drafter@v6
|
||||||
|
with:
|
||||||
|
config-name: release-drafter.yml
|
||||||
|
publish: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAG }}
|
||||||
|
name: NFOGuard ${{ env.TAG }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
body: ${{ steps.drafter.outputs.body }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
if: ${{ env.PLATFORMS == 'linux/amd64,linux/arm64' }}
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to DockerHub
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & Push (main)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
tags: ${{ env.DOCKER_TAGS }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.NUM }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Notify on new issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Validate required secrets
|
||||||
|
run: |
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_URL }}" ]]; then
|
||||||
|
echo "Error: GOTIFY_URL secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||||
|
echo "Error: GOTIFY_TOKEN secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Secrets validation passed"
|
||||||
|
|
||||||
|
- name: Build and send Gotify notification
|
||||||
|
env:
|
||||||
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Remove trailing slash from URL if present
|
||||||
|
GOTIFY_URL="${GOTIFY_URL%/}"
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
if [[ ! "$GOTIFY_URL" =~ ^https?:// ]]; then
|
||||||
|
echo "Error: GOTIFY_URL must start with http:// or https://"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the notification payload
|
||||||
|
TITLE="New issue in ${{ github.repository }}"
|
||||||
|
MESSAGE="Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}
|
||||||
|
By: ${{ github.actor }}
|
||||||
|
${{ github.event.issue.html_url }}"
|
||||||
|
|
||||||
|
# Create JSON payload
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg title "$TITLE" \
|
||||||
|
--arg message "$MESSAGE" \
|
||||||
|
--argjson priority 5 \
|
||||||
|
'{title: $title, message: $message, priority: $priority}')
|
||||||
|
|
||||||
|
echo "Sending notification to Gotify..."
|
||||||
|
echo "URL: ${GOTIFY_URL}/message"
|
||||||
|
echo "Payload: $PAYLOAD"
|
||||||
|
|
||||||
|
# Send the notification using header authentication
|
||||||
|
curl -X POST "${GOTIFY_URL}/message" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
--fail \
|
||||||
|
--show-error \
|
||||||
|
--silent
|
||||||
|
|
||||||
|
echo "Notification sent successfully!"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: Release Drafter
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize, closed]
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_release_draft:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: release-drafter/release-drafter@v6
|
||||||
|
with:
|
||||||
|
config-name: release-drafter.yml
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Environment files with sensitive data
|
||||||
|
.env
|
||||||
|
.env.secrets
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Local development files (not for public repo)
|
||||||
|
*.local
|
||||||
|
CODE_STRUCTURE.local
|
||||||
|
sync-to-github.sh
|
||||||
|
|
||||||
|
# Ignore GitHub workflows when pushing to Gitea
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Ignore Gitea workflows when pushing to GitHub
|
||||||
|
#.gitea/
|
||||||
|
|
||||||
|
# Local development documentation (Gitea only, not for GitHub)
|
||||||
|
.local/
|
||||||
|
|
||||||
|
# Temporary troubleshooting files - to be removed
|
||||||
|
Dockerfile.optimized
|
||||||
|
fix-gitea-packages.md
|
||||||
|
configure-docker-insecure.md
|
||||||
|
CLEANUP.md
|
||||||
|
README-OPTIMIZED.md
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
#Udates for local builds
|
||||||
|
|
||||||
|
# Build argument for git branch and build source
|
||||||
|
ARG GIT_BRANCH=main
|
||||||
|
ARG BUILD_SOURCE=
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONIOENCODING=utf-8 \
|
||||||
|
GIT_BRANCH=${GIT_BRANCH} \
|
||||||
|
BUILD_SOURCE=${BUILD_SOURCE}
|
||||||
|
|
||||||
|
# Install system dependencies including PostgreSQL client libraries and tini
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
tini \
|
||||||
|
&& 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 . .
|
||||||
|
|
||||||
|
# Create git metadata for version detection based on build arg
|
||||||
|
RUN mkdir -p .git && \
|
||||||
|
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
|
||||||
|
|
||||||
|
# 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 -u main.py' >> /app/deploy-plugin.sh && \
|
||||||
|
chmod +x /app/deploy-plugin.sh
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to app user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Use tini as init process to handle signals and zombie processes properly
|
||||||
|
ENTRYPOINT ["tini", "--"]
|
||||||
|
|
||||||
|
# Run the application with plugin deployment
|
||||||
|
CMD ["/app/deploy-plugin.sh"]
|
||||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 NFOGuard
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commercial Licensing
|
||||||
|
|
||||||
|
For commercial use, enterprise features, or if the MIT license doesn't meet your needs,
|
||||||
|
commercial licenses are available. Contact us for more information.
|
||||||
|
|
||||||
|
Future premium features (such as web UI, advanced analytics, enterprise support)
|
||||||
|
may be offered under separate commercial licensing terms.
|
||||||
|
|
||||||
|
## Third-Party Licenses
|
||||||
|
|
||||||
|
This software includes third-party libraries with their own licenses.
|
||||||
|
See requirements.txt and the respective package documentation for details.
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
# NFOGuard
|
||||||
|
|
||||||
|
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
|
||||||
|
**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
|
||||||
|
>
|
||||||
|
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
|
||||||
|
>
|
||||||
|
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
||||||
|
>
|
||||||
|
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users:
|
||||||
|
>
|
||||||
|
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
|
||||||
|
>
|
||||||
|
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
NFOGuard automatically updates movie and TV show NFO files with proper release dates and metadata when triggered by Radarr/Sonarr webhooks. It preserves existing metadata while adding clean, accurate date information at the bottom of NFO files.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### **Core Media Management**
|
||||||
|
- **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
|
||||||
|
- **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom
|
||||||
|
- **Metadata Locking** - Prevents overwrites with lockdata tags
|
||||||
|
|
||||||
|
### **Performance & Scalability**
|
||||||
|
- **Async I/O Operations** - High-performance concurrent file processing
|
||||||
|
- **Batch Processing** - Efficient handling of multiple files simultaneously
|
||||||
|
- **PostgreSQL Database** - Production-ready database with optimized queries
|
||||||
|
- **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans
|
||||||
|
- **88% Scan Optimization** - TV library scans reduced from hours to minutes
|
||||||
|
|
||||||
|
### **Web Interface & Management**
|
||||||
|
- **Complete Web UI** - Episode and movie management with filtering and search
|
||||||
|
- **Database Cleanup Tools** - Delete orphaned episodes with confirmation dialogs
|
||||||
|
- **Real-time Statistics** - Live episode counts and source mapping
|
||||||
|
- **Manual Scan Control** - Smart, full, and incomplete scan modes
|
||||||
|
- **Health Monitoring** - System status and performance metrics
|
||||||
|
|
||||||
|
### **Configuration & Validation**
|
||||||
|
- **Comprehensive Config Validation** - Validates all settings before startup
|
||||||
|
- **Runtime Health Checks** - Monitors system health and dependencies
|
||||||
|
- **Path Validation** - Ensures media directories are accessible
|
||||||
|
- **Database Connectivity Tests** - Validates database connections
|
||||||
|
|
||||||
|
### **Production Ready**
|
||||||
|
- **Docker & Kubernetes** - Health checks for orchestration platforms
|
||||||
|
- **Graceful Shutdown** - Proper signal handling for container management
|
||||||
|
- **Configuration CLI** - Validation tools for troubleshooting
|
||||||
|
- **Modular Architecture** - Clean separation of concerns for maintainability
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Download Configuration Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template
|
||||||
|
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template
|
||||||
|
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and edit main configuration
|
||||||
|
cp .env.template .env
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Copy and edit secrets (API keys, passwords)
|
||||||
|
cp .env.secrets.template .env.secrets
|
||||||
|
nano .env.secrets
|
||||||
|
chmod 600 .env.secrets
|
||||||
|
|
||||||
|
# Copy and edit Docker Compose
|
||||||
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
|
nano docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create data directory
|
||||||
|
mkdir -p ./data
|
||||||
|
|
||||||
|
# Start NFOGuard
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs -f nfoguard
|
||||||
|
|
||||||
|
# Verify health
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Files
|
||||||
|
|
||||||
|
| File | Purpose | Contains |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `.env` | Main configuration | Paths, behavior settings, non-sensitive options |
|
||||||
|
| `.env.secrets` | Sensitive data | API keys, passwords, database credentials |
|
||||||
|
|
||||||
|
### Key Configuration Options
|
||||||
|
|
||||||
|
**Media Paths** (Required):
|
||||||
|
```bash
|
||||||
|
# Container paths (what NFOGuard sees)
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
|
||||||
|
# *arr application paths (what your apps see)
|
||||||
|
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies
|
||||||
|
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv
|
||||||
|
```
|
||||||
|
|
||||||
|
**Release Date Priority**:
|
||||||
|
```bash
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debug Mode**:
|
||||||
|
```bash
|
||||||
|
DEBUG=false # Clean production logs
|
||||||
|
SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
### Production (Stable)
|
||||||
|
```yaml
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development (Latest Features)
|
||||||
|
```yaml
|
||||||
|
image: sbcrumb/nfoguard:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Version
|
||||||
|
```yaml
|
||||||
|
image: sbcrumb/nfoguard:v2.0.0 # Latest with monitoring & validation
|
||||||
|
```
|
||||||
|
|
||||||
|
> **🚀 Version 2.0.0** includes major architecture improvements, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
|
||||||
|
|
||||||
|
## 🔗 Webhook Setup
|
||||||
|
|
||||||
|
Configure these webhook URLs in your applications:
|
||||||
|
|
||||||
|
**Radarr**: `http://nfoguard:8080/webhook/radarr`
|
||||||
|
**Sonarr**: `http://nfoguard:8080/webhook/sonarr`
|
||||||
|
|
||||||
|
**Triggers**: On Import, On Upgrade, On Rename
|
||||||
|
|
||||||
|
## 🔄 Manual Operations
|
||||||
|
|
||||||
|
### Manual Scanning
|
||||||
|
|
||||||
|
Trigger manual scans via API endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual scan all media (movies and TV)
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?scan_type=both"
|
||||||
|
|
||||||
|
# Manual scan TV only
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?scan_type=tv"
|
||||||
|
|
||||||
|
# Manual scan movies only
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
|
||||||
|
|
||||||
|
# Manual scan specific path
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
|
||||||
|
|
||||||
|
# Bulk update all movies from Radarr database
|
||||||
|
curl -X POST "http://localhost:8080/bulk/update"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
#### **Core Operations**
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/webhook/radarr` | POST | Radarr webhook handler |
|
||||||
|
| `/webhook/sonarr` | POST | Sonarr webhook handler |
|
||||||
|
| `/manual/scan` | POST | Manual media scanning |
|
||||||
|
| `/bulk/update` | POST | Bulk update from Radarr database |
|
||||||
|
|
||||||
|
#### **Health & Monitoring**
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/api/v1/health` | GET | Comprehensive health status |
|
||||||
|
| `/api/v1/health/ready` | GET | Kubernetes readiness probe |
|
||||||
|
| `/api/v1/health/live` | GET | Kubernetes liveness probe |
|
||||||
|
| `/api/v1/status` | GET | Complete system status |
|
||||||
|
| `/api/v1/status/brief` | GET | Quick system overview |
|
||||||
|
|
||||||
|
#### **Metrics & Performance**
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/api/v1/metrics` | GET | Prometheus metrics (text format) |
|
||||||
|
| `/api/v1/metrics/json` | GET | Metrics in JSON format |
|
||||||
|
| `/api/v1/metrics/processing` | GET | Processing-specific metrics |
|
||||||
|
| `/api/v1/metrics/errors` | GET | Error metrics and recent failures |
|
||||||
|
| `/api/v1/metrics/system` | GET | System resource metrics |
|
||||||
|
|
||||||
|
#### **Configuration & Debugging**
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/debug/movie/{imdb_id}/priority` | GET | Debug movie priority logic |
|
||||||
|
| `/debug/tmdb/{imdb_id}` | GET | Debug TMDB lookup |
|
||||||
|
| `/ping` | GET | Simple connectivity test |
|
||||||
|
|
||||||
|
### Manual Scan Parameters
|
||||||
|
|
||||||
|
- `scan_type`: `both`, `movies`, `tv`
|
||||||
|
- `path`: Specific directory path to scan
|
||||||
|
- Use for initial setup or fixing existing media
|
||||||
|
|
||||||
|
## 📁 Volume Mapping
|
||||||
|
|
||||||
|
Critical: Update `docker-compose.yml` with your actual paths:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # NFOGuard data
|
||||||
|
- /your/movies/path:/media/Movies/movies:rw # Movie library
|
||||||
|
- /your/tv/path:/media/TV/tv:rw # TV library
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Examples
|
||||||
|
|
||||||
|
**Unraid**:
|
||||||
|
```yaml
|
||||||
|
- /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||||
|
- /mnt/user/Media/TV:/media/TV/tv:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
**Synology**:
|
||||||
|
```yaml
|
||||||
|
- /volume1/Media/Movies:/media/Movies/movies:rw
|
||||||
|
- /volume1/Media/TV:/media/TV/tv:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
**Standard Linux**:
|
||||||
|
```yaml
|
||||||
|
- /home/user/media/movies:/media/Movies/movies:rw
|
||||||
|
- /home/user/media/tv:/media/TV/tv:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Check Logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f nfoguard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Debug Mode
|
||||||
|
```bash
|
||||||
|
# In .env file
|
||||||
|
DEBUG=true
|
||||||
|
PATH_DEBUG=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
NFOGuard provides comprehensive monitoring capabilities for production deployments:
|
||||||
|
|
||||||
|
### **Health Checks**
|
||||||
|
```bash
|
||||||
|
# Basic health status
|
||||||
|
curl http://localhost:8080/api/v1/health
|
||||||
|
|
||||||
|
# Kubernetes readiness probe
|
||||||
|
curl http://localhost:8080/api/v1/health/ready
|
||||||
|
|
||||||
|
# Kubernetes liveness probe
|
||||||
|
curl http://localhost:8080/api/v1/health/live
|
||||||
|
|
||||||
|
# Quick system overview
|
||||||
|
curl http://localhost:8080/api/v1/status/brief
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Metrics for Grafana/Prometheus**
|
||||||
|
```bash
|
||||||
|
# Prometheus metrics format
|
||||||
|
curl http://localhost:8080/api/v1/metrics
|
||||||
|
|
||||||
|
# JSON metrics for dashboards
|
||||||
|
curl http://localhost:8080/api/v1/metrics/json
|
||||||
|
|
||||||
|
# Processing performance metrics
|
||||||
|
curl http://localhost:8080/api/v1/metrics/processing
|
||||||
|
|
||||||
|
# Error rates and recent failures
|
||||||
|
curl http://localhost:8080/api/v1/metrics/errors
|
||||||
|
|
||||||
|
# System resource usage
|
||||||
|
curl http://localhost:8080/api/v1/metrics/system
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Configuration Validation**
|
||||||
|
```bash
|
||||||
|
# Validate configuration before deployment
|
||||||
|
python config/validation_cli.py
|
||||||
|
|
||||||
|
# Include runtime tests (database, APIs, file access)
|
||||||
|
python config/validation_cli.py --runtime
|
||||||
|
|
||||||
|
# JSON output for automation
|
||||||
|
python config/validation_cli.py --runtime --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Docker Health Checks**
|
||||||
|
Add to your `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Structured Logging**
|
||||||
|
NFOGuard uses structured JSON logging with correlation IDs for request tracing:
|
||||||
|
```bash
|
||||||
|
# Enable structured logging
|
||||||
|
STRUCTURED_LOGGING=true
|
||||||
|
|
||||||
|
# Log level configuration
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Example log output with correlation ID
|
||||||
|
{"timestamp": "2024-01-01T12:00:00Z", "level": "INFO", "correlation_id": "req_123", "message": "Webhook received", "context": {"webhook_type": "radarr", "media_type": "movie"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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>Movie Title</title>
|
||||||
|
<year>2023</year>
|
||||||
|
<!-- Existing Radarr metadata -->
|
||||||
|
</movie>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After
|
||||||
|
```xml
|
||||||
|
<movie>
|
||||||
|
<title>Movie Title</title>
|
||||||
|
<year>2023</year>
|
||||||
|
<!-- Existing Radarr metadata preserved -->
|
||||||
|
|
||||||
|
<!-- NFOGuard additions at bottom -->
|
||||||
|
<digital_release_date>2023-03-15</digital_release_date>
|
||||||
|
<lockdata>true</lockdata>
|
||||||
|
<!-- Manager: NFOGuard -->
|
||||||
|
</movie>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Radarr and/or Sonarr
|
||||||
|
- Media files in accessible directories
|
||||||
|
- Network connectivity between services
|
||||||
|
|
||||||
|
## 📁 Directory Structure Requirements
|
||||||
|
|
||||||
|
NFOGuard identifies movies and TV shows using two methods: directory names with IMDb IDs (primary) or NFO files with IMDb IDs (fallback). Your media should follow these conventions:
|
||||||
|
|
||||||
|
### **Movies**
|
||||||
|
|
||||||
|
**Directory Structure:**
|
||||||
|
```
|
||||||
|
/movies/
|
||||||
|
└── Movie Title (2024) [tt1234567]/
|
||||||
|
├── movie.mkv
|
||||||
|
└── movie.nfo (created by NFOGuard)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Identification Methods:**
|
||||||
|
1. **Primary**: Directory name contains IMDb ID in brackets: `[tt1234567]` or `[imdb-tt1234567]`
|
||||||
|
2. **Fallback**: NFO file with IMDb ID in movie.nfo file (see NFO format below)
|
||||||
|
3. **Video file** required: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`
|
||||||
|
4. **Case insensitive** - `[TT1234567]` works too
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
✅ Action Film (2024) [tt1234567]/
|
||||||
|
✅ Drama Movie [imdb-tt7654321]/
|
||||||
|
✅ SciFi Thriller (2023) [TT9876543]/
|
||||||
|
❌ Missing IMDB Directory/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📺 **TV Shows**
|
||||||
|
|
||||||
|
**Directory Structure:**
|
||||||
|
```
|
||||||
|
/tv/
|
||||||
|
└── Series Title (2024) [tt1234567]/
|
||||||
|
├── Season 01/
|
||||||
|
│ ├── Series S01E01.mkv
|
||||||
|
│ ├── Series S01E02.mkv
|
||||||
|
│ ├── S01E01.nfo (created by NFOGuard)
|
||||||
|
│ └── S01E02.nfo (created by NFOGuard)
|
||||||
|
├── Season 02/
|
||||||
|
└── tvshow.nfo (created by NFOGuard)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Identification Methods:**
|
||||||
|
1. **Primary**: Series directory contains IMDb ID: `[tt1234567]` or `[imdb-tt1234567]`
|
||||||
|
2. **Fallback**: NFO file with IMDb ID in tvshow.nfo file (see NFO format below)
|
||||||
|
3. **Season directories** must match pattern: `Season 01`, `Season 1`, `season 01` etc.
|
||||||
|
4. **Episode files** must contain season/episode info:
|
||||||
|
- **SxxExx format**: `S01E01`, `S1E1`, `s01e01`
|
||||||
|
- **Dot format**: `1.1`, `01.01`
|
||||||
|
5. **Video extensions**: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.ts`, `.m2ts`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
✅ Drama Series (2024) [tt1234567]/
|
||||||
|
├── Season 01/
|
||||||
|
│ ├── Drama S01E01.mkv
|
||||||
|
│ └── Drama S01E02.mkv
|
||||||
|
└── Season 02/
|
||||||
|
|
||||||
|
✅ Comedy Show [tt7654321]/
|
||||||
|
└── Season 1/
|
||||||
|
├── Comedy 1.1.mkv
|
||||||
|
└── Comedy 1.2.mkv
|
||||||
|
|
||||||
|
❌ Series Without IMDB []/
|
||||||
|
❌ Series [tt1234567]/Episode01.mkv (no season directory)
|
||||||
|
❌ Series [tt1234567]/Season 1/RandomName.mkv (no episode pattern)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📄 **NFO File Identification Format**
|
||||||
|
|
||||||
|
NFOGuard can extract IMDb IDs from existing NFO files using these XML tags:
|
||||||
|
|
||||||
|
**Movie NFO (movie.nfo):**
|
||||||
|
```xml
|
||||||
|
<!-- Method 1: uniqueid tag (preferred) -->
|
||||||
|
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||||
|
|
||||||
|
<!-- Method 2: imdbid tag -->
|
||||||
|
<imdbid>tt1234567</imdbid>
|
||||||
|
|
||||||
|
<!-- Method 3: imdb tag -->
|
||||||
|
<imdb>tt1234567</imdb>
|
||||||
|
```
|
||||||
|
|
||||||
|
**TV Show NFO (tvshow.nfo):**
|
||||||
|
```xml
|
||||||
|
<!-- Same format as movies -->
|
||||||
|
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||||
|
<imdbid>tt1234567</imdbid>
|
||||||
|
<imdb>tt1234567</imdb>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Episode NFO Files:**
|
||||||
|
NFOGuard creates standardized episode NFO files using the pattern `S##E##.nfo`:
|
||||||
|
- `S01E01.nfo` for Season 1, Episode 1
|
||||||
|
- `S02E05.nfo` for Season 2, Episode 5
|
||||||
|
- Always zero-padded format (S01E01, not S1E1)
|
||||||
|
- **Smart Rename**: NFOGuard will find existing NFO files (created by Sonarr/other tools), extract their metadata, and rename them to the standard format
|
||||||
|
|
||||||
|
### 🚫 **What Gets Skipped**
|
||||||
|
NFOGuard will ignore:
|
||||||
|
- Directories without IMDb IDs in brackets AND no NFO files with IMDb IDs
|
||||||
|
- Directories without video files
|
||||||
|
- TV episodes without recognizable season/episode patterns
|
||||||
|
- Season directories that don't match "Season X" format
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
|
||||||
|
- **Documentation**: See `SETUP.md` for detailed instructions
|
||||||
|
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
Commercial licensing and enterprise features may be available separately. Contact us for more information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**NFOGuard** - Keeping your media metadata clean and organized! 🎯
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# NFOGuard Setup Guide
|
||||||
|
|
||||||
|
## 🔐 Secure Configuration Setup
|
||||||
|
|
||||||
|
NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
|
||||||
|
|
||||||
|
- **`.env`** - Main configuration (safe to share for debugging)
|
||||||
|
- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
|
||||||
|
|
||||||
|
### Step 1: Copy Configuration Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy main configuration
|
||||||
|
cp .env.template .env
|
||||||
|
|
||||||
|
# Copy secrets configuration
|
||||||
|
cp .env.secrets.template .env.secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure Main Settings
|
||||||
|
|
||||||
|
Edit your `.env` file with your specific paths and preferences:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Media paths (adjust to your directory structure)
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
|
||||||
|
# Database connection details
|
||||||
|
RADARR_DB_HOST=radarr-postgres
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr
|
||||||
|
RADARR_DB_USER=radarr
|
||||||
|
|
||||||
|
# Processing preferences
|
||||||
|
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||||
|
ALLOW_FILE_DATE_FALLBACK=false
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
|
||||||
|
# TV webhook processing mode (v0.6.0+)
|
||||||
|
TV_WEBHOOK_PROCESSING_MODE=targeted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Configure Secrets
|
||||||
|
|
||||||
|
Edit your `.env.secrets` file with your actual API keys and passwords:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database password
|
||||||
|
RADARR_DB_PASSWORD=your_actual_radarr_password
|
||||||
|
|
||||||
|
# TMDB API key (required for release date detection)
|
||||||
|
TMDB_API_KEY=your_actual_tmdb_api_key
|
||||||
|
|
||||||
|
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||||
|
SONARR_API_KEY=your_actual_sonarr_api_key
|
||||||
|
|
||||||
|
# Optional API keys
|
||||||
|
RADARR_API_KEY=your_radarr_api_key
|
||||||
|
OMDB_API_KEY=your_omdb_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Configuration
|
||||||
|
|
||||||
|
Test your setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test database connections
|
||||||
|
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||||
|
|
||||||
|
# Test movie scanning
|
||||||
|
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||||
|
|
||||||
|
# Check system health
|
||||||
|
curl "http://localhost:8080/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
### API Key Masking
|
||||||
|
All API keys and passwords are automatically masked in logs:
|
||||||
|
```
|
||||||
|
[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
|
||||||
|
[2025-09-09T12:34:56] INFO: Database connection password=***masked***
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensitive Data Separation
|
||||||
|
- **Main `.env`**: Paths, preferences, URLs (safe to share)
|
||||||
|
- **`.env.secrets`**: API keys, passwords (never commit to version control)
|
||||||
|
- **Automatic loading**: Both files loaded automatically at startup
|
||||||
|
|
||||||
|
### Git Protection
|
||||||
|
The `.gitignore` file prevents accidental commits:
|
||||||
|
```
|
||||||
|
.env
|
||||||
|
.env.secrets
|
||||||
|
.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠 Troubleshooting
|
||||||
|
|
||||||
|
### "Environment files not loaded" Warning
|
||||||
|
Install python-dotenv:
|
||||||
|
```bash
|
||||||
|
pip install python-dotenv==1.0.0
|
||||||
|
# or
|
||||||
|
docker-compose build # rebuilds with updated requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing Configuration for Help
|
||||||
|
You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
|
||||||
|
|
||||||
|
### Migration from Old Setup
|
||||||
|
If you have an existing `.env` with API keys:
|
||||||
|
1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
|
||||||
|
2. Remove sensitive data from `.env`
|
||||||
|
3. Restart NFOGuard
|
||||||
|
|
||||||
|
## 🎯 Docker Compose Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
nfoguard:
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
container_name: nfoguard
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/media:/media:rw
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./.env:/app/.env:ro # Main configuration
|
||||||
|
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
depends_on:
|
||||||
|
- radarr-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Configuration Reference
|
||||||
|
|
||||||
|
### Main Configuration (.env)
|
||||||
|
- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
|
||||||
|
- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
|
||||||
|
- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
|
||||||
|
- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
|
||||||
|
|
||||||
|
### Secrets Configuration (.env.secrets)
|
||||||
|
- **Database**: `RADARR_DB_PASSWORD`
|
||||||
|
- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
|
||||||
|
- **Optional**: `JELLYSEERR_API_KEY`
|
||||||
|
|
||||||
|
This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Pydantic models for NFOGuard API
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrWebhook(BaseModel):
|
||||||
|
"""Sonarr webhook payload model"""
|
||||||
|
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):
|
||||||
|
"""Radarr webhook payload model"""
|
||||||
|
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):
|
||||||
|
"""Health check response model"""
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
uptime: str
|
||||||
|
database_status: str
|
||||||
|
radarr_database: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TVSeasonRequest(BaseModel):
|
||||||
|
"""TV season processing request model"""
|
||||||
|
series_path: str
|
||||||
|
season_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TVEpisodeRequest(BaseModel):
|
||||||
|
"""TV episode processing request model"""
|
||||||
|
series_path: str
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
|
||||||
|
|
||||||
|
# Web interface models
|
||||||
|
class MovieUpdateRequest(BaseModel):
|
||||||
|
"""Request to update movie dateadded"""
|
||||||
|
dateadded: Optional[str]
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeUpdateRequest(BaseModel):
|
||||||
|
"""Request to update episode dateadded"""
|
||||||
|
dateadded: Optional[str]
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateRequest(BaseModel):
|
||||||
|
"""Request for bulk source updates"""
|
||||||
|
media_type: str # "movies" or "episodes"
|
||||||
|
old_source: str
|
||||||
|
new_source: str
|
||||||
|
|
||||||
|
|
||||||
|
class MovieResponse(BaseModel):
|
||||||
|
"""Movie data response"""
|
||||||
|
imdb_id: str
|
||||||
|
title: str
|
||||||
|
path: str
|
||||||
|
released: Optional[str]
|
||||||
|
dateadded: Optional[str]
|
||||||
|
source: Optional[str]
|
||||||
|
has_video_file: bool
|
||||||
|
last_updated: str
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesResponse(BaseModel):
|
||||||
|
"""TV series data response"""
|
||||||
|
imdb_id: str
|
||||||
|
title: str
|
||||||
|
path: str
|
||||||
|
last_updated: str
|
||||||
|
total_episodes: int
|
||||||
|
episodes_with_dates: int
|
||||||
|
episodes_with_video: int
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeResponse(BaseModel):
|
||||||
|
"""TV episode data response"""
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
aired: Optional[str]
|
||||||
|
dateadded: Optional[str]
|
||||||
|
source: Optional[str]
|
||||||
|
has_video_file: bool
|
||||||
|
last_updated: str
|
||||||
|
series_path: str
|
||||||
|
season_name: str
|
||||||
|
episode_name: str
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""
|
||||||
|
Monitoring API Routes for NFOGuard
|
||||||
|
Provides health checks, metrics, and system status endpoints
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Response, HTTPException
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import time
|
||||||
|
|
||||||
|
from monitoring.health import health_checker, HealthStatus
|
||||||
|
from monitoring.metrics import metrics
|
||||||
|
try:
|
||||||
|
from config.validator import get_configuration_summary
|
||||||
|
except ImportError:
|
||||||
|
def get_configuration_summary():
|
||||||
|
return {"status": "Configuration validator not available"}
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["monitoring"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def get_health_status():
|
||||||
|
"""
|
||||||
|
Get comprehensive health status
|
||||||
|
|
||||||
|
Returns detailed health information including:
|
||||||
|
- Overall system health
|
||||||
|
- Individual component health checks
|
||||||
|
- Performance metrics
|
||||||
|
- Error status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
health_status = await health_checker.get_full_health_status()
|
||||||
|
|
||||||
|
# Set appropriate HTTP status code
|
||||||
|
if health_status.status == HealthStatus.HEALTHY:
|
||||||
|
status_code = 200
|
||||||
|
elif health_status.status == HealthStatus.DEGRADED:
|
||||||
|
status_code = 200 # Still operational
|
||||||
|
else: # UNHEALTHY
|
||||||
|
status_code = 503 # Service unavailable
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=health_status.to_dict(),
|
||||||
|
status_code=status_code,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Return unhealthy status if health check itself fails
|
||||||
|
return Response(
|
||||||
|
content={
|
||||||
|
"status": "unhealthy",
|
||||||
|
"message": f"Health check failed: {e}",
|
||||||
|
"timestamp": time.time()
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready")
|
||||||
|
async def get_readiness_status():
|
||||||
|
"""
|
||||||
|
Kubernetes readiness probe endpoint
|
||||||
|
|
||||||
|
Returns 200 if service is ready to accept traffic
|
||||||
|
Returns 503 if service is not ready
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
readiness = await health_checker.get_readiness_status()
|
||||||
|
|
||||||
|
status_code = 200 if readiness["ready"] else 503
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=readiness,
|
||||||
|
status_code=status_code,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
content={
|
||||||
|
"ready": False,
|
||||||
|
"message": f"Readiness check failed: {e}",
|
||||||
|
"timestamp": time.time()
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/live")
|
||||||
|
async def get_liveness_status():
|
||||||
|
"""
|
||||||
|
Kubernetes liveness probe endpoint
|
||||||
|
|
||||||
|
Returns 200 if service is alive and responsive
|
||||||
|
Returns 500 if service should be restarted
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
liveness = await health_checker.get_liveness_status()
|
||||||
|
|
||||||
|
status_code = 200 if liveness["alive"] else 500
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=liveness,
|
||||||
|
status_code=status_code,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
content={
|
||||||
|
"alive": False,
|
||||||
|
"message": f"Liveness check failed: {e}",
|
||||||
|
"timestamp": time.time()
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
async def get_prometheus_metrics():
|
||||||
|
"""
|
||||||
|
Prometheus-compatible metrics endpoint
|
||||||
|
|
||||||
|
Returns metrics in Prometheus text format for scraping
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prometheus_metrics = metrics.get_prometheus_metrics()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=prometheus_metrics,
|
||||||
|
media_type="text/plain; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to generate metrics: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics/json")
|
||||||
|
async def get_metrics_json():
|
||||||
|
"""
|
||||||
|
Get all metrics in JSON format
|
||||||
|
|
||||||
|
Returns structured metrics data including:
|
||||||
|
- System metrics (CPU, memory, disk)
|
||||||
|
- Processing metrics (rates, durations)
|
||||||
|
- Error metrics (counts, recent errors)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
all_metrics = metrics.get_all_metrics()
|
||||||
|
return all_metrics
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get metrics: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_system_status():
|
||||||
|
"""
|
||||||
|
Get comprehensive system status
|
||||||
|
|
||||||
|
Returns detailed system information including:
|
||||||
|
- Health status
|
||||||
|
- Configuration summary
|
||||||
|
- Performance metrics
|
||||||
|
- Recent activity
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get health status
|
||||||
|
health_status = await health_checker.get_full_health_status()
|
||||||
|
|
||||||
|
# Get metrics
|
||||||
|
all_metrics = metrics.get_all_metrics()
|
||||||
|
|
||||||
|
# Get configuration summary
|
||||||
|
config_summary = get_configuration_summary()
|
||||||
|
|
||||||
|
# Combine into comprehensive status
|
||||||
|
status_response = {
|
||||||
|
"overall_status": health_status.status.value,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"uptime_seconds": health_status.uptime_seconds,
|
||||||
|
"version": health_status.version,
|
||||||
|
"health": health_status.to_dict(),
|
||||||
|
"metrics": all_metrics,
|
||||||
|
"configuration": config_summary,
|
||||||
|
"summary": {
|
||||||
|
"service_healthy": health_status.status in [HealthStatus.HEALTHY, HealthStatus.DEGRADED],
|
||||||
|
"total_webhooks_processed": all_metrics["processing"]["total_webhooks"],
|
||||||
|
"total_nfo_files_created": all_metrics["processing"]["total_nfo_created"],
|
||||||
|
"total_errors": all_metrics["processing"]["total_errors"],
|
||||||
|
"current_processing_rate": all_metrics["processing"]["webhooks_received_per_minute"],
|
||||||
|
"average_processing_time": all_metrics["processing"]["average_processing_time_seconds"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status_response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get system status: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/brief")
|
||||||
|
async def get_brief_status():
|
||||||
|
"""
|
||||||
|
Get brief system status for quick monitoring
|
||||||
|
|
||||||
|
Returns essential status information without detailed metrics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get basic health
|
||||||
|
liveness = await health_checker.get_liveness_status()
|
||||||
|
readiness = await health_checker.get_readiness_status()
|
||||||
|
|
||||||
|
# Get basic metrics
|
||||||
|
processing_metrics = metrics.get_processing_metrics()
|
||||||
|
system_metrics = metrics.get_system_metrics()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if liveness["alive"] and readiness["ready"] else "unhealthy",
|
||||||
|
"alive": liveness["alive"],
|
||||||
|
"ready": readiness["ready"],
|
||||||
|
"uptime_seconds": liveness["uptime_seconds"],
|
||||||
|
"webhooks_per_minute": processing_metrics["webhooks_received_per_minute"],
|
||||||
|
"active_operations": processing_metrics["active_operations"],
|
||||||
|
"total_errors": processing_metrics["total_errors"],
|
||||||
|
"cpu_percent": system_metrics.get("cpu_percent", 0),
|
||||||
|
"memory_percent": system_metrics.get("memory_percent", 0),
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get brief status: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics/processing")
|
||||||
|
async def get_processing_metrics():
|
||||||
|
"""
|
||||||
|
Get processing-specific metrics
|
||||||
|
|
||||||
|
Returns metrics focused on NFO processing performance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
processing_metrics = metrics.get_processing_metrics()
|
||||||
|
return processing_metrics
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get processing metrics: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics/errors")
|
||||||
|
async def get_error_metrics():
|
||||||
|
"""
|
||||||
|
Get error-specific metrics
|
||||||
|
|
||||||
|
Returns error counts, types, and recent error information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
error_metrics = metrics.get_error_metrics()
|
||||||
|
return error_metrics
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get error metrics: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics/system")
|
||||||
|
async def get_system_metrics():
|
||||||
|
"""
|
||||||
|
Get system resource metrics
|
||||||
|
|
||||||
|
Returns CPU, memory, disk, and process information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
system_metrics = metrics.get_system_metrics()
|
||||||
|
return system_metrics
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get system metrics: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/metrics/reset")
|
||||||
|
async def reset_metrics(metric_types: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Reset specific metric types
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- metric_types: Comma-separated list of metric types to reset
|
||||||
|
(counters, histograms, errors, timeseries)
|
||||||
|
If not specified, resets all metrics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reset_types = None
|
||||||
|
if metric_types:
|
||||||
|
reset_types = [t.strip() for t in metric_types.split(",")]
|
||||||
|
|
||||||
|
metrics.reset_metrics(reset_types)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Metrics reset successfully",
|
||||||
|
"reset_types": reset_types or "all",
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to reset metrics: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy endpoints for backwards compatibility
|
||||||
|
@router.get("/health-check")
|
||||||
|
async def legacy_health_check():
|
||||||
|
"""Legacy health check endpoint (redirects to /health)"""
|
||||||
|
return await get_brief_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ping")
|
||||||
|
async def ping():
|
||||||
|
"""Simple ping endpoint for basic connectivity testing"""
|
||||||
|
return {
|
||||||
|
"message": "pong",
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"service": "nfoguard",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
+1442
File diff suppressed because it is too large
Load Diff
+1216
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,706 @@
|
|||||||
|
#!/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}")
|
||||||
|
|
||||||
|
# Fallback: First try English-speaking countries, then any country
|
||||||
|
english_speaking_countries = {"GB", "CA", "AU", "NZ", "IE"} # UK, Canada, Australia, New Zealand, Ireland
|
||||||
|
|
||||||
|
# First pass: Try English-speaking countries
|
||||||
|
_log("INFO", f"🇺🇸 TMDB: Trying English-speaking countries fallback for {imdb_id}")
|
||||||
|
for entry in release_dates.get("results", []):
|
||||||
|
country = entry.get("iso_3166_1", "").upper()
|
||||||
|
if country not in english_speaking_countries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_log("INFO", f"🎯 TMDB: Checking English-speaking country {country}")
|
||||||
|
|
||||||
|
releases = entry.get("release_dates", [])
|
||||||
|
if not releases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
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: Using English-speaking {country} {type_name} release date: {parsed_date}")
|
||||||
|
return parsed_date
|
||||||
|
|
||||||
|
# Second pass: Try any remaining country as last resort
|
||||||
|
_log("INFO", f"🌍 TMDB: Trying any available country as last resort for {imdb_id}")
|
||||||
|
for entry in release_dates.get("results", []):
|
||||||
|
country = entry.get("iso_3166_1", "").upper()
|
||||||
|
if country in english_speaking_countries or country == self.primary_country:
|
||||||
|
continue # Already tried these
|
||||||
|
|
||||||
|
_log("INFO", f"🎯 TMDB: Checking fallback country {country}")
|
||||||
|
|
||||||
|
releases = entry.get("release_dates", [])
|
||||||
|
if not releases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
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: Using fallback {country} {type_name} release date: {parsed_date}")
|
||||||
|
return parsed_date
|
||||||
|
|
||||||
|
_log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in any 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)
|
||||||
|
|
||||||
|
def get_episode_air_date(self, imdb_id: str, season: int, episode: int) -> Optional[str]:
|
||||||
|
"""Get episode air date from external sources"""
|
||||||
|
_log("DEBUG", f"Looking for air date for {imdb_id} S{season:02d}E{episode:02d}")
|
||||||
|
|
||||||
|
# Try TMDB first if available
|
||||||
|
if self.tmdb.enabled:
|
||||||
|
# Find TV show by IMDB ID
|
||||||
|
tv_find_result = self.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
|
||||||
|
if tv_find_result and tv_find_result.get("tv_results"):
|
||||||
|
tv_show = tv_find_result["tv_results"][0]
|
||||||
|
tv_id = tv_show.get("id")
|
||||||
|
if tv_id:
|
||||||
|
_log("DEBUG", f"Found TMDB TV ID {tv_id} for {imdb_id}")
|
||||||
|
episodes = self.tmdb.get_tv_season_episodes(tv_id, season)
|
||||||
|
if episode in episodes:
|
||||||
|
air_date = episodes[episode]
|
||||||
|
_log("INFO", f"Found TMDB air date for {imdb_id} S{season:02d}E{episode:02d}: {air_date}")
|
||||||
|
return _parse_date_to_iso(air_date)
|
||||||
|
|
||||||
|
# Try OMDb as fallback
|
||||||
|
if self.omdb.enabled:
|
||||||
|
episodes = self.omdb.get_tv_season_episodes(imdb_id, season)
|
||||||
|
if episode in episodes:
|
||||||
|
air_date = episodes[episode]
|
||||||
|
_log("INFO", f"Found OMDb air date for {imdb_id} S{season:02d}E{episode:02d}: {air_date}")
|
||||||
|
return _parse_date_to_iso(air_date)
|
||||||
|
|
||||||
|
_log("WARNING", f"No air date found for {imdb_id} S{season:02d}E{episode:02d}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -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", [])
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Enhanced Sonarr API client for TV show metadata and episode management
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request as UrlRequest, urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
from core.logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrClient:
|
||||||
|
"""Enhanced Sonarr API client for TV series and episode management"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.retries = max(0, retries)
|
||||||
|
self.enabled = bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||||
|
"""Make GET request to Sonarr API with retries"""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{self.base_url}/api/v3{path}"
|
||||||
|
if params:
|
||||||
|
url += "?" + urlencode(params)
|
||||||
|
|
||||||
|
headers = {"X-Api-Key": self.api_key}
|
||||||
|
|
||||||
|
for attempt in range(self.retries):
|
||||||
|
try:
|
||||||
|
_log("DEBUG", f"Sonarr API Request: {url}")
|
||||||
|
req = UrlRequest(url, headers=headers)
|
||||||
|
|
||||||
|
with urlopen(req, timeout=self.timeout) as resp:
|
||||||
|
data = resp.read().decode("utf-8")
|
||||||
|
result = json.loads(data) if data else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
_log("ERROR", "Sonarr authentication failed - check API key")
|
||||||
|
return None
|
||||||
|
elif e.code == 429:
|
||||||
|
wait_time = (attempt + 1) * 2
|
||||||
|
_log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}")
|
||||||
|
|
||||||
|
if attempt < self.retries - 1:
|
||||||
|
time.sleep(0.5 * (attempt + 1))
|
||||||
|
|
||||||
|
_log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find series by IMDb ID using lookup endpoint"""
|
||||||
|
search_term = f"imdbid:{imdb_id}"
|
||||||
|
_log("DEBUG", f"Searching Sonarr with term: {search_term}")
|
||||||
|
|
||||||
|
result = self._get("/series/lookup", {"term": search_term})
|
||||||
|
if not result:
|
||||||
|
_log("WARNING", f"No results from Sonarr lookup for: {search_term}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log("DEBUG", f"Sonarr lookup returned {len(result)} results")
|
||||||
|
|
||||||
|
# Log all results for debugging
|
||||||
|
for i, series in enumerate(result):
|
||||||
|
series_imdb = series.get("imdbId", "")
|
||||||
|
series_title = series.get("title", "")
|
||||||
|
series_id = series.get("id", "")
|
||||||
|
_log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
|
||||||
|
|
||||||
|
# Find exact IMDb match (case insensitive)
|
||||||
|
target_imdb = imdb_id.lower()
|
||||||
|
for series in result:
|
||||||
|
series_imdb = (series.get("imdbId") or "").lower()
|
||||||
|
if series_imdb == target_imdb:
|
||||||
|
_log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
# Try partial match as fallback
|
||||||
|
for series in result:
|
||||||
|
series_imdb = (series.get("imdbId") or "").lower()
|
||||||
|
if target_imdb in series_imdb or series_imdb in target_imdb:
|
||||||
|
_log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
_log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Search for series by title as fallback when IMDb lookup fails"""
|
||||||
|
_log("DEBUG", f"Searching Sonarr by title: {title}")
|
||||||
|
|
||||||
|
result = self._get("/series/lookup", {"term": title})
|
||||||
|
if not result:
|
||||||
|
_log("WARNING", f"No results from Sonarr title search for: {title}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log("DEBUG", f"Sonarr title search returned {len(result)} results")
|
||||||
|
|
||||||
|
title_lower = title.lower()
|
||||||
|
|
||||||
|
# Look for exact title match
|
||||||
|
for series in result:
|
||||||
|
series_title = (series.get("title") or "").lower()
|
||||||
|
if series_title == title_lower:
|
||||||
|
_log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
# Look for partial title match
|
||||||
|
for series in result:
|
||||||
|
series_title = (series.get("title") or "").lower()
|
||||||
|
if title_lower in series_title or series_title in title_lower:
|
||||||
|
_log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
_log("WARNING", f"No title match found for: {title}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_series(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all series from Sonarr"""
|
||||||
|
return self._get("/series") or []
|
||||||
|
|
||||||
|
def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find series by scanning all series for IMDb match (slower but more reliable)"""
|
||||||
|
_log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}")
|
||||||
|
all_series = self.get_all_series()
|
||||||
|
|
||||||
|
target_imdb = imdb_id.lower()
|
||||||
|
for series in all_series:
|
||||||
|
series_imdb = (series.get("imdbId") or "").lower()
|
||||||
|
if series_imdb == target_imdb:
|
||||||
|
_log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all episodes for a series"""
|
||||||
|
return self._get("/episode", {"seriesId": series_id}) or []
|
||||||
|
|
||||||
|
def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get episode file details"""
|
||||||
|
return self._get(f"/episodefile/{episode_file_id}")
|
||||||
|
|
||||||
|
def get_episode_import_history(self, episode_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the original import date from history with enhanced detection.
|
||||||
|
Focuses on finding the earliest REAL import, not upgrades.
|
||||||
|
"""
|
||||||
|
all_records = []
|
||||||
|
page = 1
|
||||||
|
page_size = 100
|
||||||
|
|
||||||
|
# Collect all history records for this episode
|
||||||
|
while True:
|
||||||
|
history = self._get("/history", {
|
||||||
|
"episodeId": episode_id,
|
||||||
|
"sortKey": "date",
|
||||||
|
"sortDir": "asc",
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size
|
||||||
|
})
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
break
|
||||||
|
|
||||||
|
records = history.get("records", []) if isinstance(history, dict) else []
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_records.extend(records)
|
||||||
|
|
||||||
|
if len(records) < page_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
if page > 10: # Safety valve
|
||||||
|
break
|
||||||
|
|
||||||
|
_log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
|
||||||
|
|
||||||
|
# Categorize events
|
||||||
|
import_events = []
|
||||||
|
grabbed_events = []
|
||||||
|
rename_events = []
|
||||||
|
|
||||||
|
for event in all_records:
|
||||||
|
event_type = event.get("eventType", "").lower()
|
||||||
|
date = event.get("date")
|
||||||
|
|
||||||
|
if not date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_log("DEBUG", f"History event: {event_type} at {date}")
|
||||||
|
|
||||||
|
if event_type == "downloadfolderimported":
|
||||||
|
import_events.append({"date": date, "event": event})
|
||||||
|
elif event_type == "grabbed":
|
||||||
|
grabbed_events.append({"date": date, "event": event})
|
||||||
|
elif event_type == "episodefilerenamed":
|
||||||
|
rename_events.append({"date": date, "event": event})
|
||||||
|
|
||||||
|
# Use the earliest real import event
|
||||||
|
if import_events:
|
||||||
|
earliest_import = min(import_events, key=lambda x: x["date"])
|
||||||
|
import_date = earliest_import["date"]
|
||||||
|
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
|
||||||
|
|
||||||
|
# Check if this looks like an upgrade by comparing to renames
|
||||||
|
if rename_events:
|
||||||
|
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||||
|
rename_date = earliest_rename["date"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
|
||||||
|
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
|
||||||
|
days_diff = (import_dt - rename_dt).days
|
||||||
|
|
||||||
|
# If import is significantly after rename, prefer rename date
|
||||||
|
if days_diff > 30:
|
||||||
|
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
|
||||||
|
return rename_date
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("DEBUG", f"Error comparing dates: {e}")
|
||||||
|
|
||||||
|
return import_date
|
||||||
|
|
||||||
|
# Fallback to grab event
|
||||||
|
if grabbed_events:
|
||||||
|
earliest_grab = min(grabbed_events, key=lambda x: x["date"])
|
||||||
|
_log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}")
|
||||||
|
return earliest_grab["date"]
|
||||||
|
|
||||||
|
_log("WARNING", f"No reliable import events found for episode {episode_id} - should use air date instead")
|
||||||
|
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")
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
"""
|
||||||
|
Runtime Configuration Validator for NFOGuard
|
||||||
|
Provides validation of configuration at runtime with health checks
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import aiohttp
|
||||||
|
import sqlite3
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
from config.validator import ValidationIssue, ValidationResult, ValidationSeverity
|
||||||
|
from utils.exceptions import ConfigurationError, NetworkRetryableError, DatabaseError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthCheckResult:
|
||||||
|
"""Result of a runtime health check"""
|
||||||
|
component: str
|
||||||
|
is_healthy: bool
|
||||||
|
response_time_ms: Optional[float] = None
|
||||||
|
message: str = ""
|
||||||
|
details: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"component": self.component,
|
||||||
|
"is_healthy": self.is_healthy,
|
||||||
|
"response_time_ms": self.response_time_ms,
|
||||||
|
"message": self.message,
|
||||||
|
"details": self.details,
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeValidator:
|
||||||
|
"""Runtime configuration and system health validator"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self._health_cache = {}
|
||||||
|
self._cache_ttl = 60 # Cache health results for 60 seconds
|
||||||
|
|
||||||
|
async def validate_runtime_config(self) -> ValidationResult:
|
||||||
|
"""Perform runtime validation of configuration"""
|
||||||
|
result = ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
# Validate filesystem access
|
||||||
|
await self._validate_filesystem_access(result)
|
||||||
|
|
||||||
|
# Validate database connectivity
|
||||||
|
await self._validate_database_connectivity(result)
|
||||||
|
|
||||||
|
# Validate external API connectivity
|
||||||
|
await self._validate_api_connectivity(result)
|
||||||
|
|
||||||
|
# Validate permissions
|
||||||
|
await self._validate_permissions(result)
|
||||||
|
|
||||||
|
# Validate resource availability
|
||||||
|
await self._validate_resources(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _validate_filesystem_access(self, result: ValidationResult) -> None:
|
||||||
|
"""Validate filesystem access for media paths"""
|
||||||
|
all_paths = []
|
||||||
|
|
||||||
|
# Collect all media paths
|
||||||
|
all_paths.extend(self.config.tv_paths)
|
||||||
|
all_paths.extend(self.config.movie_paths)
|
||||||
|
|
||||||
|
for path in all_paths:
|
||||||
|
try:
|
||||||
|
# Check if path exists and is accessible
|
||||||
|
if not path.exists():
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="media_paths",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Media path does not exist: {path}",
|
||||||
|
current_value=str(path)
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if path is readable
|
||||||
|
test_file = None
|
||||||
|
try:
|
||||||
|
# Try to list directory contents
|
||||||
|
list(path.iterdir())
|
||||||
|
except PermissionError:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="media_paths",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"No read permission for media path: {path}",
|
||||||
|
current_value=str(path)
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
except OSError as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="media_paths",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Error accessing media path: {e}",
|
||||||
|
current_value=str(path)
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check write permissions (needed for NFO files)
|
||||||
|
try:
|
||||||
|
test_file = path / ".nfoguard_write_test"
|
||||||
|
test_file.write_text("test")
|
||||||
|
test_file.unlink()
|
||||||
|
except PermissionError:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="media_paths",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"No write permission for media path: {path}",
|
||||||
|
current_value=str(path),
|
||||||
|
details={"required_for": "NFO file creation"}
|
||||||
|
))
|
||||||
|
except OSError as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="media_paths",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Write test failed for media path: {e}",
|
||||||
|
current_value=str(path)
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
# Cleanup test file if it exists
|
||||||
|
if test_file and test_file.exists():
|
||||||
|
try:
|
||||||
|
test_file.unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="media_paths",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Unexpected error accessing path: {e}",
|
||||||
|
current_value=str(path)
|
||||||
|
))
|
||||||
|
|
||||||
|
# Validate database directory
|
||||||
|
db_path = Path(self.config.db_path)
|
||||||
|
db_dir = db_path.parent
|
||||||
|
|
||||||
|
if not db_dir.exists():
|
||||||
|
try:
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Cannot create database directory: {db_dir}",
|
||||||
|
current_value=str(db_path)
|
||||||
|
))
|
||||||
|
except OSError as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Error creating database directory: {e}",
|
||||||
|
current_value=str(db_path)
|
||||||
|
))
|
||||||
|
|
||||||
|
# Test database file access
|
||||||
|
if not db_path.exists():
|
||||||
|
try:
|
||||||
|
# Try to create the database
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
except PermissionError:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Cannot create database file: {db_path}",
|
||||||
|
current_value=str(db_path)
|
||||||
|
))
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Database error: {e}",
|
||||||
|
current_value=str(db_path)
|
||||||
|
))
|
||||||
|
|
||||||
|
async def _validate_database_connectivity(self, result: ValidationResult) -> None:
|
||||||
|
"""Validate database connectivity"""
|
||||||
|
# Test Radarr database if configured
|
||||||
|
if hasattr(self.config, 'db_type') or 'RADARR_DB_TYPE' in os.environ:
|
||||||
|
import os
|
||||||
|
db_type = getattr(self.config, 'db_type', os.environ.get('RADARR_DB_TYPE', '')).lower()
|
||||||
|
|
||||||
|
if db_type == 'postgresql':
|
||||||
|
await self._test_postgresql_connection(result)
|
||||||
|
elif db_type == 'sqlite':
|
||||||
|
await self._test_sqlite_connection(result)
|
||||||
|
|
||||||
|
# Test local SQLite database
|
||||||
|
await self._test_local_database(result)
|
||||||
|
|
||||||
|
async def _test_postgresql_connection(self, result: ValidationResult) -> None:
|
||||||
|
"""Test PostgreSQL connection"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = os.environ.get('RADARR_DB_HOST')
|
||||||
|
port = int(os.environ.get('RADARR_DB_PORT', 5432))
|
||||||
|
database = os.environ.get('RADARR_DB_NAME')
|
||||||
|
user = os.environ.get('RADARR_DB_USER')
|
||||||
|
password = os.environ.get('RADARR_DB_PASSWORD', '')
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Use asyncio to run blocking DB call
|
||||||
|
def test_connection():
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=host, port=port, database=database,
|
||||||
|
user=user, password=password,
|
||||||
|
connect_timeout=10
|
||||||
|
)
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
conn = await asyncio.get_event_loop().run_in_executor(None, test_connection)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
if response_time > 5000: # 5 seconds
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="RADARR_DB",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Slow database connection ({response_time:.0f}ms)",
|
||||||
|
details={"response_time_ms": response_time}
|
||||||
|
))
|
||||||
|
|
||||||
|
except psycopg2.OperationalError as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="RADARR_DB",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Cannot connect to PostgreSQL database: {e}",
|
||||||
|
details={"error_type": "connection_failed"}
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="RADARR_DB",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Database connection error: {e}",
|
||||||
|
details={"error_type": "unexpected_error"}
|
||||||
|
))
|
||||||
|
|
||||||
|
async def _test_local_database(self, result: ValidationResult) -> None:
|
||||||
|
"""Test local SQLite database"""
|
||||||
|
try:
|
||||||
|
db_path = Path(self.config.db_path)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
def test_db():
|
||||||
|
with sqlite3.connect(str(db_path), timeout=10) as conn:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
return True
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, test_db)
|
||||||
|
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
if response_time > 1000: # 1 second is slow for SQLite
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Slow local database access ({response_time:.0f}ms)",
|
||||||
|
current_value=str(db_path),
|
||||||
|
details={"response_time_ms": response_time}
|
||||||
|
))
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Local database error: {e}",
|
||||||
|
current_value=str(self.config.db_path)
|
||||||
|
))
|
||||||
|
|
||||||
|
async def _validate_api_connectivity(self, result: ValidationResult) -> None:
|
||||||
|
"""Validate external API connectivity"""
|
||||||
|
apis = []
|
||||||
|
|
||||||
|
if hasattr(self.config, 'radarr_url') and self.config.radarr_url:
|
||||||
|
apis.append(("Radarr", self.config.radarr_url))
|
||||||
|
|
||||||
|
if hasattr(self.config, 'sonarr_url') and self.config.sonarr_url:
|
||||||
|
apis.append(("Sonarr", self.config.sonarr_url))
|
||||||
|
|
||||||
|
for api_name, base_url in apis:
|
||||||
|
await self._test_api_connectivity(result, api_name, base_url)
|
||||||
|
|
||||||
|
async def _test_api_connectivity(self, result: ValidationResult, api_name: str, base_url: str) -> None:
|
||||||
|
"""Test connectivity to a specific API"""
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Test basic connectivity
|
||||||
|
test_url = f"{base_url.rstrip('/')}/api/v1/health"
|
||||||
|
|
||||||
|
async with session.get(test_url) as response:
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
if response.status == 200:
|
||||||
|
if response_time > 5000: # 5 seconds
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting=f"{api_name.upper()}_URL",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Slow {api_name} API response ({response_time:.0f}ms)",
|
||||||
|
current_value=base_url,
|
||||||
|
details={"response_time_ms": response_time}
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting=f"{api_name.upper()}_URL",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"{api_name} API returned HTTP {response.status}",
|
||||||
|
current_value=base_url,
|
||||||
|
details={"status_code": response.status}
|
||||||
|
))
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting=f"{api_name.upper()}_URL",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"{api_name} API connection timeout",
|
||||||
|
current_value=base_url,
|
||||||
|
details={"error_type": "timeout"}
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting=f"{api_name.upper()}_URL",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"{api_name} API connection error: {e}",
|
||||||
|
current_value=base_url,
|
||||||
|
details={"error_type": "connection_error"}
|
||||||
|
))
|
||||||
|
|
||||||
|
async def _validate_permissions(self, result: ValidationResult) -> None:
|
||||||
|
"""Validate file system permissions"""
|
||||||
|
# This is partially covered in filesystem validation
|
||||||
|
# Additional permission checks can be added here
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _validate_resources(self, result: ValidationResult) -> None:
|
||||||
|
"""Validate system resources"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check disk space for database directory
|
||||||
|
db_path = Path(self.config.db_path)
|
||||||
|
db_dir = db_path.parent if not db_path.is_dir() else db_path
|
||||||
|
|
||||||
|
if db_dir.exists():
|
||||||
|
free_space = shutil.disk_usage(str(db_dir)).free
|
||||||
|
free_space_mb = free_space / (1024 * 1024)
|
||||||
|
|
||||||
|
if free_space_mb < 100: # Less than 100MB
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Low disk space in database directory ({free_space_mb:.1f}MB free)",
|
||||||
|
current_value=str(db_path),
|
||||||
|
details={"free_space_mb": free_space_mb}
|
||||||
|
))
|
||||||
|
elif free_space_mb < 1000: # Less than 1GB
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="DB_PATH",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Low disk space in database directory ({free_space_mb:.1f}MB free)",
|
||||||
|
current_value=str(db_path),
|
||||||
|
details={"free_space_mb": free_space_mb}
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result.add_issue(ValidationIssue(
|
||||||
|
setting="system_resources",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Could not check disk space: {e}",
|
||||||
|
details={"error_type": "resource_check_failed"}
|
||||||
|
))
|
||||||
|
|
||||||
|
async def get_system_health(self) -> Dict[str, HealthCheckResult]:
|
||||||
|
"""Get comprehensive system health status"""
|
||||||
|
health_checks = {}
|
||||||
|
|
||||||
|
# Database health
|
||||||
|
health_checks["database"] = await self._check_database_health()
|
||||||
|
|
||||||
|
# Filesystem health
|
||||||
|
health_checks["filesystem"] = await self._check_filesystem_health()
|
||||||
|
|
||||||
|
# API health
|
||||||
|
health_checks["external_apis"] = await self._check_api_health()
|
||||||
|
|
||||||
|
return health_checks
|
||||||
|
|
||||||
|
async def _check_database_health(self) -> HealthCheckResult:
|
||||||
|
"""Check database health"""
|
||||||
|
try:
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
def test_db():
|
||||||
|
with sqlite3.connect(str(self.config.db_path), timeout=5) as conn:
|
||||||
|
conn.execute("SELECT COUNT(*) FROM sqlite_master")
|
||||||
|
return True
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, test_db)
|
||||||
|
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="database",
|
||||||
|
is_healthy=True,
|
||||||
|
response_time_ms=response_time,
|
||||||
|
message="Database accessible",
|
||||||
|
details={"db_path": str(self.config.db_path)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="database",
|
||||||
|
is_healthy=False,
|
||||||
|
message=f"Database error: {e}",
|
||||||
|
details={"db_path": str(self.config.db_path), "error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_filesystem_health(self) -> HealthCheckResult:
|
||||||
|
"""Check filesystem health"""
|
||||||
|
try:
|
||||||
|
accessible_paths = 0
|
||||||
|
total_paths = len(self.config.tv_paths) + len(self.config.movie_paths)
|
||||||
|
|
||||||
|
for path in list(self.config.tv_paths) + list(self.config.movie_paths):
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
try:
|
||||||
|
# Quick access test
|
||||||
|
next(path.iterdir(), None)
|
||||||
|
accessible_paths += 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_healthy = accessible_paths == total_paths
|
||||||
|
health_percentage = (accessible_paths / total_paths * 100) if total_paths > 0 else 0
|
||||||
|
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="filesystem",
|
||||||
|
is_healthy=is_healthy,
|
||||||
|
message=f"{accessible_paths}/{total_paths} media paths accessible ({health_percentage:.1f}%)",
|
||||||
|
details={
|
||||||
|
"accessible_paths": accessible_paths,
|
||||||
|
"total_paths": total_paths,
|
||||||
|
"health_percentage": health_percentage
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="filesystem",
|
||||||
|
is_healthy=False,
|
||||||
|
message=f"Filesystem check error: {e}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_api_health(self) -> HealthCheckResult:
|
||||||
|
"""Check external API health"""
|
||||||
|
apis_tested = 0
|
||||||
|
apis_healthy = 0
|
||||||
|
api_details = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test configured APIs
|
||||||
|
if hasattr(self.config, 'radarr_url') and self.config.radarr_url:
|
||||||
|
apis_tested += 1
|
||||||
|
healthy = await self._test_single_api("Radarr", self.config.radarr_url)
|
||||||
|
if healthy:
|
||||||
|
apis_healthy += 1
|
||||||
|
api_details["radarr"] = {"healthy": healthy}
|
||||||
|
|
||||||
|
if hasattr(self.config, 'sonarr_url') and self.config.sonarr_url:
|
||||||
|
apis_tested += 1
|
||||||
|
healthy = await self._test_single_api("Sonarr", self.config.sonarr_url)
|
||||||
|
if healthy:
|
||||||
|
apis_healthy += 1
|
||||||
|
api_details["sonarr"] = {"healthy": healthy}
|
||||||
|
|
||||||
|
if apis_tested == 0:
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="external_apis",
|
||||||
|
is_healthy=True,
|
||||||
|
message="No external APIs configured",
|
||||||
|
details={"apis_configured": 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
is_healthy = apis_healthy == apis_tested
|
||||||
|
health_percentage = (apis_healthy / apis_tested * 100) if apis_tested > 0 else 0
|
||||||
|
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="external_apis",
|
||||||
|
is_healthy=is_healthy,
|
||||||
|
message=f"{apis_healthy}/{apis_tested} APIs healthy ({health_percentage:.1f}%)",
|
||||||
|
details={
|
||||||
|
"healthy_apis": apis_healthy,
|
||||||
|
"total_apis": apis_tested,
|
||||||
|
"health_percentage": health_percentage,
|
||||||
|
"api_status": api_details
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheckResult(
|
||||||
|
component="external_apis",
|
||||||
|
is_healthy=False,
|
||||||
|
message=f"API health check error: {e}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _test_single_api(self, api_name: str, base_url: str) -> bool:
|
||||||
|
"""Test a single API for health"""
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=5)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
test_url = f"{base_url.rstrip('/')}/api/v1/health"
|
||||||
|
|
||||||
|
async with session.get(test_url) as response:
|
||||||
|
return response.status == 200
|
||||||
|
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Import needed for database validation
|
||||||
|
import os
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
NFOGuard Configuration Module
|
||||||
|
Handles all configuration loading and validation with comprehensive error reporting
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
from utils.exceptions import ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_env(name: str, default: bool) -> bool:
|
||||||
|
"""Convert environment variable to boolean"""
|
||||||
|
v = os.environ.get(name)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
return v.lower() in ("1", "true", "yes", "y", "on")
|
||||||
|
|
||||||
|
|
||||||
|
class NFOGuardConfig:
|
||||||
|
"""Configuration class for NFOGuard with integrated validation"""
|
||||||
|
|
||||||
|
def __init__(self, validate_on_init: bool = True, strict_validation: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize NFOGuard configuration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validate_on_init: Run validation during initialization
|
||||||
|
strict_validation: Treat warnings as errors
|
||||||
|
"""
|
||||||
|
self.strict_validation = strict_validation
|
||||||
|
self._validation_issues = []
|
||||||
|
|
||||||
|
# Initialize configuration
|
||||||
|
self._load_configuration()
|
||||||
|
|
||||||
|
# Run validation if requested
|
||||||
|
if validate_on_init:
|
||||||
|
self._validate_configuration()
|
||||||
|
|
||||||
|
def _load_configuration(self) -> None:
|
||||||
|
"""Load all configuration from environment variables"""
|
||||||
|
# Core paths - Required
|
||||||
|
self._load_paths()
|
||||||
|
|
||||||
|
# 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 and performance
|
||||||
|
self.batch_delay = self._get_float_env("BATCH_DELAY", 5.0, 0.1, 300.0)
|
||||||
|
self.max_concurrent = self._get_int_env("MAX_CONCURRENT_SERIES", 3, 1, 10)
|
||||||
|
|
||||||
|
# Database
|
||||||
|
self.db_type = os.environ.get("DB_TYPE", "sqlite").lower()
|
||||||
|
self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
|
||||||
|
|
||||||
|
# PostgreSQL database settings
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
self.db_host = os.environ.get("DB_HOST", "localhost")
|
||||||
|
self.db_port = self._get_int_env("DB_PORT", 5432, 1, 65535)
|
||||||
|
self.db_name = os.environ.get("DB_NAME", "nfoguard")
|
||||||
|
self.db_user = os.environ.get("DB_USER", "nfoguard")
|
||||||
|
self.db_password = os.environ.get("DB_PASSWORD", "")
|
||||||
|
|
||||||
|
# External connections
|
||||||
|
self._load_external_connections()
|
||||||
|
|
||||||
|
# Movie processing
|
||||||
|
self._load_movie_settings()
|
||||||
|
|
||||||
|
# TV processing
|
||||||
|
self._load_tv_settings()
|
||||||
|
|
||||||
|
def _load_paths(self) -> None:
|
||||||
|
"""Load and validate path configuration"""
|
||||||
|
tv_paths_env = os.environ.get("TV_PATHS", "")
|
||||||
|
movie_paths_env = os.environ.get("MOVIE_PATHS", "")
|
||||||
|
|
||||||
|
if not tv_paths_env:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="TV_PATHS",
|
||||||
|
reason="TV_PATHS environment variable is required but not set"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not movie_paths_env:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="MOVIE_PATHS",
|
||||||
|
reason="MOVIE_PATHS environment variable is required but not set"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse paths
|
||||||
|
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()]
|
||||||
|
|
||||||
|
if not self.tv_paths:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="TV_PATHS",
|
||||||
|
reason="No valid TV paths found after parsing",
|
||||||
|
current_value=tv_paths_env
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.movie_paths:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="MOVIE_PATHS",
|
||||||
|
reason="No valid movie paths found after parsing",
|
||||||
|
current_value=movie_paths_env
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_external_connections(self) -> None:
|
||||||
|
"""Load external API and database connection settings"""
|
||||||
|
# API URLs
|
||||||
|
self.radarr_url = os.environ.get("RADARR_URL", "")
|
||||||
|
self.sonarr_url = os.environ.get("SONARR_URL", "")
|
||||||
|
self.jellyseerr_url = os.environ.get("JELLYSEERR_URL", "")
|
||||||
|
|
||||||
|
# Radarr database settings
|
||||||
|
self.radarr_db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||||
|
self.radarr_db_host = os.environ.get("RADARR_DB_HOST", "")
|
||||||
|
self.radarr_db_port = self._get_int_env("RADARR_DB_PORT", 5432, 1, 65535)
|
||||||
|
self.radarr_db_name = os.environ.get("RADARR_DB_NAME", "")
|
||||||
|
self.radarr_db_user = os.environ.get("RADARR_DB_USER", "")
|
||||||
|
|
||||||
|
# Timeout settings
|
||||||
|
self.timeout_seconds = self._get_int_env("TIMEOUT_SECONDS", 45, 10, 300)
|
||||||
|
|
||||||
|
def _load_movie_settings(self) -> None:
|
||||||
|
"""Load movie processing settings"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Manual scan behavior
|
||||||
|
self.manual_scan_prioritize_nfo = _bool_env("MANUAL_SCAN_PRIORITIZE_NFO", False)
|
||||||
|
|
||||||
|
# Release date settings
|
||||||
|
release_priority_env = os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical")
|
||||||
|
self.release_date_priority = [p.strip() for p in release_priority_env.split(",") if p.strip()]
|
||||||
|
|
||||||
|
self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True)
|
||||||
|
self.max_release_date_gap_years = self._get_int_env("MAX_RELEASE_DATE_GAP_YEARS", 10, 1, 50)
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _load_tv_settings(self) -> None:
|
||||||
|
"""Load TV processing settings"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int:
|
||||||
|
"""Get integer environment variable with validation"""
|
||||||
|
value_str = os.environ.get(name)
|
||||||
|
if not value_str:
|
||||||
|
return default
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = int(value_str)
|
||||||
|
if value < min_val or value > max_val:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Value must be between {min_val} and {max_val}",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Invalid integer value",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_float_env(self, name: str, default: float, min_val: float, max_val: float) -> float:
|
||||||
|
"""Get float environment variable with validation"""
|
||||||
|
value_str = os.environ.get(name)
|
||||||
|
if not value_str:
|
||||||
|
return default
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = float(value_str)
|
||||||
|
if value < min_val or value > max_val:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Value must be between {min_val} and {max_val}",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Invalid float value",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_configuration(self) -> None:
|
||||||
|
"""Validate configuration using the validator"""
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from config.validator import validate_configuration_and_raise
|
||||||
|
validate_configuration_and_raise()
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Fallback to basic validation if validator not available
|
||||||
|
logger.warning("Configuration validator not available, using basic validation")
|
||||||
|
self._basic_validation()
|
||||||
|
except ConfigurationError:
|
||||||
|
if self.strict_validation:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Log warning but continue
|
||||||
|
logger.warning("Configuration validation found issues", exc_info=True)
|
||||||
|
|
||||||
|
def _basic_validation(self) -> None:
|
||||||
|
"""Basic fallback validation"""
|
||||||
|
# Validate that paths exist (basic check)
|
||||||
|
for path_list, path_type in [(self.tv_paths, "TV"), (self.movie_paths, "Movie")]:
|
||||||
|
for path in path_list:
|
||||||
|
if not path.is_absolute():
|
||||||
|
logger.warning(f"{path_type} path should be absolute: {path}")
|
||||||
|
|
||||||
|
def get_configuration_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Get a summary of current configuration"""
|
||||||
|
return {
|
||||||
|
"tv_paths": [str(p) for p in self.tv_paths],
|
||||||
|
"movie_paths": [str(p) for p in self.movie_paths],
|
||||||
|
"database": {
|
||||||
|
"type": self.db_type,
|
||||||
|
"path": str(self.db_path) if self.db_type == "sqlite" else None,
|
||||||
|
"host": getattr(self, 'db_host', None) if self.db_type == "postgresql" else None,
|
||||||
|
"port": getattr(self, 'db_port', None) if self.db_type == "postgresql" else None,
|
||||||
|
"name": getattr(self, 'db_name', None) if self.db_type == "postgresql" else None
|
||||||
|
},
|
||||||
|
"external_apis": {
|
||||||
|
"radarr": bool(self.radarr_url),
|
||||||
|
"sonarr": bool(self.sonarr_url),
|
||||||
|
"jellyseerr": bool(self.jellyseerr_url)
|
||||||
|
},
|
||||||
|
"radarr_database": {
|
||||||
|
"type": getattr(self, 'radarr_db_type', None),
|
||||||
|
"configured": bool(getattr(self, 'radarr_db_type', None) and getattr(self, 'radarr_db_host', None))
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"batch_delay": self.batch_delay,
|
||||||
|
"max_concurrent": self.max_concurrent,
|
||||||
|
"timeout_seconds": self.timeout_seconds
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"manage_nfo": self.manage_nfo,
|
||||||
|
"fix_dir_mtimes": self.fix_dir_mtimes,
|
||||||
|
"lock_metadata": self.lock_metadata,
|
||||||
|
"debug": self.debug,
|
||||||
|
"manual_scan_prioritize_nfo": self.manual_scan_prioritize_nfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_runtime_access(self) -> Dict[str, bool]:
|
||||||
|
"""Quick runtime validation of critical paths"""
|
||||||
|
results = {
|
||||||
|
"tv_paths_accessible": True,
|
||||||
|
"movie_paths_accessible": True,
|
||||||
|
"database_writable": True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test TV paths
|
||||||
|
for path in self.tv_paths:
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
results["tv_paths_accessible"] = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Test movie paths
|
||||||
|
for path in self.movie_paths:
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
results["movie_paths_accessible"] = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Test database directory
|
||||||
|
db_dir = self.db_path.parent
|
||||||
|
try:
|
||||||
|
if not db_dir.exists():
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Test write access
|
||||||
|
test_file = db_dir / ".nfoguard_write_test"
|
||||||
|
test_file.write_text("test")
|
||||||
|
test_file.unlink()
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
results["database_writable"] = False
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Global config instance - Initialize with validation disabled by default for backwards compatibility
|
||||||
|
# Applications can enable validation by creating their own instance with validate_on_init=True
|
||||||
|
config = NFOGuardConfig(validate_on_init=False)
|
||||||
Executable
+293
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Configuration Validation CLI for NFOGuard
|
||||||
|
Provides command-line validation and reporting of configuration issues
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config.validator import validate_configuration, ValidationSeverity
|
||||||
|
from config.runtime_validator import RuntimeValidator
|
||||||
|
from config.settings import NFOGuardConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationReporter:
|
||||||
|
"""Formats and displays validation results"""
|
||||||
|
|
||||||
|
def __init__(self, verbose: bool = False, json_output: bool = False):
|
||||||
|
self.verbose = verbose
|
||||||
|
self.json_output = json_output
|
||||||
|
|
||||||
|
# Color codes for terminal output
|
||||||
|
self.colors = {
|
||||||
|
'error': '\033[91m', # Red
|
||||||
|
'warning': '\033[93m', # Yellow
|
||||||
|
'info': '\033[94m', # Blue
|
||||||
|
'success': '\033[92m', # Green
|
||||||
|
'reset': '\033[0m', # Reset
|
||||||
|
'bold': '\033[1m' # Bold
|
||||||
|
}
|
||||||
|
|
||||||
|
def report_validation_results(self, result, runtime_result=None) -> int:
|
||||||
|
"""
|
||||||
|
Report validation results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code (0 for success, 1 for warnings, 2 for errors)
|
||||||
|
"""
|
||||||
|
if self.json_output:
|
||||||
|
return self._report_json(result, runtime_result)
|
||||||
|
else:
|
||||||
|
return self._report_human_readable(result, runtime_result)
|
||||||
|
|
||||||
|
def _report_json(self, result, runtime_result=None) -> int:
|
||||||
|
"""Report results in JSON format"""
|
||||||
|
output = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"validation": result.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime_result:
|
||||||
|
output["runtime_validation"] = runtime_result.to_dict()
|
||||||
|
|
||||||
|
print(json.dumps(output, indent=2))
|
||||||
|
|
||||||
|
if result.errors_count > 0:
|
||||||
|
return 2
|
||||||
|
elif result.warnings_count > 0:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _report_human_readable(self, result, runtime_result=None) -> int:
|
||||||
|
"""Report results in human-readable format"""
|
||||||
|
print(f"{self.colors['bold']}NFOGuard Configuration Validation Report{self.colors['reset']}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Overall status
|
||||||
|
if result.is_valid:
|
||||||
|
status_color = self.colors['success']
|
||||||
|
status_text = "✓ VALID"
|
||||||
|
else:
|
||||||
|
status_color = self.colors['error']
|
||||||
|
status_text = "✗ INVALID"
|
||||||
|
|
||||||
|
print(f"Status: {status_color}{status_text}{self.colors['reset']}")
|
||||||
|
print(f"Errors: {result.errors_count}")
|
||||||
|
print(f"Warnings: {result.warnings_count}")
|
||||||
|
print(f"Total Issues: {len(result.issues)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Report issues by severity
|
||||||
|
if result.issues:
|
||||||
|
self._report_issues_by_severity(result.issues)
|
||||||
|
|
||||||
|
# Report runtime validation if available
|
||||||
|
if runtime_result:
|
||||||
|
print(f"\n{self.colors['bold']}Runtime Validation{self.colors['reset']}")
|
||||||
|
print("-" * 20)
|
||||||
|
if runtime_result.issues:
|
||||||
|
self._report_issues_by_severity(runtime_result.issues, "Runtime")
|
||||||
|
else:
|
||||||
|
print(f"{self.colors['success']}✓ All runtime checks passed{self.colors['reset']}")
|
||||||
|
|
||||||
|
# Summary and recommendations
|
||||||
|
self._report_summary_and_recommendations(result)
|
||||||
|
|
||||||
|
# Return appropriate exit code
|
||||||
|
if result.errors_count > 0 or (runtime_result and runtime_result.errors_count > 0):
|
||||||
|
return 2
|
||||||
|
elif result.warnings_count > 0 or (runtime_result and runtime_result.warnings_count > 0):
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _report_issues_by_severity(self, issues: List, context: str = "Configuration") -> None:
|
||||||
|
"""Report issues grouped by severity"""
|
||||||
|
errors = [issue for issue in issues if issue.severity == ValidationSeverity.ERROR]
|
||||||
|
warnings = [issue for issue in issues if issue.severity == ValidationSeverity.WARNING]
|
||||||
|
info_issues = [issue for issue in issues if issue.severity == ValidationSeverity.INFO]
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"{self.colors['error']}{self.colors['bold']}ERRORS ({len(errors)}):{self.colors['reset']}")
|
||||||
|
for issue in errors:
|
||||||
|
self._format_issue(issue)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
print(f"{self.colors['warning']}{self.colors['bold']}WARNINGS ({len(warnings)}):{self.colors['reset']}")
|
||||||
|
for issue in warnings:
|
||||||
|
self._format_issue(issue)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if info_issues and self.verbose:
|
||||||
|
print(f"{self.colors['info']}{self.colors['bold']}INFO ({len(info_issues)}):{self.colors['reset']}")
|
||||||
|
for issue in info_issues:
|
||||||
|
self._format_issue(issue)
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _format_issue(self, issue) -> None:
|
||||||
|
"""Format a single validation issue"""
|
||||||
|
severity_colors = {
|
||||||
|
ValidationSeverity.ERROR: self.colors['error'],
|
||||||
|
ValidationSeverity.WARNING: self.colors['warning'],
|
||||||
|
ValidationSeverity.INFO: self.colors['info']
|
||||||
|
}
|
||||||
|
|
||||||
|
color = severity_colors.get(issue.severity, '')
|
||||||
|
|
||||||
|
print(f" {color}• {issue.setting}:{self.colors['reset']} {issue.message}")
|
||||||
|
|
||||||
|
if issue.current_value is not None:
|
||||||
|
print(f" Current: {issue.current_value}")
|
||||||
|
|
||||||
|
if issue.suggested_value is not None:
|
||||||
|
print(f" Suggested: {issue.suggested_value}")
|
||||||
|
|
||||||
|
if self.verbose and issue.details:
|
||||||
|
print(f" Details: {issue.details}")
|
||||||
|
|
||||||
|
def _report_summary_and_recommendations(self, result) -> None:
|
||||||
|
"""Report summary and general recommendations"""
|
||||||
|
print(f"\n{self.colors['bold']}Summary{self.colors['reset']}")
|
||||||
|
print("-" * 10)
|
||||||
|
|
||||||
|
if result.is_valid:
|
||||||
|
print(f"{self.colors['success']}✓ Your configuration is valid and ready to use!{self.colors['reset']}")
|
||||||
|
else:
|
||||||
|
print(f"{self.colors['error']}✗ Your configuration has issues that need to be resolved.{self.colors['reset']}")
|
||||||
|
|
||||||
|
# Provide specific recommendations based on issue types
|
||||||
|
recommendations = self._generate_recommendations(result)
|
||||||
|
if recommendations:
|
||||||
|
print(f"\n{self.colors['bold']}Recommendations{self.colors['reset']}")
|
||||||
|
print("-" * 15)
|
||||||
|
for rec in recommendations:
|
||||||
|
print(f" {self.colors['info']}• {rec}{self.colors['reset']}")
|
||||||
|
|
||||||
|
def _generate_recommendations(self, result) -> List[str]:
|
||||||
|
"""Generate recommendations based on validation results"""
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Check for common patterns
|
||||||
|
path_issues = [issue for issue in result.issues if 'path' in issue.setting.lower()]
|
||||||
|
if path_issues:
|
||||||
|
recommendations.append("Verify all file paths are correct for your environment")
|
||||||
|
recommendations.append("Ensure paths are absolute when using Docker")
|
||||||
|
|
||||||
|
db_issues = [issue for issue in result.issues if 'db' in issue.setting.lower()]
|
||||||
|
if db_issues:
|
||||||
|
recommendations.append("Check database connection settings and credentials")
|
||||||
|
|
||||||
|
url_issues = [issue for issue in result.issues if 'url' in issue.setting.lower()]
|
||||||
|
if url_issues:
|
||||||
|
recommendations.append("Verify API URLs are reachable and include the correct port")
|
||||||
|
|
||||||
|
perf_issues = [issue for issue in result.issues if any(perf in issue.setting.lower()
|
||||||
|
for perf in ['concurrent', 'delay', 'timeout'])]
|
||||||
|
if perf_issues:
|
||||||
|
recommendations.append("Consider adjusting performance settings based on your system resources")
|
||||||
|
|
||||||
|
# General recommendations
|
||||||
|
if result.errors_count > 0:
|
||||||
|
recommendations.append("Fix all ERROR-level issues before starting NFOGuard")
|
||||||
|
|
||||||
|
if result.warnings_count > 0:
|
||||||
|
recommendations.append("Review WARNING-level issues to optimize performance and reliability")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
|
||||||
|
async def run_validation(args) -> int:
|
||||||
|
"""Run configuration validation"""
|
||||||
|
reporter = ValidationReporter(verbose=args.verbose, json_output=args.json)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run static validation
|
||||||
|
print("Running configuration validation..." if not args.json else "", file=sys.stderr)
|
||||||
|
result = validate_configuration()
|
||||||
|
|
||||||
|
runtime_result = None
|
||||||
|
|
||||||
|
# Run runtime validation if requested
|
||||||
|
if args.runtime:
|
||||||
|
print("Running runtime validation..." if not args.json else "", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
config = NFOGuardConfig()
|
||||||
|
runtime_validator = RuntimeValidator(config)
|
||||||
|
runtime_result = await runtime_validator.validate_runtime_config()
|
||||||
|
except Exception as e:
|
||||||
|
if not args.json:
|
||||||
|
print(f"Runtime validation failed: {e}", file=sys.stderr)
|
||||||
|
# Continue with static validation results
|
||||||
|
|
||||||
|
# Report results
|
||||||
|
return reporter.report_validation_results(result, runtime_result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if args.json:
|
||||||
|
error_output = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"error": {
|
||||||
|
"message": str(e),
|
||||||
|
"type": type(e).__name__
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(json.dumps(error_output, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"Validation failed: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main CLI entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate NFOGuard configuration",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s # Basic validation
|
||||||
|
%(prog)s --runtime # Include runtime checks
|
||||||
|
%(prog)s --verbose # Show detailed information
|
||||||
|
%(prog)s --json # Output JSON format
|
||||||
|
%(prog)s --runtime --json # Runtime validation with JSON output
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime",
|
||||||
|
action="store_true",
|
||||||
|
help="Perform runtime validation (tests actual connectivity and permissions)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", "-v",
|
||||||
|
action="store_true",
|
||||||
|
help="Show verbose output including info-level messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output results in JSON format"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Run validation
|
||||||
|
import asyncio
|
||||||
|
try:
|
||||||
|
exit_code = asyncio.run(run_validation(args))
|
||||||
|
sys.exit(exit_code)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Validation interrupted", file=sys.stderr)
|
||||||
|
sys.exit(130)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""
|
||||||
|
Configuration Validator for NFOGuard
|
||||||
|
Provides comprehensive validation of all configuration settings with detailed error reporting
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any, Optional, Union, Type, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from utils.exceptions import ConfigurationError
|
||||||
|
from utils.validation import validate_url_format
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationSeverity(Enum):
|
||||||
|
"""Severity levels for validation issues"""
|
||||||
|
ERROR = "error" # Configuration is invalid, will cause failures
|
||||||
|
WARNING = "warning" # Configuration may cause issues but is workable
|
||||||
|
INFO = "info" # Configuration could be improved
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationIssue:
|
||||||
|
"""Represents a configuration validation issue"""
|
||||||
|
setting: str
|
||||||
|
severity: ValidationSeverity
|
||||||
|
message: str
|
||||||
|
current_value: Any = None
|
||||||
|
suggested_value: Any = None
|
||||||
|
details: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for structured logging"""
|
||||||
|
return {
|
||||||
|
"setting": self.setting,
|
||||||
|
"severity": self.severity.value,
|
||||||
|
"message": self.message,
|
||||||
|
"current_value": str(self.current_value) if self.current_value is not None else None,
|
||||||
|
"suggested_value": str(self.suggested_value) if self.suggested_value is not None else None,
|
||||||
|
"details": self.details
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""Results of configuration validation"""
|
||||||
|
is_valid: bool
|
||||||
|
issues: List[ValidationIssue] = field(default_factory=list)
|
||||||
|
warnings_count: int = 0
|
||||||
|
errors_count: int = 0
|
||||||
|
|
||||||
|
def add_issue(self, issue: ValidationIssue) -> None:
|
||||||
|
"""Add a validation issue"""
|
||||||
|
self.issues.append(issue)
|
||||||
|
if issue.severity == ValidationSeverity.ERROR:
|
||||||
|
self.errors_count += 1
|
||||||
|
self.is_valid = False
|
||||||
|
elif issue.severity == ValidationSeverity.WARNING:
|
||||||
|
self.warnings_count += 1
|
||||||
|
|
||||||
|
def get_errors(self) -> List[ValidationIssue]:
|
||||||
|
"""Get only error-level issues"""
|
||||||
|
return [issue for issue in self.issues if issue.severity == ValidationSeverity.ERROR]
|
||||||
|
|
||||||
|
def get_warnings(self) -> List[ValidationIssue]:
|
||||||
|
"""Get only warning-level issues"""
|
||||||
|
return [issue for issue in self.issues if issue.severity == ValidationSeverity.WARNING]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for structured logging"""
|
||||||
|
return {
|
||||||
|
"is_valid": self.is_valid,
|
||||||
|
"errors_count": self.errors_count,
|
||||||
|
"warnings_count": self.warnings_count,
|
||||||
|
"issues": [issue.to_dict() for issue in self.issues]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidator:
|
||||||
|
"""Comprehensive configuration validator for NFOGuard"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.result = ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
# Define validation rules
|
||||||
|
self._path_settings = {
|
||||||
|
"TV_PATHS", "MOVIE_PATHS", "RADARR_ROOT_FOLDERS",
|
||||||
|
"SONARR_ROOT_FOLDERS", "DB_PATH", "LOG_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._url_settings = {
|
||||||
|
"RADARR_URL", "SONARR_URL", "JELLYSEERR_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._required_settings = {
|
||||||
|
"TV_PATHS", "MOVIE_PATHS"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._numeric_settings = {
|
||||||
|
"BATCH_DELAY": (float, 0.1, 300.0),
|
||||||
|
"MAX_CONCURRENT_SERIES": (int, 1, 10),
|
||||||
|
"TIMEOUT_SECONDS": (int, 10, 300),
|
||||||
|
"PORT": (int, 1024, 65535),
|
||||||
|
"RADARR_DB_PORT": (int, 1, 65535),
|
||||||
|
"MAX_RELEASE_DATE_GAP_YEARS": (int, 1, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
self._boolean_settings = {
|
||||||
|
"MANAGE_NFO", "FIX_DIR_MTIMES", "LOCK_METADATA", "DEBUG",
|
||||||
|
"PREFER_RELEASE_DATES_OVER_FILE_DATES", "ALLOW_FILE_DATE_FALLBACK",
|
||||||
|
"ENABLE_SMART_DATE_VALIDATION", "PATH_DEBUG", "SUPPRESS_TVDB_WARNINGS"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._choice_settings = {
|
||||||
|
"MOVIE_PRIORITY": ["import_then_digital", "digital_first", "file_date_only"],
|
||||||
|
"MOVIE_POLL_MODE": ["always", "missing_only", "never"],
|
||||||
|
"MOVIE_DATE_UPDATE_MODE": ["overwrite", "backfill_only", "preserve_existing"],
|
||||||
|
"TV_WEBHOOK_PROCESSING_MODE": ["targeted", "full_scan", "hybrid"],
|
||||||
|
"UPDATE_MODE": ["always", "missing_only", "never"],
|
||||||
|
"MTIME_BEHAVIOR": ["update", "leave_alone"],
|
||||||
|
"RADARR_DB_TYPE": ["postgresql", "sqlite"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_all(self) -> ValidationResult:
|
||||||
|
"""Validate all configuration settings"""
|
||||||
|
self.result = ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
# Validate required settings
|
||||||
|
self._validate_required_settings()
|
||||||
|
|
||||||
|
# Validate paths
|
||||||
|
self._validate_paths()
|
||||||
|
|
||||||
|
# Validate URLs
|
||||||
|
self._validate_urls()
|
||||||
|
|
||||||
|
# Validate numeric settings
|
||||||
|
self._validate_numeric_settings()
|
||||||
|
|
||||||
|
# Validate boolean settings
|
||||||
|
self._validate_boolean_settings()
|
||||||
|
|
||||||
|
# Validate choice settings
|
||||||
|
self._validate_choice_settings()
|
||||||
|
|
||||||
|
# Validate database configuration
|
||||||
|
self._validate_database_config()
|
||||||
|
|
||||||
|
# Validate release date configuration
|
||||||
|
self._validate_release_date_config()
|
||||||
|
|
||||||
|
# Validate performance settings
|
||||||
|
self._validate_performance_settings()
|
||||||
|
|
||||||
|
# Validate cross-setting dependencies
|
||||||
|
self._validate_dependencies()
|
||||||
|
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
def _validate_required_settings(self) -> None:
|
||||||
|
"""Validate required environment variables are set"""
|
||||||
|
for setting in self._required_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if not value:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Required setting {setting} is not set",
|
||||||
|
current_value=value
|
||||||
|
))
|
||||||
|
elif not value.strip():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Required setting {setting} is empty",
|
||||||
|
current_value=value
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_paths(self) -> None:
|
||||||
|
"""Validate all path-related settings"""
|
||||||
|
for setting in self._path_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if not value:
|
||||||
|
if setting in self._required_settings:
|
||||||
|
continue # Already handled in required validation
|
||||||
|
else:
|
||||||
|
# Optional path settings
|
||||||
|
continue
|
||||||
|
|
||||||
|
if setting in {"TV_PATHS", "MOVIE_PATHS", "RADARR_ROOT_FOLDERS", "SONARR_ROOT_FOLDERS"}:
|
||||||
|
# Multi-path settings
|
||||||
|
paths = [p.strip() for p in value.split(",") if p.strip()]
|
||||||
|
if not paths:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"No valid paths found in {setting}",
|
||||||
|
current_value=value
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for path_str in paths:
|
||||||
|
self._validate_single_path(setting, path_str)
|
||||||
|
else:
|
||||||
|
# Single path settings
|
||||||
|
self._validate_single_path(setting, value)
|
||||||
|
|
||||||
|
def _validate_single_path(self, setting: str, path_str: str) -> None:
|
||||||
|
"""Validate a single path"""
|
||||||
|
try:
|
||||||
|
path = Path(path_str)
|
||||||
|
|
||||||
|
# Check if path is absolute (recommended for Docker)
|
||||||
|
if not path.is_absolute():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Path should be absolute for reliable Docker operation",
|
||||||
|
current_value=path_str,
|
||||||
|
suggested_value=f"Use absolute path like /media/..."
|
||||||
|
))
|
||||||
|
|
||||||
|
# For media paths, check existence if not in container
|
||||||
|
if setting in {"TV_PATHS", "MOVIE_PATHS"} and not self._is_likely_container_path(path_str):
|
||||||
|
if not path.exists():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Path does not exist (may be valid in container)",
|
||||||
|
current_value=path_str,
|
||||||
|
details={"path_type": "media"}
|
||||||
|
))
|
||||||
|
elif not path.is_dir():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Path exists but is not a directory",
|
||||||
|
current_value=path_str
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for database directory
|
||||||
|
if setting == "DB_PATH":
|
||||||
|
parent_dir = path.parent
|
||||||
|
if not self._is_likely_container_path(str(parent_dir)) and not parent_dir.exists():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Database directory does not exist: {parent_dir}",
|
||||||
|
current_value=path_str
|
||||||
|
))
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid path format: {e}",
|
||||||
|
current_value=path_str
|
||||||
|
))
|
||||||
|
|
||||||
|
def _is_likely_container_path(self, path: str) -> bool:
|
||||||
|
"""Check if path looks like a container path"""
|
||||||
|
container_indicators = ["/app/", "/media/", "/config/", "/data/"]
|
||||||
|
return any(indicator in path for indicator in container_indicators)
|
||||||
|
|
||||||
|
def _validate_urls(self) -> None:
|
||||||
|
"""Validate URL settings"""
|
||||||
|
for setting in self._url_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if value and not validate_url_format(value):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid URL format",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value="Use format: http://hostname:port or https://hostname:port"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_numeric_settings(self) -> None:
|
||||||
|
"""Validate numeric settings"""
|
||||||
|
for setting, (type_class, min_val, max_val) in self._numeric_settings.items():
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
numeric_value = type_class(value)
|
||||||
|
if numeric_value < min_val or numeric_value > max_val:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Value must be between {min_val} and {max_val}",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value=f"Use value between {min_val}-{max_val}"
|
||||||
|
))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid {type_class.__name__} value",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value=f"Use a valid {type_class.__name__} between {min_val}-{max_val}"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_boolean_settings(self) -> None:
|
||||||
|
"""Validate boolean settings"""
|
||||||
|
valid_true = {"1", "true", "yes", "y", "on"}
|
||||||
|
valid_false = {"0", "false", "no", "n", "off"}
|
||||||
|
valid_values = valid_true | valid_false
|
||||||
|
|
||||||
|
for setting in self._boolean_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if value and value.lower() not in valid_values:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid boolean value",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value="Use: true/false, 1/0, yes/no, y/n, on/off"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_choice_settings(self) -> None:
|
||||||
|
"""Validate settings with predefined choices"""
|
||||||
|
for setting, valid_choices in self._choice_settings.items():
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if value and value.lower() not in [choice.lower() for choice in valid_choices]:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid choice",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value=f"Use one of: {', '.join(valid_choices)}"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_database_config(self) -> None:
|
||||||
|
"""Validate database configuration"""
|
||||||
|
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||||
|
|
||||||
|
if db_type == "postgresql":
|
||||||
|
# Check required PostgreSQL settings
|
||||||
|
required_pg_settings = ["RADARR_DB_HOST", "RADARR_DB_NAME", "RADARR_DB_USER"]
|
||||||
|
for setting in required_pg_settings:
|
||||||
|
if not os.environ.get(setting):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Required for PostgreSQL database connection",
|
||||||
|
current_value=os.environ.get(setting)
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_release_date_config(self) -> None:
|
||||||
|
"""Validate release date processing configuration"""
|
||||||
|
priority = os.environ.get("RELEASE_DATE_PRIORITY", "")
|
||||||
|
if priority:
|
||||||
|
priorities = [p.strip().lower() for p in priority.split(",")]
|
||||||
|
valid_priorities = {"digital", "physical", "theatrical"}
|
||||||
|
|
||||||
|
invalid_priorities = [p for p in priorities if p not in valid_priorities]
|
||||||
|
if invalid_priorities:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="RELEASE_DATE_PRIORITY",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid release date priorities: {', '.join(invalid_priorities)}",
|
||||||
|
current_value=priority,
|
||||||
|
suggested_value="Use: digital, physical, theatrical"
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(set(priorities)) != len(priorities):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="RELEASE_DATE_PRIORITY",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="Duplicate priorities found",
|
||||||
|
current_value=priority
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_performance_settings(self) -> None:
|
||||||
|
"""Validate performance-related settings"""
|
||||||
|
batch_delay = os.environ.get("BATCH_DELAY")
|
||||||
|
max_concurrent = os.environ.get("MAX_CONCURRENT_SERIES")
|
||||||
|
|
||||||
|
# Performance recommendations
|
||||||
|
if batch_delay:
|
||||||
|
try:
|
||||||
|
delay = float(batch_delay)
|
||||||
|
if delay < 1.0:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="BATCH_DELAY",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="Very low batch delay may increase system load",
|
||||||
|
current_value=batch_delay,
|
||||||
|
suggested_value="Consider using 1.0 or higher for better stability"
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
pass # Already caught in numeric validation
|
||||||
|
|
||||||
|
if max_concurrent:
|
||||||
|
try:
|
||||||
|
concurrent = int(max_concurrent)
|
||||||
|
if concurrent > 5:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="MAX_CONCURRENT_SERIES",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="High concurrency may overload system resources",
|
||||||
|
current_value=max_concurrent,
|
||||||
|
suggested_value="Consider using 3-5 for optimal balance"
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
pass # Already caught in numeric validation
|
||||||
|
|
||||||
|
def _validate_dependencies(self) -> None:
|
||||||
|
"""Validate cross-setting dependencies"""
|
||||||
|
# If database is configured, recommend using database over API
|
||||||
|
db_type = os.environ.get("RADARR_DB_TYPE")
|
||||||
|
radarr_url = os.environ.get("RADARR_URL")
|
||||||
|
|
||||||
|
if db_type and radarr_url:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="RADARR_DB_TYPE",
|
||||||
|
severity=ValidationSeverity.INFO,
|
||||||
|
message="Database connection preferred over API for better performance",
|
||||||
|
details={"recommendation": "Database access is faster and more reliable"}
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check path mapping consistency
|
||||||
|
tv_paths = os.environ.get("TV_PATHS", "").split(",")
|
||||||
|
sonarr_paths = os.environ.get("SONARR_ROOT_FOLDERS", "").split(",")
|
||||||
|
|
||||||
|
if len([p for p in tv_paths if p.strip()]) != len([p for p in sonarr_paths if p.strip()]):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="TV_PATHS",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="TV_PATHS and SONARR_ROOT_FOLDERS should have matching number of paths",
|
||||||
|
details={
|
||||||
|
"tv_paths_count": len([p for p in tv_paths if p.strip()]),
|
||||||
|
"sonarr_paths_count": len([p for p in sonarr_paths if p.strip()])
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration() -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Validate the complete NFOGuard configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult with all validation issues found
|
||||||
|
"""
|
||||||
|
validator = ConfigValidator()
|
||||||
|
return validator.validate_all()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration_and_raise() -> None:
|
||||||
|
"""
|
||||||
|
Validate configuration and raise ConfigurationError if invalid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration validation fails
|
||||||
|
"""
|
||||||
|
result = validate_configuration()
|
||||||
|
|
||||||
|
if not result.is_valid:
|
||||||
|
error_messages = []
|
||||||
|
for error in result.get_errors():
|
||||||
|
error_messages.append(f"{error.setting}: {error.message}")
|
||||||
|
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="configuration",
|
||||||
|
reason=f"Configuration validation failed with {result.errors_count} errors",
|
||||||
|
current_value={
|
||||||
|
"errors": error_messages,
|
||||||
|
"warnings_count": result.warnings_count,
|
||||||
|
"validation_details": result.to_dict()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_configuration_summary() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get a summary of current configuration status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with configuration summary
|
||||||
|
"""
|
||||||
|
result = validate_configuration()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_valid": result.is_valid,
|
||||||
|
"errors_count": result.errors_count,
|
||||||
|
"warnings_count": result.warnings_count,
|
||||||
|
"total_issues": len(result.issues),
|
||||||
|
"critical_issues": [
|
||||||
|
issue.to_dict() for issue in result.issues
|
||||||
|
if issue.severity == ValidationSeverity.ERROR
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
issue.to_dict() for issue in result.issues
|
||||||
|
if issue.severity == ValidationSeverity.WARNING
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
Async NFO Manager for NFOGuard
|
||||||
|
High-performance async NFO file operations with concurrent processing
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from utils.logging import _log
|
||||||
|
from utils.async_file_utils import (
|
||||||
|
async_read_nfo_file,
|
||||||
|
async_write_nfo_file,
|
||||||
|
async_file_exists,
|
||||||
|
async_set_file_mtime,
|
||||||
|
async_batch_nfo_operations,
|
||||||
|
async_concurrent_episode_processing
|
||||||
|
)
|
||||||
|
from utils.nfo_patterns import (
|
||||||
|
create_basic_nfo_structure,
|
||||||
|
extract_imdb_from_nfo_content,
|
||||||
|
extract_dates_from_nfo,
|
||||||
|
extract_imdb_id_from_text
|
||||||
|
)
|
||||||
|
from utils.validation import validate_date_string
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncNFOManager:
|
||||||
|
"""Async NFO file manager with concurrent processing capabilities"""
|
||||||
|
|
||||||
|
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||||
|
self.manager_brand = manager_brand
|
||||||
|
self.debug = debug
|
||||||
|
|
||||||
|
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Async extract IMDb ID from directory path or filename
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to examine
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IMDb ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Use the sync version since it's just string processing
|
||||||
|
return extract_imdb_id_from_text(str(path))
|
||||||
|
|
||||||
|
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Async extract IMDb ID from NFO file content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path to NFO file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IMDb ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
root = await async_read_nfo_file(nfo_path)
|
||||||
|
if root is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return extract_imdb_from_nfo_content(root)
|
||||||
|
|
||||||
|
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Async find IMDb ID from directory name, filenames, or NFO file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
movie_dir: Path to movie directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IMDb ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
if self.debug:
|
||||||
|
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
|
||||||
|
|
||||||
|
# First try directory name
|
||||||
|
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
|
||||||
|
if imdb_id:
|
||||||
|
if self.debug:
|
||||||
|
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try all files in the directory concurrently
|
||||||
|
if await async_file_exists(movie_dir):
|
||||||
|
try:
|
||||||
|
from utils.async_file_utils import aiofiles
|
||||||
|
entries = await aiofiles.os.listdir(movie_dir)
|
||||||
|
|
||||||
|
# Create tasks to check all files
|
||||||
|
async def check_file(filename: str) -> Optional[str]:
|
||||||
|
file_path = movie_dir / filename
|
||||||
|
if await aiofiles.os.path.isfile(file_path):
|
||||||
|
return await self.async_parse_imdb_from_path(file_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check all files concurrently
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[check_file(filename) for filename in entries],
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find first valid IMDb ID
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, str) and result:
|
||||||
|
if self.debug:
|
||||||
|
_log("DEBUG", f"Found IMDb ID in filename: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
|
||||||
|
|
||||||
|
# Finally, try NFO file content
|
||||||
|
nfo_path = movie_dir / "movie.nfo"
|
||||||
|
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
|
||||||
|
if imdb_id:
|
||||||
|
if self.debug:
|
||||||
|
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_create_movie_nfo(
|
||||||
|
self,
|
||||||
|
movie_dir: Path,
|
||||||
|
imdb_id: str,
|
||||||
|
dateadded: str,
|
||||||
|
premiered: Optional[str] = None,
|
||||||
|
lock_metadata: bool = True
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Async create movie NFO file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
movie_dir: Path to movie directory
|
||||||
|
imdb_id: IMDb ID
|
||||||
|
dateadded: Date added
|
||||||
|
premiered: Optional premiere date
|
||||||
|
lock_metadata: Whether to lock metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
nfo_path = movie_dir / "movie.nfo"
|
||||||
|
|
||||||
|
# Prepare dates
|
||||||
|
dates = {"dateadded": dateadded}
|
||||||
|
if premiered and validate_date_string(premiered):
|
||||||
|
dates["premiered"] = premiered
|
||||||
|
|
||||||
|
# Create NFO structure
|
||||||
|
root = create_basic_nfo_structure(
|
||||||
|
media_type="movie",
|
||||||
|
title=movie_dir.name,
|
||||||
|
imdb_id=imdb_id,
|
||||||
|
dates=dates,
|
||||||
|
additional_fields={"source": self.manager_brand}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write NFO file asynchronously
|
||||||
|
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
|
||||||
|
|
||||||
|
if success and self.debug:
|
||||||
|
_log("DEBUG", f"Created movie NFO: {nfo_path}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_create_episode_nfo(
|
||||||
|
self,
|
||||||
|
season_dir: Path,
|
||||||
|
season: int,
|
||||||
|
episode: int,
|
||||||
|
aired: Optional[str] = None,
|
||||||
|
dateadded: Optional[str] = None,
|
||||||
|
source: str = "unknown",
|
||||||
|
lock_metadata: bool = True
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Async create episode NFO file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season_dir: Path to season directory
|
||||||
|
season: Season number
|
||||||
|
episode: Episode number
|
||||||
|
aired: Optional air date
|
||||||
|
dateadded: Optional date added
|
||||||
|
source: Source of the data
|
||||||
|
lock_metadata: Whether to lock metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
|
||||||
|
nfo_path = season_dir / nfo_filename
|
||||||
|
|
||||||
|
# Prepare dates
|
||||||
|
dates = {}
|
||||||
|
if aired and validate_date_string(aired):
|
||||||
|
dates["aired"] = aired
|
||||||
|
if dateadded and validate_date_string(dateadded):
|
||||||
|
dates["dateadded"] = dateadded
|
||||||
|
|
||||||
|
# Create NFO structure
|
||||||
|
root = create_basic_nfo_structure(
|
||||||
|
media_type="episodedetails",
|
||||||
|
title=f"S{season:02d}E{episode:02d}",
|
||||||
|
dates=dates,
|
||||||
|
additional_fields={
|
||||||
|
"season": str(season),
|
||||||
|
"episode": str(episode),
|
||||||
|
"source": source
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write NFO file asynchronously
|
||||||
|
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
|
||||||
|
|
||||||
|
if success and self.debug:
|
||||||
|
_log("DEBUG", f"Created episode NFO: {nfo_path}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_batch_create_episode_nfos(
|
||||||
|
self,
|
||||||
|
episode_data_list: List[Dict[str, Any]],
|
||||||
|
max_concurrent: int = 5
|
||||||
|
) -> List[bool]:
|
||||||
|
"""
|
||||||
|
Batch create multiple episode NFOs concurrently
|
||||||
|
|
||||||
|
Args:
|
||||||
|
episode_data_list: List of episode data dictionaries
|
||||||
|
max_concurrent: Maximum concurrent NFO operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of success/failure results
|
||||||
|
"""
|
||||||
|
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
|
||||||
|
return await self.async_create_episode_nfo(
|
||||||
|
season_dir=episode_data.get('season_dir'),
|
||||||
|
season=episode_data.get('season'),
|
||||||
|
episode=episode_data.get('episode'),
|
||||||
|
aired=episode_data.get('aired'),
|
||||||
|
dateadded=episode_data.get('dateadded'),
|
||||||
|
source=episode_data.get('source', 'unknown'),
|
||||||
|
lock_metadata=episode_data.get('lock_metadata', True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return await async_concurrent_episode_processing(
|
||||||
|
episode_data_list,
|
||||||
|
_create_episode_nfo,
|
||||||
|
max_concurrent
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
|
||||||
|
"""
|
||||||
|
Async set file modification time from date string
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
date_str: Date string in ISO format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not validate_date_string(date_str):
|
||||||
|
_log("WARNING", f"Invalid date format for mtime: {date_str}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse date string to timestamp
|
||||||
|
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||||
|
timestamp = dt.timestamp()
|
||||||
|
|
||||||
|
# Set file mtime asynchronously
|
||||||
|
success = await async_set_file_mtime(file_path, timestamp)
|
||||||
|
|
||||||
|
if success and self.debug:
|
||||||
|
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_batch_set_file_mtimes(
|
||||||
|
self,
|
||||||
|
file_mtime_pairs: List[Tuple[Path, str]],
|
||||||
|
max_concurrent: int = 10
|
||||||
|
) -> List[bool]:
|
||||||
|
"""
|
||||||
|
Batch set file modification times concurrently
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_mtime_pairs: List of (file_path, date_str) tuples
|
||||||
|
max_concurrent: Maximum concurrent operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of success/failure results
|
||||||
|
"""
|
||||||
|
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
|
||||||
|
return await self.async_set_file_mtime(file_path, date_str)
|
||||||
|
|
||||||
|
# Create tasks for all mtime operations
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
|
||||||
|
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
|
||||||
|
async with semaphore:
|
||||||
|
return await _set_single_mtime(file_path, date_str)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
_set_mtime_with_semaphore(file_path, date_str)
|
||||||
|
for file_path, date_str in file_mtime_pairs
|
||||||
|
]
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
return [result if isinstance(result, bool) else False for result in results]
|
||||||
|
|
||||||
|
async def async_validate_nfo_integrity(
|
||||||
|
self,
|
||||||
|
nfo_paths: List[Path],
|
||||||
|
max_concurrent: int = 10
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Async validate integrity of multiple NFO files
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_paths: List of NFO file paths to validate
|
||||||
|
max_concurrent: Maximum concurrent validations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with validation results and statistics
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
'total_files': len(nfo_paths),
|
||||||
|
'valid_files': 0,
|
||||||
|
'invalid_files': 0,
|
||||||
|
'missing_files': 0,
|
||||||
|
'validation_errors': [],
|
||||||
|
'file_results': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
|
||||||
|
file_result = {
|
||||||
|
'path': str(nfo_path),
|
||||||
|
'exists': False,
|
||||||
|
'valid_xml': False,
|
||||||
|
'has_imdb_id': False,
|
||||||
|
'has_dates': False,
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if file exists
|
||||||
|
if not await async_file_exists(nfo_path):
|
||||||
|
file_result['error'] = 'File does not exist'
|
||||||
|
return file_result
|
||||||
|
|
||||||
|
file_result['exists'] = True
|
||||||
|
|
||||||
|
# Try to parse NFO
|
||||||
|
root = await async_read_nfo_file(nfo_path)
|
||||||
|
if root is None:
|
||||||
|
file_result['error'] = 'Failed to parse XML'
|
||||||
|
return file_result
|
||||||
|
|
||||||
|
file_result['valid_xml'] = True
|
||||||
|
|
||||||
|
# Check for IMDb ID
|
||||||
|
imdb_id = extract_imdb_from_nfo_content(root)
|
||||||
|
file_result['has_imdb_id'] = bool(imdb_id)
|
||||||
|
|
||||||
|
# Check for dates
|
||||||
|
dates = extract_dates_from_nfo(root)
|
||||||
|
file_result['has_dates'] = any(dates.values())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
file_result['error'] = str(e)
|
||||||
|
|
||||||
|
return file_result
|
||||||
|
|
||||||
|
# Validate all files concurrently
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
|
||||||
|
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
|
||||||
|
async with semaphore:
|
||||||
|
return await _validate_single_nfo(nfo_path)
|
||||||
|
|
||||||
|
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
|
||||||
|
file_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
for i, result in enumerate(file_results):
|
||||||
|
if isinstance(result, dict):
|
||||||
|
path_str = str(nfo_paths[i])
|
||||||
|
results['file_results'][path_str] = result
|
||||||
|
|
||||||
|
if not result['exists']:
|
||||||
|
results['missing_files'] += 1
|
||||||
|
elif result['valid_xml']:
|
||||||
|
results['valid_files'] += 1
|
||||||
|
else:
|
||||||
|
results['invalid_files'] += 1
|
||||||
|
|
||||||
|
if result.get('error'):
|
||||||
|
results['validation_errors'].append(f"{path_str}: {result['error']}")
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PostgreSQL database management for NFOGuard
|
||||||
|
Handles database operations for tracking media dates and processing history
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
class NFOGuardDatabase:
|
||||||
|
"""PostgreSQL database manager for NFOGuard media tracking and processing history"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""
|
||||||
|
Initialize PostgreSQL database connection
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration object with database settings
|
||||||
|
"""
|
||||||
|
if not config:
|
||||||
|
raise ValueError("PostgreSQL configuration is required")
|
||||||
|
self.db_host = config.db_host
|
||||||
|
self.db_port = config.db_port
|
||||||
|
self.db_name = config.db_name
|
||||||
|
self.db_user = config.db_user
|
||||||
|
self.db_password = config.db_password
|
||||||
|
self.db_type = "postgresql" # NFOGuard uses PostgreSQL
|
||||||
|
|
||||||
|
self._local = threading.local()
|
||||||
|
self._init_database()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_connection(self) -> 'psycopg2.extensions.connection':
|
||||||
|
"""Get thread-local PostgreSQL database connection"""
|
||||||
|
if not hasattr(self._local, 'connection'):
|
||||||
|
self._local.connection = psycopg2.connect(
|
||||||
|
host=self.db_host,
|
||||||
|
port=self.db_port,
|
||||||
|
database=self.db_name,
|
||||||
|
user=self.db_user,
|
||||||
|
password=self.db_password,
|
||||||
|
cursor_factory=psycopg2.extras.RealDictCursor
|
||||||
|
)
|
||||||
|
self._local.connection.autocommit = True
|
||||||
|
return self._local.connection
|
||||||
|
|
||||||
|
def _get_first_value(self, row):
|
||||||
|
"""Get first value from row from PostgreSQL RealDictCursor"""
|
||||||
|
# RealDictCursor returns dict-like objects
|
||||||
|
return list(row.values())[0] if row else None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection(self):
|
||||||
|
"""Context manager for PostgreSQL database connections"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
# PostgreSQL uses autocommit - no manual commit needed
|
||||||
|
except Exception:
|
||||||
|
# PostgreSQL uses autocommit - no manual rollback needed
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _init_database(self):
|
||||||
|
"""Initialize PostgreSQL database tables"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
self._init_postgresql_tables(cursor)
|
||||||
|
|
||||||
|
# Test the connection works and verify autocommit
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
print(f"✅ PostgreSQL database initialized and connection verified")
|
||||||
|
print(f"🔍 Autocommit status: {conn.autocommit}")
|
||||||
|
|
||||||
|
def _init_postgresql_tables(self, cursor):
|
||||||
|
"""Initialize database tables"""
|
||||||
|
# Series table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS series (
|
||||||
|
imdb_id VARCHAR(20) PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
last_updated TIMESTAMP NOT NULL,
|
||||||
|
metadata JSONB
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Episodes table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
imdb_id VARCHAR(20) NOT NULL,
|
||||||
|
season INTEGER NOT NULL,
|
||||||
|
episode INTEGER NOT NULL,
|
||||||
|
aired DATE,
|
||||||
|
dateadded TIMESTAMP,
|
||||||
|
source VARCHAR(100),
|
||||||
|
last_updated TIMESTAMP NOT NULL,
|
||||||
|
has_video_file BOOLEAN DEFAULT FALSE,
|
||||||
|
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 VARCHAR(20) PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
released DATE,
|
||||||
|
dateadded TIMESTAMP,
|
||||||
|
source VARCHAR(100),
|
||||||
|
last_updated TIMESTAMP NOT NULL,
|
||||||
|
has_video_file BOOLEAN DEFAULT FALSE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Processing history table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS processing_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
imdb_id VARCHAR(20) NOT NULL,
|
||||||
|
media_type VARCHAR(20) NOT NULL,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
processed_at TIMESTAMP NOT NULL,
|
||||||
|
details TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for PostgreSQL
|
||||||
|
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()
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO series (imdb_id, path, last_updated, metadata)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||||
|
path = EXCLUDED.path,
|
||||||
|
last_updated = EXCLUDED.last_updated,
|
||||||
|
metadata = EXCLUDED.metadata
|
||||||
|
""", (imdb_id, path, timestamp, 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()
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO episodes
|
||||||
|
(imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (imdb_id, season, episode) DO UPDATE SET
|
||||||
|
aired = EXCLUDED.aired,
|
||||||
|
dateadded = EXCLUDED.dateadded,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
has_video_file = EXCLUDED.has_video_file,
|
||||||
|
last_updated = EXCLUDED.last_updated
|
||||||
|
""", (imdb_id, season, episode, aired, dateadded, source, has_video_file, timestamp))
|
||||||
|
import os
|
||||||
|
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||||
|
print(f"🔍 DEBUG: PostgreSQL upsert executed for {imdb_id} S{season:02d}E{episode:02d}, rows affected: {cursor.rowcount}")
|
||||||
|
|
||||||
|
def upsert_movie(self, imdb_id: str, path: str):
|
||||||
|
"""Insert or update movie record"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO movies (imdb_id, path, last_updated)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||||
|
path = EXCLUDED.path,
|
||||||
|
last_updated = EXCLUDED.last_updated
|
||||||
|
""", (imdb_id, path, timestamp))
|
||||||
|
|
||||||
|
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"""
|
||||||
|
import os
|
||||||
|
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||||
|
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||||
|
VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||||
|
released = EXCLUDED.released,
|
||||||
|
dateadded = EXCLUDED.dateadded,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
has_video_file = EXCLUDED.has_video_file,
|
||||||
|
last_updated = EXCLUDED.last_updated
|
||||||
|
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp))
|
||||||
|
|
||||||
|
# Debug: Check what was actually saved
|
||||||
|
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
import os
|
||||||
|
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||||
|
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}")
|
||||||
|
|
||||||
|
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
|
||||||
|
"""Get all episodes for a series"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT * FROM episodes WHERE imdb_id = %s"
|
||||||
|
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 = %s AND season = %s AND episode = %s
|
||||||
|
""", (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 = %s", (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 (%s, %s, %s, %s, %s)
|
||||||
|
""", (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() # Regular cursor for PostgreSQL
|
||||||
|
|
||||||
|
# Series stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM series")
|
||||||
|
series_count = self._get_first_value(cursor.fetchone())
|
||||||
|
|
||||||
|
# Episode stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM episodes")
|
||||||
|
episodes_total = self._get_first_value(cursor.fetchone())
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
|
||||||
|
episodes_with_video = self._get_first_value(cursor.fetchone())
|
||||||
|
|
||||||
|
# Movie stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM movies")
|
||||||
|
movies_total = self._get_first_value(cursor.fetchone())
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
|
||||||
|
movies_with_video = self._get_first_value(cursor.fetchone())
|
||||||
|
|
||||||
|
# Processing history
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM processing_history")
|
||||||
|
history_count = self._get_first_value(cursor.fetchone())
|
||||||
|
|
||||||
|
# Database size calculation for PostgreSQL
|
||||||
|
cursor.execute("SELECT pg_database_size(%s)", (self.db_name,))
|
||||||
|
db_size_bytes = self._get_first_value(cursor.fetchone())
|
||||||
|
db_size_mb = round(db_size_bytes / 1024 / 1024, 2) if db_size_bytes else 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": db_size_mb,
|
||||||
|
"database_type": "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_episode(self, imdb_id: str, season: int, episode: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a specific episode from the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_id: Series IMDb ID
|
||||||
|
season: Season number
|
||||||
|
episode: Episode number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if episode was deleted, False if not found
|
||||||
|
"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM episodes
|
||||||
|
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||||
|
""", (imdb_id, season, episode))
|
||||||
|
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return deleted_count > 0
|
||||||
|
|
||||||
|
def delete_series_episodes(self, imdb_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all episodes for a series from the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_id: Series IMDb ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of episodes deleted
|
||||||
|
"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM episodes
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
def delete_orphaned_episodes(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Find and delete episodes that don't have corresponding video files on disk
|
||||||
|
This requires checking filesystem for each episode, so use carefully
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deleted episodes with their details
|
||||||
|
"""
|
||||||
|
from utils.file_utils import find_episodes_on_disk
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
deleted_episodes = []
|
||||||
|
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all series with their paths
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT imdb_id, path FROM series
|
||||||
|
""")
|
||||||
|
|
||||||
|
series_list = cursor.fetchall()
|
||||||
|
|
||||||
|
for series in series_list:
|
||||||
|
imdb_id = series['imdb_id']
|
||||||
|
series_path = Path(series['path'])
|
||||||
|
|
||||||
|
if not series_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get episodes on disk
|
||||||
|
disk_episodes = find_episodes_on_disk(series_path)
|
||||||
|
disk_episode_keys = set(disk_episodes.keys())
|
||||||
|
|
||||||
|
# Get episodes in database
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT season, episode, dateadded, source
|
||||||
|
FROM episodes
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
db_episodes = cursor.fetchall()
|
||||||
|
|
||||||
|
# Find orphaned episodes (in DB but not on disk)
|
||||||
|
for db_episode in db_episodes:
|
||||||
|
season = db_episode['season']
|
||||||
|
episode = db_episode['episode']
|
||||||
|
episode_key = (season, episode)
|
||||||
|
|
||||||
|
if episode_key not in disk_episode_keys:
|
||||||
|
# Episode is orphaned - delete it
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM episodes
|
||||||
|
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||||
|
""", (imdb_id, season, episode))
|
||||||
|
|
||||||
|
deleted_episodes.append({
|
||||||
|
'imdb_id': imdb_id,
|
||||||
|
'season': season,
|
||||||
|
'episode': episode,
|
||||||
|
'dateadded': db_episode['dateadded'],
|
||||||
|
'source': db_episode['source'],
|
||||||
|
'series_path': str(series_path)
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return deleted_episodes
|
||||||
|
|
||||||
|
def delete_movie(self, imdb_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a specific movie from the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_id: Movie IMDb ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if movie was deleted, False if not found
|
||||||
|
"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM movies
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return deleted_count > 0
|
||||||
|
|
||||||
|
def delete_orphaned_movies(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Find and delete movies that don't have corresponding video files on disk
|
||||||
|
This requires checking filesystem for each movie, so use carefully
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deleted movies with their details
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
deleted_movies = []
|
||||||
|
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all movies with their paths
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT imdb_id, path, dateadded, source
|
||||||
|
FROM movies
|
||||||
|
""")
|
||||||
|
|
||||||
|
movies_list = cursor.fetchall()
|
||||||
|
|
||||||
|
for movie in movies_list:
|
||||||
|
imdb_id = movie['imdb_id']
|
||||||
|
movie_path = Path(movie['path'])
|
||||||
|
|
||||||
|
if not movie_path.exists():
|
||||||
|
# Movie directory doesn't exist - delete it
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM movies
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
deleted_movies.append({
|
||||||
|
'imdb_id': imdb_id,
|
||||||
|
'reason': 'directory_not_found',
|
||||||
|
'path': str(movie_path),
|
||||||
|
'dateadded': movie['dateadded'],
|
||||||
|
'source': movie['source']
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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 f.is_file())
|
||||||
|
|
||||||
|
if not has_video:
|
||||||
|
# No video files found - delete this movie
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM movies
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
deleted_movies.append({
|
||||||
|
'imdb_id': imdb_id,
|
||||||
|
'reason': 'no_video_files',
|
||||||
|
'path': str(movie_path),
|
||||||
|
'dateadded': movie['dateadded'],
|
||||||
|
'source': movie['source']
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return deleted_movies
|
||||||
|
|
||||||
|
def delete_orphaned_series(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Find and delete TV series that don't have corresponding directories on disk
|
||||||
|
This requires checking filesystem for each series, so use carefully
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deleted series with their details
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
deleted_series = []
|
||||||
|
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all series with their paths
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT imdb_id, path, last_updated, metadata
|
||||||
|
FROM series
|
||||||
|
""")
|
||||||
|
|
||||||
|
series_list = cursor.fetchall()
|
||||||
|
|
||||||
|
for series in series_list:
|
||||||
|
imdb_id = series['imdb_id']
|
||||||
|
series_path = Path(series['path'])
|
||||||
|
|
||||||
|
if not series_path.exists():
|
||||||
|
# Series directory doesn't exist - delete the series and all its episodes
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM episodes
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
episodes_deleted = cursor.rowcount
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM series
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
deleted_series.append({
|
||||||
|
'imdb_id': imdb_id,
|
||||||
|
'reason': 'directory_not_found',
|
||||||
|
'path': str(series_path),
|
||||||
|
'last_updated': series['last_updated'],
|
||||||
|
'episodes_deleted': episodes_deleted
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return deleted_series
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close all database connections"""
|
||||||
|
if hasattr(self._local, 'connection'):
|
||||||
|
try:
|
||||||
|
self._local.connection.close()
|
||||||
|
delattr(self._local, 'connection')
|
||||||
|
except Exception:
|
||||||
|
pass # Connection may already be closed
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
|
||||||
|
Core principle: NFO filenames should match video filenames
|
||||||
|
"""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
|
import re
|
||||||
|
from .logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeNFOManager:
|
||||||
|
"""Manages episode NFO files with video filename matching"""
|
||||||
|
|
||||||
|
def __init__(self, manager_brand: str = "NFOGuard"):
|
||||||
|
self.manager_brand = manager_brand
|
||||||
|
|
||||||
|
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||||
|
"""Find all video files in season directory, grouped by (season, episode)"""
|
||||||
|
if not season_dir.exists():
|
||||||
|
_log("DEBUG", f"Season directory does not exist: {season_dir}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
_log("DEBUG", f"Scanning video files in: {season_dir}")
|
||||||
|
for video_file in season_dir.iterdir():
|
||||||
|
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
|
||||||
|
if (video_file.is_file() and
|
||||||
|
video_file.suffix.lower() in video_extensions):
|
||||||
|
|
||||||
|
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||||
|
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
|
||||||
|
if episode_info:
|
||||||
|
season_num, episode_num = episode_info
|
||||||
|
key = (season_num, episode_num)
|
||||||
|
if key not in episodes:
|
||||||
|
episodes[key] = []
|
||||||
|
episodes[key].append(video_file)
|
||||||
|
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d} → {video_file.name}")
|
||||||
|
|
||||||
|
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Extract season and episode numbers from filename"""
|
||||||
|
# Try S##E## format first (most common)
|
||||||
|
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
# Try ##x## format
|
||||||
|
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||||
|
"""Find existing NFO file for episode (prefer video-matching filename)"""
|
||||||
|
if not season_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# First, look for NFO files that match video filenames
|
||||||
|
video_files = self.find_video_files_for_season(season_dir)
|
||||||
|
key = (season_num, episode_num)
|
||||||
|
|
||||||
|
if key in video_files:
|
||||||
|
for video_file in video_files[key]:
|
||||||
|
potential_nfo = season_dir / f"{video_file.stem}.nfo"
|
||||||
|
if potential_nfo.exists():
|
||||||
|
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
|
||||||
|
return potential_nfo
|
||||||
|
|
||||||
|
# Fallback: look for short name NFO
|
||||||
|
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||||
|
if short_nfo.exists():
|
||||||
|
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
|
||||||
|
return short_nfo
|
||||||
|
|
||||||
|
# Last resort: search all NFO files for matching season/episode data
|
||||||
|
for nfo_file in season_dir.glob("*.nfo"):
|
||||||
|
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
|
||||||
|
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
|
||||||
|
return nfo_file
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
|
||||||
|
"""Check if NFO file contains the specified season/episode"""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
if root.tag == "episodedetails":
|
||||||
|
season_elem = root.find("season")
|
||||||
|
episode_elem = root.find("episode")
|
||||||
|
|
||||||
|
if (season_elem is not None and episode_elem is not None and
|
||||||
|
season_elem.text and episode_elem.text):
|
||||||
|
try:
|
||||||
|
file_season = int(season_elem.text)
|
||||||
|
file_episode = int(episode_elem.text)
|
||||||
|
return file_season == season_num and file_episode == episode_num
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except (ET.ParseError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
|
||||||
|
"""Get the target NFO path (prefer video filename, fallback to short name)"""
|
||||||
|
video_files = self.find_video_files_for_season(season_dir)
|
||||||
|
key = (season_num, episode_num)
|
||||||
|
|
||||||
|
if key in video_files and video_files[key]:
|
||||||
|
# Use the first video file found (handle multiple files gracefully)
|
||||||
|
video_file = video_files[key][0]
|
||||||
|
target_nfo = season_dir / f"{video_file.stem}.nfo"
|
||||||
|
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
|
||||||
|
return target_nfo
|
||||||
|
else:
|
||||||
|
# Fallback to short name if no video file found
|
||||||
|
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||||
|
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
|
||||||
|
return target_nfo
|
||||||
|
|
||||||
|
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
|
||||||
|
"""If short-name NFO exists, rename it to match video filename"""
|
||||||
|
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
|
||||||
|
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
|
||||||
|
|
||||||
|
# If we already have the right filename, nothing to do
|
||||||
|
if existing_nfo and existing_nfo == target_nfo:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If we have an NFO but it doesn't match target, rename it
|
||||||
|
if existing_nfo and existing_nfo != target_nfo:
|
||||||
|
try:
|
||||||
|
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
|
||||||
|
existing_nfo.rename(target_nfo)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to rename NFO: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||||
|
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||||
|
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
|
||||||
|
"""Create or update episode NFO with video filename matching"""
|
||||||
|
|
||||||
|
# Get the target NFO path (matching video filename)
|
||||||
|
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
|
||||||
|
|
||||||
|
# Migrate existing NFO if needed
|
||||||
|
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load existing NFO if it exists
|
||||||
|
episode_elem = None
|
||||||
|
if nfo_path.exists():
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
episode_elem = tree.getroot()
|
||||||
|
|
||||||
|
if episode_elem.tag != "episodedetails":
|
||||||
|
raise ValueError("Root element is not <episodedetails>")
|
||||||
|
|
||||||
|
# Remove NFOGuard-managed fields (we'll re-add them)
|
||||||
|
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
|
||||||
|
existing = episode_elem.find(tag)
|
||||||
|
if existing is not None:
|
||||||
|
episode_elem.remove(existing)
|
||||||
|
|
||||||
|
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
|
||||||
|
|
||||||
|
except (ET.ParseError, ValueError) as e:
|
||||||
|
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
|
||||||
|
episode_elem = None
|
||||||
|
|
||||||
|
# Create new structure if needed
|
||||||
|
if episode_elem is None:
|
||||||
|
episode_elem = ET.Element("episodedetails")
|
||||||
|
|
||||||
|
# Add title if provided and not already present
|
||||||
|
if title and not episode_elem.find("title"):
|
||||||
|
title_elem = ET.SubElement(episode_elem, "title")
|
||||||
|
title_elem.text = title
|
||||||
|
|
||||||
|
# Add plot if provided and not already present
|
||||||
|
if plot and not episode_elem.find("plot"):
|
||||||
|
plot_elem = ET.SubElement(episode_elem, "plot")
|
||||||
|
plot_elem.text = plot
|
||||||
|
|
||||||
|
# Add NFOGuard fields at the end
|
||||||
|
season_elem = ET.SubElement(episode_elem, "season")
|
||||||
|
season_elem.text = str(season_num)
|
||||||
|
|
||||||
|
episode_num_elem = ET.SubElement(episode_elem, "episode")
|
||||||
|
episode_num_elem.text = str(episode_num)
|
||||||
|
|
||||||
|
if aired:
|
||||||
|
aired_elem = ET.SubElement(episode_elem, "aired")
|
||||||
|
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||||
|
|
||||||
|
# Also add premiered for compatibility
|
||||||
|
premiered_elem = ET.SubElement(episode_elem, "premiered")
|
||||||
|
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||||
|
|
||||||
|
if dateadded:
|
||||||
|
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
|
||||||
|
dateadded_elem.text = dateadded
|
||||||
|
|
||||||
|
# Add lockdata
|
||||||
|
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
|
||||||
|
lockdata_elem.text = "true"
|
||||||
|
|
||||||
|
# Add comment with source
|
||||||
|
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||||
|
episode_elem.append(comment)
|
||||||
|
|
||||||
|
# Write the NFO file
|
||||||
|
tree = ET.ElementTree(episode_elem)
|
||||||
|
ET.indent(tree, space=" ", level=0)
|
||||||
|
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||||
|
|
||||||
|
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
|
||||||
|
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||||
|
"""Extract NFOGuard-managed data from existing NFO"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
if root.tag != "episodedetails":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for NFOGuard fields
|
||||||
|
dateadded_elem = root.find("dateadded")
|
||||||
|
aired_elem = root.find("aired")
|
||||||
|
lockdata_elem = root.find("lockdata")
|
||||||
|
|
||||||
|
# Only consider it NFOGuard-managed if it has dateadded and lockdata
|
||||||
|
if (dateadded_elem is not None and dateadded_elem.text and
|
||||||
|
lockdata_elem is not None and lockdata_elem.text == "true"):
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"dateadded": dateadded_elem.text.strip(),
|
||||||
|
"source": "existing_nfo"
|
||||||
|
}
|
||||||
|
|
||||||
|
if aired_elem is not None and aired_elem.text:
|
||||||
|
result["aired"] = aired_elem.text.strip()
|
||||||
|
|
||||||
|
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception) as e:
|
||||||
|
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
#!/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, Tuple
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class NFOManager:
|
||||||
|
"""Manages NFO file creation and updates"""
|
||||||
|
|
||||||
|
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||||
|
self.manager_brand = manager_brand
|
||||||
|
self.debug = debug
|
||||||
|
|
||||||
|
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||||
|
"""Extract IMDb ID from directory path or filename"""
|
||||||
|
# Look for various IMDb patterns in both directory and file names
|
||||||
|
path_str = str(path).lower()
|
||||||
|
|
||||||
|
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||||
|
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try standalone [ttXXXXXXX] format in brackets
|
||||||
|
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||||
|
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||||
|
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||||
|
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||||
|
"""Extract IMDb ID from NFO file content"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = self._parse_nfo_with_tolerance(nfo_path)
|
||||||
|
|
||||||
|
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
|
||||||
|
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
|
||||||
|
if imdb_uniqueid is not None and imdb_uniqueid.text:
|
||||||
|
imdb_id = imdb_uniqueid.text.strip()
|
||||||
|
if imdb_id.startswith('tt'):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Check for legacy <imdbid>ttXXXXXX</imdbid>
|
||||||
|
imdbid_elem = root.find('.//imdbid')
|
||||||
|
if imdbid_elem is not None and imdbid_elem.text:
|
||||||
|
imdb_id = imdbid_elem.text.strip()
|
||||||
|
if imdb_id.startswith('tt'):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Check for legacy <imdb>ttXXXXXX</imdb>
|
||||||
|
imdb_elem = root.find('.//imdb')
|
||||||
|
if imdb_elem is not None and imdb_elem.text:
|
||||||
|
imdb_id = imdb_elem.text.strip()
|
||||||
|
if imdb_id.startswith('tt'):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Last resort: Check for TMDB ID as fallback identifier
|
||||||
|
# This handles movies that only have TMDB IDs in NFO files
|
||||||
|
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
|
||||||
|
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
|
||||||
|
tmdb_id = tmdb_uniqueid.text.strip()
|
||||||
|
if tmdb_id.isdigit():
|
||||||
|
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
|
||||||
|
# Return TMDB ID with prefix to distinguish from IMDb IDs
|
||||||
|
return f"tmdb-{tmdb_id}"
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception):
|
||||||
|
# Skip corrupted or non-XML files
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||||
|
"""Find IMDb ID from directory name, filenames, or NFO file"""
|
||||||
|
# First try directory name
|
||||||
|
imdb_id = self.parse_imdb_from_path(movie_dir)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try all files in the directory for IMDb ID patterns
|
||||||
|
for file_path in movie_dir.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
imdb_id = self.parse_imdb_from_path(file_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Finally, try NFO file content (including TMDB fallback)
|
||||||
|
nfo_path = movie_dir / "movie.nfo"
|
||||||
|
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_series_imdb_id(self, series_dir: Path) -> Optional[str]:
|
||||||
|
"""Find IMDb ID from TV series directory name, filenames, or tvshow.nfo file"""
|
||||||
|
# First try directory name
|
||||||
|
imdb_id = self.parse_imdb_from_path(series_dir)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try all files in the directory for IMDb ID patterns
|
||||||
|
for file_path in series_dir.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
imdb_id = self.parse_imdb_from_path(file_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Finally, try tvshow.nfo file content
|
||||||
|
nfo_path = series_dir / "tvshow.nfo"
|
||||||
|
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||||
|
"""Extract NFOGuard-managed dates from existing NFO file"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Look for NFOGuard fields
|
||||||
|
dateadded_elem = root.find('.//dateadded')
|
||||||
|
premiered_elem = root.find('.//premiered')
|
||||||
|
aired_elem = root.find('.//aired') # For TV episodes
|
||||||
|
lockdata_elem = root.find('.//lockdata')
|
||||||
|
|
||||||
|
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
|
||||||
|
if lockdata_elem is not None and lockdata_elem.text == "true":
|
||||||
|
# Extract original source from NFOGuard comment, default to nfo_file_existing
|
||||||
|
source = "nfo_file_existing"
|
||||||
|
|
||||||
|
# Parse XML content to find NFOGuard comment with source
|
||||||
|
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||||
|
import re
|
||||||
|
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
|
||||||
|
if source_match:
|
||||||
|
source = source_match.group(1).strip()
|
||||||
|
print(f"🔍 Extracted original source from NFO comment: {source}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"source": source
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateadded_elem is not None and dateadded_elem.text:
|
||||||
|
result["dateadded"] = dateadded_elem.text.strip()
|
||||||
|
|
||||||
|
if premiered_elem is not None and premiered_elem.text:
|
||||||
|
result["released"] = premiered_elem.text.strip()
|
||||||
|
|
||||||
|
if aired_elem is not None and aired_elem.text:
|
||||||
|
result["aired"] = aired_elem.text.strip()
|
||||||
|
|
||||||
|
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception) as e:
|
||||||
|
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
|
||||||
|
"""Extract NFOGuard-managed dates from existing episode NFO file"""
|
||||||
|
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
|
||||||
|
nfo_path = season_path / nfo_filename
|
||||||
|
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Look for NFOGuard fields in episode NFO
|
||||||
|
dateadded_elem = root.find('.//dateadded')
|
||||||
|
aired_elem = root.find('.//aired')
|
||||||
|
lockdata_elem = root.find('.//lockdata')
|
||||||
|
|
||||||
|
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
|
||||||
|
if lockdata_elem is not None and lockdata_elem.text == "true":
|
||||||
|
# Extract original source from NFOGuard comment, default to episode_nfo_existing
|
||||||
|
source = "episode_nfo_existing"
|
||||||
|
|
||||||
|
# Parse XML content to find NFOGuard comment with source
|
||||||
|
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||||
|
import re
|
||||||
|
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
|
||||||
|
if source_match:
|
||||||
|
source = source_match.group(1).strip()
|
||||||
|
print(f"🔍 Extracted original source from episode NFO comment: {source}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"source": source
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateadded_elem is not None and dateadded_elem.text:
|
||||||
|
result["dateadded"] = dateadded_elem.text.strip()
|
||||||
|
|
||||||
|
if aired_elem is not None and aired_elem.text:
|
||||||
|
result["aired"] = aired_elem.text.strip()
|
||||||
|
|
||||||
|
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result.get('dateadded', 'None')}, source={source}, aired={result.get('aired', 'None')}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception) as e:
|
||||||
|
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_nfo_with_tolerance(self, nfo_path: Path):
|
||||||
|
"""Parse NFO file with tolerance for URLs appended after XML"""
|
||||||
|
try:
|
||||||
|
# First try normal parsing
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
return tree.getroot()
|
||||||
|
except ET.ParseError as e:
|
||||||
|
# If parsing fails, try to extract just the XML part
|
||||||
|
try:
|
||||||
|
with open(nfo_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find the last </movie> tag and truncate after it
|
||||||
|
last_movie_end = content.rfind('</movie>')
|
||||||
|
if last_movie_end != -1:
|
||||||
|
xml_content = content[:last_movie_end + 8] # +8 for </movie>
|
||||||
|
|
||||||
|
# Try parsing the truncated content
|
||||||
|
root = ET.fromstring(xml_content)
|
||||||
|
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
|
||||||
|
return root
|
||||||
|
else:
|
||||||
|
# Re-raise original error if we can't find </movie>
|
||||||
|
raise e
|
||||||
|
except Exception:
|
||||||
|
# Re-raise original error if our fix attempt fails
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
||||||
|
released: Optional[str] = None, source: str = "unknown",
|
||||||
|
lock_metadata: bool = True) -> None:
|
||||||
|
"""Create or update movie.nfo file preserving existing content"""
|
||||||
|
nfo_path = movie_dir / "movie.nfo"
|
||||||
|
|
||||||
|
# Debug output only if DEBUG=true in environment
|
||||||
|
import os
|
||||||
|
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||||
|
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
|
||||||
|
print(f"🔍 NFO path: {nfo_path}")
|
||||||
|
print(f"🔍 NFO exists: {nfo_path.exists()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to load existing NFO file
|
||||||
|
if nfo_path.exists():
|
||||||
|
try:
|
||||||
|
# Try to parse the XML, handling URLs appended after </movie>
|
||||||
|
movie = self._parse_nfo_with_tolerance(nfo_path)
|
||||||
|
|
||||||
|
# Ensure root element is <movie>
|
||||||
|
if movie.tag != "movie":
|
||||||
|
raise ValueError("Root element is not <movie>")
|
||||||
|
|
||||||
|
# Only remove elements that are clearly NFOGuard-managed
|
||||||
|
# Look for elements that have NFOGuard characteristics
|
||||||
|
elements_to_remove = []
|
||||||
|
|
||||||
|
# Remove lockdata=true (this is definitely NFOGuard)
|
||||||
|
for lockdata in movie.findall("lockdata"):
|
||||||
|
if lockdata.text == "true":
|
||||||
|
elements_to_remove.append(lockdata)
|
||||||
|
|
||||||
|
# Remove IMDb uniqueids that we manage
|
||||||
|
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
||||||
|
elements_to_remove.append(uniqueid)
|
||||||
|
|
||||||
|
# For dateadded/premiered/year, only remove if they appear to be NFOGuard-managed
|
||||||
|
# (i.e., if lockdata=true exists, these are likely ours)
|
||||||
|
has_nfoguard_lockdata = any(ld.text == "true" for ld in movie.findall("lockdata"))
|
||||||
|
|
||||||
|
if has_nfoguard_lockdata:
|
||||||
|
# This NFO was managed by NFOGuard, safe to remove our fields
|
||||||
|
for tag in ["dateadded", "premiered", "year"]:
|
||||||
|
existing = movie.find(tag)
|
||||||
|
if existing is not None:
|
||||||
|
# Store the value before removing (for premiered/year)
|
||||||
|
if tag == "premiered" and not released:
|
||||||
|
print(f"🔍 Preserving existing premiered date: {existing.text}")
|
||||||
|
released = existing.text
|
||||||
|
elements_to_remove.append(existing)
|
||||||
|
else:
|
||||||
|
# No NFOGuard lockdata found, be more conservative
|
||||||
|
# Only remove dateadded if it looks like NFOGuard format (ISO timestamp)
|
||||||
|
dateadded_elem = movie.find("dateadded")
|
||||||
|
if dateadded_elem is not None and dateadded_elem.text:
|
||||||
|
# NFOGuard uses ISO format like "2025-10-12 16:26:02"
|
||||||
|
if re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', dateadded_elem.text.strip()):
|
||||||
|
elements_to_remove.append(dateadded_elem)
|
||||||
|
|
||||||
|
# Remove all identified elements
|
||||||
|
for elem in elements_to_remove:
|
||||||
|
movie.remove(elem)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Create all NFOGuard elements first, then append them in correct order
|
||||||
|
# This ensures they appear as a group at the bottom of the file
|
||||||
|
nfoguard_elements = []
|
||||||
|
|
||||||
|
# Add NFOGuard comment marker as the first of our additions
|
||||||
|
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
|
||||||
|
nfoguard_elements.append(nfoguard_comment)
|
||||||
|
|
||||||
|
# Add IMDb uniqueid
|
||||||
|
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
|
||||||
|
uniqueid.text = imdb_id
|
||||||
|
nfoguard_elements.append(uniqueid)
|
||||||
|
|
||||||
|
# Add premiered date if we have it
|
||||||
|
if released:
|
||||||
|
premiered_elem = ET.Element("premiered")
|
||||||
|
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
||||||
|
nfoguard_elements.append(premiered_elem)
|
||||||
|
|
||||||
|
# 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.Element("year")
|
||||||
|
year_elem.text = year_value
|
||||||
|
nfoguard_elements.append(year_elem)
|
||||||
|
except:
|
||||||
|
pass # Skip year if we can't extract it
|
||||||
|
|
||||||
|
# Add dateadded - THIS IS CRITICAL FOR EMBY PLUGIN
|
||||||
|
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||||
|
print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})")
|
||||||
|
if dateadded:
|
||||||
|
dateadded_elem = ET.Element("dateadded")
|
||||||
|
dateadded_elem.text = dateadded
|
||||||
|
nfoguard_elements.append(dateadded_elem)
|
||||||
|
print(f"✅ Adding dateadded to NFO: {dateadded}")
|
||||||
|
else:
|
||||||
|
print(f"❌ dateadded is empty/None, not adding to NFO")
|
||||||
|
|
||||||
|
# Add lockdata at the very end
|
||||||
|
if lock_metadata:
|
||||||
|
lockdata = ET.Element("lockdata")
|
||||||
|
lockdata.text = "true"
|
||||||
|
nfoguard_elements.append(lockdata)
|
||||||
|
|
||||||
|
# Now append all NFOGuard elements to the movie in one batch
|
||||||
|
# This ensures they appear as a contiguous block at the bottom
|
||||||
|
for elem in nfoguard_elements:
|
||||||
|
movie.append(elem)
|
||||||
|
|
||||||
|
print(f"✅ Added {len(nfoguard_elements)} NFOGuard elements to bottom of NFO")
|
||||||
|
|
||||||
|
# Write file with proper formatting
|
||||||
|
tree = ET.ElementTree(movie)
|
||||||
|
ET.indent(tree, space=" ", level=0)
|
||||||
|
|
||||||
|
# Write directly to file (comment is already embedded in XML)
|
||||||
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
|
||||||
|
tree.write(f, encoding='unicode', xml_declaration=False)
|
||||||
|
|
||||||
|
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 <tvshow>
|
||||||
|
if tvshow.tag != "tvshow":
|
||||||
|
raise ValueError("Root element is not <tvshow>")
|
||||||
|
|
||||||
|
# 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'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\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 <season>
|
||||||
|
if season.tag != "season":
|
||||||
|
raise ValueError("Root element is not <season>")
|
||||||
|
|
||||||
|
# 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'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||||
|
print(f" Season: {season_number}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
|
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||||
|
"""Find any existing episode NFO file that matches season/episode"""
|
||||||
|
if not season_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for NFO files in the season directory
|
||||||
|
for nfo_file in season_dir.glob("*.nfo"):
|
||||||
|
|
||||||
|
# Check if this NFO contains the right season/episode
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_file)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
if root.tag == "episodedetails":
|
||||||
|
# Check for season/episode elements
|
||||||
|
season_elem = root.find("season")
|
||||||
|
episode_elem = root.find("episode")
|
||||||
|
|
||||||
|
if (season_elem is not None and episode_elem is not None and
|
||||||
|
season_elem.text and episode_elem.text):
|
||||||
|
try:
|
||||||
|
file_season = int(season_elem.text)
|
||||||
|
file_episode = int(episode_elem.text)
|
||||||
|
|
||||||
|
if file_season == season_num and file_episode == episode_num:
|
||||||
|
print(f"🔍 Found existing episode NFO: {nfo_file.name}")
|
||||||
|
return nfo_file
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception):
|
||||||
|
# Skip corrupted or non-XML files
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
|
||||||
|
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
|
||||||
|
if not season_dir.exists():
|
||||||
|
return f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||||
|
|
||||||
|
# First check if an NFO already exists for this episode
|
||||||
|
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||||
|
if existing_nfo:
|
||||||
|
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
|
||||||
|
return existing_nfo.name
|
||||||
|
|
||||||
|
# Look for video files with matching season/episode
|
||||||
|
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
|
||||||
|
|
||||||
|
for video_file in season_dir.iterdir():
|
||||||
|
if (video_file.is_file() and
|
||||||
|
video_file.suffix.lower() in video_extensions):
|
||||||
|
|
||||||
|
# Parse episode info from video filename
|
||||||
|
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||||
|
if episode_info and episode_info == (season_num, episode_num):
|
||||||
|
# Found matching video file - use its name for NFO
|
||||||
|
target_nfo_name = f"{video_file.stem}.nfo"
|
||||||
|
print(f"🎯 Target NFO will match video: {target_nfo_name}")
|
||||||
|
return target_nfo_name
|
||||||
|
|
||||||
|
# Fallback to short name if no matching video found
|
||||||
|
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||||
|
print(f"⚠️ No matching video file found, using short name: {short_name}")
|
||||||
|
return short_name
|
||||||
|
|
||||||
|
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Parse season and episode numbers from filename"""
|
||||||
|
# Try S##E## format first
|
||||||
|
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
# Try ##x## format
|
||||||
|
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1)), int(match.group(2))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||||
|
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||||
|
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
"""Create or update episode NFO file preserving existing content"""
|
||||||
|
# Get target NFO filename (prefer long name matching video file)
|
||||||
|
target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
|
||||||
|
nfo_path = season_dir / target_nfo_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for existing NFO file at target location
|
||||||
|
source_nfo_path = nfo_path if nfo_path.exists() else None
|
||||||
|
|
||||||
|
if source_nfo_path:
|
||||||
|
try:
|
||||||
|
tree = ET.parse(source_nfo_path)
|
||||||
|
episode = tree.getroot()
|
||||||
|
|
||||||
|
# Ensure root element is <episodedetails>
|
||||||
|
if episode.tag != "episodedetails":
|
||||||
|
raise ValueError("Root element is not <episodedetails>")
|
||||||
|
|
||||||
|
print(f"📝 Updating existing NFO: {nfo_path.name}")
|
||||||
|
|
||||||
|
# Preserve existing content fields and store NFOGuard-managed fields for re-adding
|
||||||
|
preserved_values = {}
|
||||||
|
|
||||||
|
# Extract and preserve NFOGuard-managed fields (we'll re-add these 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 value before removing
|
||||||
|
if tag == "aired" and not aired:
|
||||||
|
aired = existing.text # Preserve existing aired date
|
||||||
|
preserved_values[tag] = existing.text
|
||||||
|
episode.remove(existing)
|
||||||
|
|
||||||
|
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
|
||||||
|
# These should be preserved from the long-named NFO files
|
||||||
|
|
||||||
|
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
|
||||||
|
if self.debug:
|
||||||
|
preserved_fields = [elem.tag for elem in episode]
|
||||||
|
if preserved_fields:
|
||||||
|
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
|
||||||
|
else:
|
||||||
|
print(f" ℹ️ NFO contains only NFOGuard metadata (no additional content fields)")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
|
||||||
|
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")
|
||||||
|
# Convert datetime objects to strings
|
||||||
|
aired_str = str(aired)
|
||||||
|
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
|
||||||
|
|
||||||
|
if dateadded:
|
||||||
|
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||||
|
# Convert datetime objects to strings
|
||||||
|
dateadded_elem.text = str(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'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\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}")
|
||||||
|
|
||||||
|
# NFO file created/updated successfully
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
|
def set_file_mtime(self, file_path: Path, iso_timestamp) -> None:
|
||||||
|
"""Set file modification time to match import date"""
|
||||||
|
try:
|
||||||
|
# Convert datetime objects to strings first
|
||||||
|
if hasattr(iso_timestamp, 'isoformat'):
|
||||||
|
iso_timestamp = iso_timestamp.isoformat()
|
||||||
|
elif not isinstance(iso_timestamp, str):
|
||||||
|
iso_timestamp = str(iso_timestamp)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
elif ' ' in iso_timestamp:
|
||||||
|
# Handle space-separated datetime format (e.g., "2025-10-16 20:31:22")
|
||||||
|
dt = datetime.fromisoformat(iso_timestamp.replace(' ', 'T'))
|
||||||
|
else:
|
||||||
|
# Assume it's a simple date (e.g., "2025-10-16")
|
||||||
|
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}")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug script to check specific movie data in NFOGuard database
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the project root to the path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from core.database import NFOGuardDatabase
|
||||||
|
|
||||||
|
def debug_movie(imdb_id: str):
|
||||||
|
"""Debug a specific movie's data"""
|
||||||
|
print(f"🔍 DEBUG MOVIE: {imdb_id}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db = NFOGuardDatabase()
|
||||||
|
|
||||||
|
# Get movie data
|
||||||
|
movie = db.get_movie_dates(imdb_id)
|
||||||
|
if not movie:
|
||||||
|
print(f"❌ Movie {imdb_id} not found in database")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("📊 RAW MOVIE DATA:")
|
||||||
|
for key, value in movie.items():
|
||||||
|
print(f" {key}: {repr(value)}")
|
||||||
|
|
||||||
|
print("\n🎬 FORMATTED MOVIE DATA:")
|
||||||
|
print(f" Title/Path: {movie.get('path', 'Unknown')}")
|
||||||
|
print(f" Released: {movie.get('released', 'None')}")
|
||||||
|
print(f" Date Added: {movie.get('dateadded', 'None')}")
|
||||||
|
print(f" Source: {movie.get('source', 'None')}")
|
||||||
|
print(f" Has Video: {movie.get('has_video_file', False)}")
|
||||||
|
print(f" Last Updated: {movie.get('last_updated', 'None')}")
|
||||||
|
|
||||||
|
# Check if released date is valid
|
||||||
|
released = movie.get('released')
|
||||||
|
if released and released.strip():
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
test_date = f"{released}T00:00:00"
|
||||||
|
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
|
||||||
|
print(f"\n✅ Released date is valid: {parsed}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Released date is INVALID: {e}")
|
||||||
|
else:
|
||||||
|
print(f"\n⚠️ Released date is empty or None")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python debug_movie.py <imdb_id>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
imdb_id = sys.argv[1]
|
||||||
|
if not imdb_id.startswith('tt'):
|
||||||
|
imdb_id = f'tt{imdb_id}'
|
||||||
|
|
||||||
|
debug_movie(imdb_id)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
nfoguard:
|
||||||
|
# Use the official image from Docker Hub
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
|
||||||
|
# Alternative: Use specific version
|
||||||
|
# image: sbcrumb/nfoguard:v2.6.5
|
||||||
|
|
||||||
|
# Alternative: Use development version
|
||||||
|
# image: sbcrumb/nfoguard:dev
|
||||||
|
|
||||||
|
container_name: nfoguard
|
||||||
|
|
||||||
|
# Database dependency
|
||||||
|
depends_on:
|
||||||
|
- nfoguard-postgres
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Graceful shutdown configuration
|
||||||
|
stop_grace_period: 30s
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- .env.secrets
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
environment:
|
||||||
|
- TZ=America/New_York # Set to your local timezone
|
||||||
|
# Alternative examples:
|
||||||
|
# - TZ=Europe/London
|
||||||
|
# - TZ=America/Los_Angeles
|
||||||
|
# - TZ=Australia/Sydney
|
||||||
|
# - TZ=UTC
|
||||||
|
|
||||||
|
# Port mapping (adjust if needed)
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# Volume mounts - CRITICAL: These must match your media setup
|
||||||
|
volumes:
|
||||||
|
# NFOGuard data directory (database, logs, cache)
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# Bind mount your Emby plugins directory for automatic plugin deployment
|
||||||
|
- ${EMBY_PLUGINS_PATH}:/emby-plugins
|
||||||
|
|
||||||
|
# Movie libraries - Mount your movie directories here
|
||||||
|
# Format: host_path:container_path:rw
|
||||||
|
- /path/to/your/movies:/media/Movies/movies:rw
|
||||||
|
- /path/to/your/movies2:/media/Movies/movies6:rw
|
||||||
|
|
||||||
|
# TV libraries - Mount your TV show directories here
|
||||||
|
- /path/to/your/tv:/media/TV/tv:rw
|
||||||
|
- /path/to/your/tv2:/media/TV/tv6:rw
|
||||||
|
|
||||||
|
# Optional: Mount download directories for detection
|
||||||
|
# - /path/to/downloads:/downloads:ro
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Resource limits (optional)
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
# Network (optional - use if you have a custom network)
|
||||||
|
# networks:
|
||||||
|
# - media-network
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# PostgreSQL Database (Required for v2.6+)
|
||||||
|
nfoguard-postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: nfoguard-db
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Environment variables for PostgreSQL
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=nfoguard
|
||||||
|
- POSTGRES_USER=nfoguard
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD} # Set in .env.secrets
|
||||||
|
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
|
||||||
|
|
||||||
|
# PostgreSQL data persistence
|
||||||
|
volumes:
|
||||||
|
- ./postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# PostgreSQL port (optional - only needed for external access)
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
|
||||||
|
# Health check for database
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U nfoguard -d nfoguard"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# Resource limits for database
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.3'
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
|
cpus: '0.1'
|
||||||
|
|
||||||
|
# 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.example to .env and configure all settings
|
||||||
|
# 4. Copy .env.secrets.example to .env.secrets and add credentials (especially DB_PASSWORD)
|
||||||
|
# 5. Create directories: mkdir -p ./data ./postgres-data
|
||||||
|
# 6. Run: docker-compose up -d
|
||||||
|
# 7. Check logs: docker-compose logs -f
|
||||||
|
# 8. Access web interface: http://localhost:8080
|
||||||
|
# 9. 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
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
NFOGuard - Automated NFO file management for Radarr and Sonarr
|
||||||
|
Modular architecture with webhook processing and intelligent date handling
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
# Import configuration first
|
||||||
|
from config.settings import config
|
||||||
|
from utils.logging import _log
|
||||||
|
|
||||||
|
# Import core components
|
||||||
|
from core.database import NFOGuardDatabase
|
||||||
|
from core.nfo_manager import NFOManager
|
||||||
|
from core.path_mapper import PathMapper
|
||||||
|
|
||||||
|
# Import clients
|
||||||
|
from clients.external_clients import ExternalClientManager
|
||||||
|
|
||||||
|
# Import processors
|
||||||
|
from processors.tv_processor import TVProcessor
|
||||||
|
from processors.movie_processor import MovieProcessor
|
||||||
|
|
||||||
|
# Import webhook handling
|
||||||
|
from webhooks.webhook_batcher import WebhookBatcher
|
||||||
|
|
||||||
|
# Import API routes
|
||||||
|
from api.routes import register_routes
|
||||||
|
|
||||||
|
# Global shutdown event for graceful shutdown coordination
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
"""Get application 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
|
||||||
|
|
||||||
|
# Check for build source (only add -gitea for local Gitea builds)
|
||||||
|
build_source = os.environ.get("BUILD_SOURCE", "")
|
||||||
|
if build_source == "gitea":
|
||||||
|
if "gitea" not in version: # Don't double-add gitea suffix
|
||||||
|
version = f"{version}-gitea"
|
||||||
|
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create and configure the FastAPI application"""
|
||||||
|
version = get_version()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="NFOGuard",
|
||||||
|
description="Webhook server for preserving media import dates",
|
||||||
|
version=version
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_components():
|
||||||
|
"""Initialize all application components"""
|
||||||
|
start_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Initialize core components
|
||||||
|
db = NFOGuardDatabase(config=config)
|
||||||
|
nfo_manager = NFOManager(config.manager_brand, config.debug)
|
||||||
|
path_mapper = PathMapper(config)
|
||||||
|
|
||||||
|
# Initialize processors
|
||||||
|
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
|
||||||
|
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
|
||||||
|
|
||||||
|
# Initialize webhook batcher with nfo_manager for comprehensive IMDb detection
|
||||||
|
batcher = WebhookBatcher(nfo_manager)
|
||||||
|
batcher.set_processors(tv_processor, movie_processor)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"db": db,
|
||||||
|
"nfo_manager": nfo_manager,
|
||||||
|
"path_mapper": path_mapper,
|
||||||
|
"tv_processor": tv_processor,
|
||||||
|
"movie_processor": movie_processor,
|
||||||
|
"batcher": batcher,
|
||||||
|
"start_time": start_time,
|
||||||
|
"config": config,
|
||||||
|
"version": get_version(),
|
||||||
|
"shutdown_event": shutdown_event
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
"""Handle shutdown signals gracefully"""
|
||||||
|
_log("INFO", f"Received signal {signum}, shutting down gracefully...")
|
||||||
|
|
||||||
|
# Set shutdown event to notify background tasks
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
# Get the global dependencies if they exist
|
||||||
|
if hasattr(signal_handler, 'dependencies') and signal_handler.dependencies:
|
||||||
|
deps = signal_handler.dependencies
|
||||||
|
|
||||||
|
# Shutdown webhook batcher cleanly
|
||||||
|
if 'batcher' in deps:
|
||||||
|
try:
|
||||||
|
_log("INFO", "Shutting down webhook batcher...")
|
||||||
|
deps['batcher'].shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Error during batcher shutdown: {e}")
|
||||||
|
|
||||||
|
# Close database connection
|
||||||
|
if 'db' in deps:
|
||||||
|
try:
|
||||||
|
_log("INFO", "Closing database connection...")
|
||||||
|
deps['db'].close()
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Error closing database: {e}")
|
||||||
|
|
||||||
|
_log("INFO", "Graceful shutdown complete")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main application entry point"""
|
||||||
|
# Register signal handlers for graceful shutdown
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
version = get_version()
|
||||||
|
|
||||||
|
_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]}")
|
||||||
|
if config.db_type == "postgresql":
|
||||||
|
_log("INFO", f"Database: PostgreSQL at {config.db_host}:{config.db_port}/{config.db_name}")
|
||||||
|
_log("INFO", f"Database user: {config.db_user}")
|
||||||
|
else:
|
||||||
|
_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}")
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
dependencies = initialize_components()
|
||||||
|
|
||||||
|
# Store dependencies globally for signal handler access
|
||||||
|
signal_handler.dependencies = dependencies
|
||||||
|
|
||||||
|
# Register routes
|
||||||
|
register_routes(app, dependencies)
|
||||||
|
|
||||||
|
try:
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=int(os.environ.get("PORT", "8080")),
|
||||||
|
reload=False,
|
||||||
|
access_log=False, # Reduce logging overhead
|
||||||
|
server_header=False, # Reduce response overhead
|
||||||
|
timeout_graceful_shutdown=15 # Give more time for graceful shutdown
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_log("INFO", "NFOGuard stopped by user")
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"NFOGuard crashed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
# Ensure cleanup happens even if uvicorn doesn't trigger signal handler
|
||||||
|
if hasattr(signal_handler, 'dependencies') and signal_handler.dependencies:
|
||||||
|
deps = signal_handler.dependencies
|
||||||
|
|
||||||
|
if 'batcher' in deps:
|
||||||
|
try:
|
||||||
|
deps['batcher'].shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'db' in deps:
|
||||||
|
try:
|
||||||
|
deps['db'].close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
"""
|
||||||
|
Health Check System for NFOGuard
|
||||||
|
Provides health and readiness endpoints for monitoring and orchestration
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config.runtime_validator import RuntimeValidator, HealthCheckResult
|
||||||
|
from monitoring.metrics import metrics
|
||||||
|
|
||||||
|
|
||||||
|
class HealthStatus(Enum):
|
||||||
|
"""Health check status levels"""
|
||||||
|
HEALTHY = "healthy"
|
||||||
|
DEGRADED = "degraded"
|
||||||
|
UNHEALTHY = "unhealthy"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthCheck:
|
||||||
|
"""Individual health check result"""
|
||||||
|
name: str
|
||||||
|
status: HealthStatus
|
||||||
|
message: str
|
||||||
|
duration_ms: float
|
||||||
|
details: Dict[str, Any] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"status": self.status.value,
|
||||||
|
"message": self.message,
|
||||||
|
"duration_ms": round(self.duration_ms, 2),
|
||||||
|
"details": self.details or {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OverallHealth:
|
||||||
|
"""Overall system health status"""
|
||||||
|
status: HealthStatus
|
||||||
|
checks: List[HealthCheck]
|
||||||
|
timestamp: float
|
||||||
|
uptime_seconds: float
|
||||||
|
version: str = "2.0.0"
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": self.status.value,
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
"uptime_seconds": round(self.uptime_seconds, 2),
|
||||||
|
"version": self.version,
|
||||||
|
"checks": [check.to_dict() for check in self.checks],
|
||||||
|
"summary": {
|
||||||
|
"total_checks": len(self.checks),
|
||||||
|
"healthy_checks": len([c for c in self.checks if c.status == HealthStatus.HEALTHY]),
|
||||||
|
"degraded_checks": len([c for c in self.checks if c.status == HealthStatus.DEGRADED]),
|
||||||
|
"unhealthy_checks": len([c for c in self.checks if c.status == HealthStatus.UNHEALTHY])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HealthChecker:
|
||||||
|
"""Comprehensive health checking system"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.start_time = time.time()
|
||||||
|
self._last_health_check = None
|
||||||
|
self._health_check_cache_ttl = 30 # Cache for 30 seconds
|
||||||
|
self._runtime_validator = None
|
||||||
|
|
||||||
|
def _get_runtime_validator(self):
|
||||||
|
"""Get runtime validator instance"""
|
||||||
|
if self._runtime_validator is None:
|
||||||
|
try:
|
||||||
|
from config.settings import config
|
||||||
|
self._runtime_validator = RuntimeValidator(config)
|
||||||
|
except Exception as e:
|
||||||
|
# Create a dummy validator if config fails
|
||||||
|
self._runtime_validator = None
|
||||||
|
return self._runtime_validator
|
||||||
|
|
||||||
|
async def check_basic_health(self) -> HealthCheck:
|
||||||
|
"""Basic health check - always succeeds if service is running"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Basic service availability
|
||||||
|
uptime = time.time() - self.start_time
|
||||||
|
|
||||||
|
if uptime < 30:
|
||||||
|
status = HealthStatus.DEGRADED
|
||||||
|
message = f"Service starting up (uptime: {uptime:.1f}s)"
|
||||||
|
else:
|
||||||
|
status = HealthStatus.HEALTHY
|
||||||
|
message = f"Service running normally (uptime: {uptime:.1f}s)"
|
||||||
|
|
||||||
|
return HealthCheck(
|
||||||
|
name="basic",
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
duration_ms=(time.time() - start_time) * 1000,
|
||||||
|
details={"uptime_seconds": uptime}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheck(
|
||||||
|
name="basic",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Basic health check failed: {e}",
|
||||||
|
duration_ms=(time.time() - start_time) * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_filesystem_health(self) -> HealthCheck:
|
||||||
|
"""Check filesystem access for media paths"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config.settings import config
|
||||||
|
|
||||||
|
accessible_paths = 0
|
||||||
|
total_paths = len(config.tv_paths) + len(config.movie_paths)
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Check TV paths
|
||||||
|
for path in config.tv_paths:
|
||||||
|
try:
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
# Try to read directory
|
||||||
|
list(path.iterdir())
|
||||||
|
accessible_paths += 1
|
||||||
|
else:
|
||||||
|
issues.append(f"TV path not accessible: {path}")
|
||||||
|
except PermissionError:
|
||||||
|
issues.append(f"TV path permission denied: {path}")
|
||||||
|
except Exception as e:
|
||||||
|
issues.append(f"TV path error {path}: {e}")
|
||||||
|
|
||||||
|
# Check movie paths
|
||||||
|
for path in config.movie_paths:
|
||||||
|
try:
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
list(path.iterdir())
|
||||||
|
accessible_paths += 1
|
||||||
|
else:
|
||||||
|
issues.append(f"Movie path not accessible: {path}")
|
||||||
|
except PermissionError:
|
||||||
|
issues.append(f"Movie path permission denied: {path}")
|
||||||
|
except Exception as e:
|
||||||
|
issues.append(f"Movie path error {path}: {e}")
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
if accessible_paths == total_paths:
|
||||||
|
status = HealthStatus.HEALTHY
|
||||||
|
message = f"All {total_paths} media paths accessible"
|
||||||
|
elif accessible_paths > 0:
|
||||||
|
status = HealthStatus.DEGRADED
|
||||||
|
message = f"{accessible_paths}/{total_paths} media paths accessible"
|
||||||
|
else:
|
||||||
|
status = HealthStatus.UNHEALTHY
|
||||||
|
message = "No media paths accessible"
|
||||||
|
|
||||||
|
return HealthCheck(
|
||||||
|
name="filesystem",
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
duration_ms=(time.time() - start_time) * 1000,
|
||||||
|
details={
|
||||||
|
"accessible_paths": accessible_paths,
|
||||||
|
"total_paths": total_paths,
|
||||||
|
"issues": issues[:5] # Limit to first 5 issues
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheck(
|
||||||
|
name="filesystem",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Filesystem check failed: {e}",
|
||||||
|
duration_ms=(time.time() - start_time) * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_database_health(self) -> HealthCheck:
|
||||||
|
"""Check database connectivity and performance"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
from config.settings import config
|
||||||
|
|
||||||
|
# Test local database
|
||||||
|
db_path = config.db_path
|
||||||
|
|
||||||
|
def test_db():
|
||||||
|
with sqlite3.connect(str(db_path), timeout=5) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")
|
||||||
|
table_count = cursor.fetchone()[0]
|
||||||
|
return table_count
|
||||||
|
|
||||||
|
# Run database test
|
||||||
|
table_count = await asyncio.get_event_loop().run_in_executor(None, test_db)
|
||||||
|
|
||||||
|
duration = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
if duration < 100: # < 100ms is good
|
||||||
|
status = HealthStatus.HEALTHY
|
||||||
|
message = f"Database responsive ({duration:.1f}ms, {table_count} tables)"
|
||||||
|
elif duration < 1000: # < 1s is acceptable
|
||||||
|
status = HealthStatus.DEGRADED
|
||||||
|
message = f"Database slow ({duration:.1f}ms, {table_count} tables)"
|
||||||
|
else:
|
||||||
|
status = HealthStatus.UNHEALTHY
|
||||||
|
message = f"Database very slow ({duration:.1f}ms)"
|
||||||
|
|
||||||
|
return HealthCheck(
|
||||||
|
name="database",
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
duration_ms=duration,
|
||||||
|
details={
|
||||||
|
"db_path": str(db_path),
|
||||||
|
"table_count": table_count,
|
||||||
|
"response_time_category": "fast" if duration < 100 else "slow" if duration < 1000 else "very_slow"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheck(
|
||||||
|
name="database",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Database check failed: {e}",
|
||||||
|
duration_ms=(time.time() - start_time) * 1000,
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_external_apis_health(self) -> HealthCheck:
|
||||||
|
"""Check external API connectivity"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
from config.settings import config
|
||||||
|
|
||||||
|
api_results = []
|
||||||
|
apis_tested = 0
|
||||||
|
apis_healthy = 0
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=5)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
# Test Radarr API if configured
|
||||||
|
if hasattr(config, 'radarr_url') and config.radarr_url:
|
||||||
|
apis_tested += 1
|
||||||
|
try:
|
||||||
|
test_url = f"{config.radarr_url.rstrip('/')}/api/v3/health"
|
||||||
|
async with session.get(test_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
apis_healthy += 1
|
||||||
|
api_results.append({"api": "radarr", "status": "healthy"})
|
||||||
|
else:
|
||||||
|
api_results.append({"api": "radarr", "status": f"unhealthy (HTTP {response.status})"})
|
||||||
|
except Exception as e:
|
||||||
|
api_results.append({"api": "radarr", "status": f"error: {str(e)[:50]}"})
|
||||||
|
|
||||||
|
# Test Sonarr API if configured
|
||||||
|
if hasattr(config, 'sonarr_url') and config.sonarr_url:
|
||||||
|
apis_tested += 1
|
||||||
|
try:
|
||||||
|
test_url = f"{config.sonarr_url.rstrip('/')}/api/v3/health"
|
||||||
|
async with session.get(test_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
apis_healthy += 1
|
||||||
|
api_results.append({"api": "sonarr", "status": "healthy"})
|
||||||
|
else:
|
||||||
|
api_results.append({"api": "sonarr", "status": f"unhealthy (HTTP {response.status})"})
|
||||||
|
except Exception as e:
|
||||||
|
api_results.append({"api": "sonarr", "status": f"error: {str(e)[:50]}"})
|
||||||
|
|
||||||
|
# Determine overall API health
|
||||||
|
if apis_tested == 0:
|
||||||
|
status = HealthStatus.HEALTHY
|
||||||
|
message = "No external APIs configured"
|
||||||
|
elif apis_healthy == apis_tested:
|
||||||
|
status = HealthStatus.HEALTHY
|
||||||
|
message = f"All {apis_tested} external APIs healthy"
|
||||||
|
elif apis_healthy > 0:
|
||||||
|
status = HealthStatus.DEGRADED
|
||||||
|
message = f"{apis_healthy}/{apis_tested} external APIs healthy"
|
||||||
|
else:
|
||||||
|
status = HealthStatus.UNHEALTHY
|
||||||
|
message = "No external APIs responding"
|
||||||
|
|
||||||
|
return HealthCheck(
|
||||||
|
name="external_apis",
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
duration_ms=(time.time() - start_time) * 1000,
|
||||||
|
details={
|
||||||
|
"apis_tested": apis_tested,
|
||||||
|
"apis_healthy": apis_healthy,
|
||||||
|
"api_results": api_results
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheck(
|
||||||
|
name="external_apis",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"API health check failed: {e}",
|
||||||
|
duration_ms=(time.time() - start_time) * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_performance_health(self) -> HealthCheck:
|
||||||
|
"""Check system performance metrics"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
system_metrics = metrics.get_system_metrics()
|
||||||
|
processing_metrics = metrics.get_processing_metrics()
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# Check CPU usage
|
||||||
|
cpu_percent = system_metrics.get("cpu_percent", 0)
|
||||||
|
if cpu_percent > 90:
|
||||||
|
issues.append(f"High CPU usage: {cpu_percent:.1f}%")
|
||||||
|
elif cpu_percent > 70:
|
||||||
|
warnings.append(f"Elevated CPU usage: {cpu_percent:.1f}%")
|
||||||
|
|
||||||
|
# Check memory usage
|
||||||
|
memory_percent = system_metrics.get("memory_percent", 0)
|
||||||
|
if memory_percent > 90:
|
||||||
|
issues.append(f"High memory usage: {memory_percent:.1f}%")
|
||||||
|
elif memory_percent > 80:
|
||||||
|
warnings.append(f"Elevated memory usage: {memory_percent:.1f}%")
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
if "db_disk_free" in system_metrics and system_metrics["db_disk_free"]:
|
||||||
|
free_space_gb = system_metrics["db_disk_free"] / (1024**3)
|
||||||
|
if free_space_gb < 1:
|
||||||
|
issues.append(f"Low disk space: {free_space_gb:.1f}GB free")
|
||||||
|
elif free_space_gb < 5:
|
||||||
|
warnings.append(f"Low disk space: {free_space_gb:.1f}GB free")
|
||||||
|
|
||||||
|
# Check active operations
|
||||||
|
active_ops = system_metrics.get("active_operations", 0)
|
||||||
|
if active_ops > 10:
|
||||||
|
warnings.append(f"High concurrent operations: {active_ops}")
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
if issues:
|
||||||
|
status = HealthStatus.UNHEALTHY
|
||||||
|
message = f"Performance issues detected: {', '.join(issues[:2])}"
|
||||||
|
elif warnings:
|
||||||
|
status = HealthStatus.DEGRADED
|
||||||
|
message = f"Performance warnings: {', '.join(warnings[:2])}"
|
||||||
|
else:
|
||||||
|
status = HealthStatus.HEALTHY
|
||||||
|
message = "System performance normal"
|
||||||
|
|
||||||
|
return HealthCheck(
|
||||||
|
name="performance",
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
duration_ms=(time.time() - start_time) * 1000,
|
||||||
|
details={
|
||||||
|
"cpu_percent": cpu_percent,
|
||||||
|
"memory_percent": memory_percent,
|
||||||
|
"active_operations": active_ops,
|
||||||
|
"issues": issues,
|
||||||
|
"warnings": warnings
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheck(
|
||||||
|
name="performance",
|
||||||
|
status=HealthStatus.DEGRADED,
|
||||||
|
message=f"Performance check failed: {e}",
|
||||||
|
duration_ms=(time.time() - start_time) * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_full_health_status(self) -> OverallHealth:
|
||||||
|
"""Get comprehensive health status"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Run all health checks concurrently
|
||||||
|
checks = await asyncio.gather(
|
||||||
|
self.check_basic_health(),
|
||||||
|
self.check_filesystem_health(),
|
||||||
|
self.check_database_health(),
|
||||||
|
self.check_external_apis_health(),
|
||||||
|
self.check_performance_health(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter out any exceptions and convert to HealthCheck objects
|
||||||
|
valid_checks = []
|
||||||
|
for check in checks:
|
||||||
|
if isinstance(check, HealthCheck):
|
||||||
|
valid_checks.append(check)
|
||||||
|
elif isinstance(check, Exception):
|
||||||
|
valid_checks.append(HealthCheck(
|
||||||
|
name="unknown",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Health check exception: {check}",
|
||||||
|
duration_ms=0
|
||||||
|
))
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
unhealthy_count = len([c for c in valid_checks if c.status == HealthStatus.UNHEALTHY])
|
||||||
|
degraded_count = len([c for c in valid_checks if c.status == HealthStatus.DEGRADED])
|
||||||
|
|
||||||
|
if unhealthy_count > 0:
|
||||||
|
overall_status = HealthStatus.UNHEALTHY
|
||||||
|
elif degraded_count > 0:
|
||||||
|
overall_status = HealthStatus.DEGRADED
|
||||||
|
else:
|
||||||
|
overall_status = HealthStatus.HEALTHY
|
||||||
|
|
||||||
|
return OverallHealth(
|
||||||
|
status=overall_status,
|
||||||
|
checks=valid_checks,
|
||||||
|
timestamp=start_time,
|
||||||
|
uptime_seconds=time.time() - self.start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_readiness_status(self) -> Dict[str, Any]:
|
||||||
|
"""Get readiness status for Kubernetes readiness probes"""
|
||||||
|
# Readiness is simpler - just check critical components
|
||||||
|
checks = await asyncio.gather(
|
||||||
|
self.check_basic_health(),
|
||||||
|
self.check_filesystem_health(),
|
||||||
|
self.check_database_health(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
critical_failures = 0
|
||||||
|
for check in checks:
|
||||||
|
if isinstance(check, HealthCheck) and check.status == HealthStatus.UNHEALTHY:
|
||||||
|
critical_failures += 1
|
||||||
|
|
||||||
|
is_ready = critical_failures == 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ready": is_ready,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"critical_failures": critical_failures,
|
||||||
|
"message": "Service ready" if is_ready else f"{critical_failures} critical failures"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_liveness_status(self) -> Dict[str, Any]:
|
||||||
|
"""Get liveness status for Kubernetes liveness probes"""
|
||||||
|
# Liveness is even simpler - just check if service is responsive
|
||||||
|
basic_check = await self.check_basic_health()
|
||||||
|
|
||||||
|
is_alive = basic_check.status != HealthStatus.UNHEALTHY
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alive": is_alive,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"uptime_seconds": time.time() - self.start_time,
|
||||||
|
"message": basic_check.message
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global health checker instance
|
||||||
|
health_checker = HealthChecker()
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""
|
||||||
|
Enhanced Logging System for NFOGuard
|
||||||
|
Provides structured logging with correlation IDs, request tracing, and monitoring integration
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
from typing import Dict, Any, Optional, List, Union
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from monitoring.metrics import metrics
|
||||||
|
|
||||||
|
|
||||||
|
# Thread-local storage for correlation context
|
||||||
|
_context = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LogContext:
|
||||||
|
"""Logging context with correlation and tracing information"""
|
||||||
|
correlation_id: str
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
operation: Optional[str] = None
|
||||||
|
media_type: Optional[str] = None
|
||||||
|
media_title: Optional[str] = None
|
||||||
|
webhook_type: Optional[str] = None
|
||||||
|
processing_stage: Optional[str] = None
|
||||||
|
additional_fields: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert context to dictionary for logging"""
|
||||||
|
context = {
|
||||||
|
"correlation_id": self.correlation_id,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add non-None fields
|
||||||
|
for field_name in ["request_id", "user_id", "operation", "media_type",
|
||||||
|
"media_title", "webhook_type", "processing_stage"]:
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None:
|
||||||
|
context[field_name] = value
|
||||||
|
|
||||||
|
# Add additional fields
|
||||||
|
context.update(self.additional_fields)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredFormatter(logging.Formatter):
|
||||||
|
"""JSON formatter for structured logging"""
|
||||||
|
|
||||||
|
def __init__(self, include_context: bool = True):
|
||||||
|
super().__init__()
|
||||||
|
self.include_context = include_context
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
# Base log entry
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"line": record.lineno,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add thread information
|
||||||
|
log_entry["thread"] = {
|
||||||
|
"id": record.thread,
|
||||||
|
"name": record.threadName
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add correlation context if available
|
||||||
|
if self.include_context and hasattr(_context, 'log_context'):
|
||||||
|
log_entry["context"] = _context.log_context.to_dict()
|
||||||
|
|
||||||
|
# Add exception information if present
|
||||||
|
if record.exc_info:
|
||||||
|
log_entry["exception"] = {
|
||||||
|
"type": record.exc_info[0].__name__,
|
||||||
|
"message": str(record.exc_info[1]),
|
||||||
|
"traceback": traceback.format_exception(*record.exc_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add any extra fields passed to log call
|
||||||
|
if hasattr(record, 'extra_fields') and record.extra_fields:
|
||||||
|
log_entry["extra"] = record.extra_fields
|
||||||
|
|
||||||
|
# Add performance metrics if available
|
||||||
|
if hasattr(record, 'performance_data') and record.performance_data:
|
||||||
|
log_entry["performance"] = record.performance_data
|
||||||
|
|
||||||
|
return json.dumps(log_entry, default=str, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIDFilter(logging.Filter):
|
||||||
|
"""Filter to add correlation ID to log records"""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
# Add correlation ID to record if available
|
||||||
|
if hasattr(_context, 'log_context'):
|
||||||
|
record.correlation_id = _context.log_context.correlation_id
|
||||||
|
else:
|
||||||
|
record.correlation_id = "no-correlation"
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class EnhancedLogger:
|
||||||
|
"""Enhanced logger with correlation IDs and structured logging"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.logger = logging.getLogger(name)
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
# Track log events for metrics
|
||||||
|
self._log_counts = {"debug": 0, "info": 0, "warning": 0, "error": 0, "critical": 0}
|
||||||
|
|
||||||
|
def _log_with_context(self, level: int, message: str, extra_fields: Optional[Dict[str, Any]] = None,
|
||||||
|
performance_data: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
|
"""Log with enhanced context and metrics tracking"""
|
||||||
|
|
||||||
|
# Track log counts for metrics
|
||||||
|
level_name = logging.getLevelName(level).lower()
|
||||||
|
if level_name in self._log_counts:
|
||||||
|
self._log_counts[level_name] += 1
|
||||||
|
metrics.increment_counter(f"log_messages", 1, {"level": level_name, "logger": self.name})
|
||||||
|
|
||||||
|
# Create log record with extra data
|
||||||
|
extra = {}
|
||||||
|
if extra_fields:
|
||||||
|
extra['extra_fields'] = extra_fields
|
||||||
|
if performance_data:
|
||||||
|
extra['performance_data'] = performance_data
|
||||||
|
|
||||||
|
# Log the message
|
||||||
|
self.logger.log(level, message, extra=extra, **kwargs)
|
||||||
|
|
||||||
|
# Track errors in metrics
|
||||||
|
if level >= logging.ERROR:
|
||||||
|
metrics.record_error("logging_error", message, self.name)
|
||||||
|
|
||||||
|
def debug(self, message: str, **kwargs):
|
||||||
|
"""Log debug message"""
|
||||||
|
self._log_with_context(logging.DEBUG, message, **kwargs)
|
||||||
|
|
||||||
|
def info(self, message: str, **kwargs):
|
||||||
|
"""Log info message"""
|
||||||
|
self._log_with_context(logging.INFO, message, **kwargs)
|
||||||
|
|
||||||
|
def warning(self, message: str, **kwargs):
|
||||||
|
"""Log warning message"""
|
||||||
|
self._log_with_context(logging.WARNING, message, **kwargs)
|
||||||
|
|
||||||
|
def error(self, message: str, **kwargs):
|
||||||
|
"""Log error message"""
|
||||||
|
self._log_with_context(logging.ERROR, message, **kwargs)
|
||||||
|
|
||||||
|
def critical(self, message: str, **kwargs):
|
||||||
|
"""Log critical message"""
|
||||||
|
self._log_with_context(logging.CRITICAL, message, **kwargs)
|
||||||
|
|
||||||
|
def exception(self, message: str, **kwargs):
|
||||||
|
"""Log exception with traceback"""
|
||||||
|
kwargs['exc_info'] = True
|
||||||
|
self._log_with_context(logging.ERROR, message, **kwargs)
|
||||||
|
|
||||||
|
def log_operation_start(self, operation: str, **context_fields):
|
||||||
|
"""Log the start of an operation"""
|
||||||
|
self.info(f"Starting operation: {operation}",
|
||||||
|
extra_fields={"operation_event": "start", "operation": operation, **context_fields})
|
||||||
|
|
||||||
|
def log_operation_end(self, operation: str, success: bool = True, duration: Optional[float] = None, **context_fields):
|
||||||
|
"""Log the end of an operation"""
|
||||||
|
outcome = "success" if success else "failure"
|
||||||
|
extra = {"operation_event": "end", "operation": operation, "outcome": outcome, **context_fields}
|
||||||
|
|
||||||
|
if duration is not None:
|
||||||
|
extra["duration_seconds"] = duration
|
||||||
|
|
||||||
|
level = logging.INFO if success else logging.ERROR
|
||||||
|
self._log_with_context(level, f"Operation {outcome}: {operation}", extra_fields=extra)
|
||||||
|
|
||||||
|
def log_webhook_received(self, webhook_type: str, payload_size: int, **context_fields):
|
||||||
|
"""Log webhook reception"""
|
||||||
|
self.info(f"Webhook received: {webhook_type}",
|
||||||
|
extra_fields={
|
||||||
|
"event_type": "webhook_received",
|
||||||
|
"webhook_type": webhook_type,
|
||||||
|
"payload_size_bytes": payload_size,
|
||||||
|
**context_fields
|
||||||
|
})
|
||||||
|
|
||||||
|
def log_nfo_operation(self, operation: str, file_path: str, success: bool = True, **context_fields):
|
||||||
|
"""Log NFO file operations"""
|
||||||
|
outcome = "success" if success else "failure"
|
||||||
|
level = logging.INFO if success else logging.ERROR
|
||||||
|
|
||||||
|
self._log_with_context(level, f"NFO {operation} {outcome}: {file_path}",
|
||||||
|
extra_fields={
|
||||||
|
"event_type": "nfo_operation",
|
||||||
|
"nfo_operation": operation,
|
||||||
|
"file_path": file_path,
|
||||||
|
"outcome": outcome,
|
||||||
|
**context_fields
|
||||||
|
})
|
||||||
|
|
||||||
|
def log_performance_metrics(self, operation: str, duration: float, success: bool = True, **metrics_data):
|
||||||
|
"""Log performance metrics"""
|
||||||
|
self.debug(f"Performance: {operation} took {duration:.3f}s",
|
||||||
|
performance_data={
|
||||||
|
"operation": operation,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"success": success,
|
||||||
|
**metrics_data
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_log_stats(self) -> Dict[str, int]:
|
||||||
|
"""Get logging statistics"""
|
||||||
|
return self._log_counts.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_enhanced_logging(
|
||||||
|
log_level: str = "INFO",
|
||||||
|
structured: bool = True,
|
||||||
|
log_file: Optional[str] = None,
|
||||||
|
max_bytes: int = 10 * 1024 * 1024, # 10MB
|
||||||
|
backup_count: int = 5
|
||||||
|
) -> None:
|
||||||
|
"""Setup enhanced logging configuration"""
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(getattr(logging, log_level.upper()))
|
||||||
|
|
||||||
|
# Clear existing handlers
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
|
||||||
|
if structured:
|
||||||
|
# Use structured JSON formatter
|
||||||
|
formatter = StructuredFormatter(include_context=True)
|
||||||
|
else:
|
||||||
|
# Use simple text formatter with correlation ID
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s [%(correlation_id)s] %(levelname)s %(name)s: %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
console_handler.addFilter(CorrelationIDFilter())
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Add file handler if specified
|
||||||
|
if log_file:
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
log_file, maxBytes=max_bytes, backupCount=backup_count
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
file_handler.addFilter(CorrelationIDFilter())
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Reduce noise from external libraries
|
||||||
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_enhanced_logger(name: str) -> EnhancedLogger:
|
||||||
|
"""Get enhanced logger instance"""
|
||||||
|
return EnhancedLogger(name)
|
||||||
|
|
||||||
|
|
||||||
|
def set_log_context(
|
||||||
|
correlation_id: Optional[str] = None,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> LogContext:
|
||||||
|
"""Set logging context for current thread"""
|
||||||
|
|
||||||
|
if correlation_id is None:
|
||||||
|
correlation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
context = LogContext(
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
request_id=request_id,
|
||||||
|
operation=operation,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
_context.log_context = context
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_context() -> Optional[LogContext]:
|
||||||
|
"""Get current logging context"""
|
||||||
|
return getattr(_context, 'log_context', None)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_log_context():
|
||||||
|
"""Clear logging context for current thread"""
|
||||||
|
if hasattr(_context, 'log_context'):
|
||||||
|
delattr(_context, 'log_context')
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def log_context(correlation_id: Optional[str] = None, **context_fields):
|
||||||
|
"""Context manager for scoped logging context"""
|
||||||
|
original_context = get_log_context()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set new context
|
||||||
|
new_context = set_log_context(correlation_id=correlation_id, **context_fields)
|
||||||
|
yield new_context
|
||||||
|
finally:
|
||||||
|
# Restore original context
|
||||||
|
if original_context:
|
||||||
|
_context.log_context = original_context
|
||||||
|
else:
|
||||||
|
clear_log_context()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def log_operation(operation: str, logger: Optional[EnhancedLogger] = None, **context_fields):
|
||||||
|
"""Context manager for logging operation start/end with timing"""
|
||||||
|
if logger is None:
|
||||||
|
logger = get_enhanced_logger(__name__)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
success = True
|
||||||
|
|
||||||
|
# Update context with operation
|
||||||
|
current_context = get_log_context()
|
||||||
|
if current_context:
|
||||||
|
current_context.operation = operation
|
||||||
|
current_context.processing_stage = "executing"
|
||||||
|
|
||||||
|
logger.log_operation_start(operation, **context_fields)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
logger.exception(f"Operation failed: {operation}",
|
||||||
|
extra_fields={"operation": operation, "error": str(e), **context_fields})
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
logger.log_operation_end(operation, success, duration, **context_fields)
|
||||||
|
|
||||||
|
# Update metrics
|
||||||
|
metrics.record_operation_duration(operation, duration, success)
|
||||||
|
|
||||||
|
|
||||||
|
def trace_request(request_id: Optional[str] = None, **context_fields):
|
||||||
|
"""Decorator/context manager for request tracing"""
|
||||||
|
def decorator(func):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
correlation_id = str(uuid.uuid4())
|
||||||
|
req_id = request_id or f"req_{int(time.time())}"
|
||||||
|
|
||||||
|
with log_context(correlation_id=correlation_id, request_id=req_id, **context_fields):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
# Can be used as context manager or decorator
|
||||||
|
if request_id is None and len(context_fields) == 1 and callable(list(context_fields.values())[0]):
|
||||||
|
# Used as decorator without parentheses
|
||||||
|
func = list(context_fields.values())[0]
|
||||||
|
return decorator(func)
|
||||||
|
else:
|
||||||
|
# Used as decorator with parameters or context manager
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level logger for this module
|
||||||
|
logger = get_enhanced_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logging_stats() -> Dict[str, Any]:
|
||||||
|
"""Get comprehensive logging statistics"""
|
||||||
|
# Collect stats from all enhanced loggers
|
||||||
|
total_stats = {"debug": 0, "info": 0, "warning": 0, "error": 0, "critical": 0}
|
||||||
|
|
||||||
|
# This is a simplified version - in practice you'd track all logger instances
|
||||||
|
return {
|
||||||
|
"total_log_messages": sum(total_stats.values()),
|
||||||
|
"by_level": total_stats,
|
||||||
|
"structured_logging_enabled": True,
|
||||||
|
"correlation_tracking_enabled": True
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Metrics Collection System for NFOGuard
|
||||||
|
Provides performance monitoring, counters, and operational metrics
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import psutil
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MetricValue:
|
||||||
|
"""Individual metric value with timestamp"""
|
||||||
|
value: float
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
labels: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeSeriesMetric:
|
||||||
|
"""Time series metric with historical data"""
|
||||||
|
name: str
|
||||||
|
values: deque = field(default_factory=lambda: deque(maxlen=1000))
|
||||||
|
total: float = 0.0
|
||||||
|
count: int = 0
|
||||||
|
|
||||||
|
def add_value(self, value: float, labels: Optional[Dict[str, str]] = None):
|
||||||
|
"""Add a new metric value"""
|
||||||
|
metric_value = MetricValue(value, labels=labels or {})
|
||||||
|
self.values.append(metric_value)
|
||||||
|
self.total += value
|
||||||
|
self.count += 1
|
||||||
|
|
||||||
|
def get_average(self, window_seconds: int = 300) -> float:
|
||||||
|
"""Get average value over time window"""
|
||||||
|
cutoff_time = time.time() - window_seconds
|
||||||
|
recent_values = [v.value for v in self.values if v.timestamp > cutoff_time]
|
||||||
|
return sum(recent_values) / len(recent_values) if recent_values else 0.0
|
||||||
|
|
||||||
|
def get_rate_per_minute(self, window_seconds: int = 300) -> float:
|
||||||
|
"""Get rate per minute over time window"""
|
||||||
|
cutoff_time = time.time() - window_seconds
|
||||||
|
recent_count = len([v for v in self.values if v.timestamp > cutoff_time])
|
||||||
|
return (recent_count / window_seconds) * 60 if window_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsCollector:
|
||||||
|
"""Central metrics collection system"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._metrics: Dict[str, TimeSeriesMetric] = {}
|
||||||
|
self._counters: Dict[str, int] = defaultdict(int)
|
||||||
|
self._gauges: Dict[str, float] = {}
|
||||||
|
self._histograms: Dict[str, List[float]] = defaultdict(list)
|
||||||
|
self._start_time = time.time()
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
# Processing metrics
|
||||||
|
self._active_operations = 0
|
||||||
|
self._operation_durations = deque(maxlen=1000)
|
||||||
|
|
||||||
|
# Error tracking
|
||||||
|
self._error_counts = defaultdict(int)
|
||||||
|
self._last_errors = deque(maxlen=100)
|
||||||
|
|
||||||
|
# System metrics
|
||||||
|
self._system_stats_cache = {}
|
||||||
|
self._system_stats_last_update = 0
|
||||||
|
self._system_stats_cache_ttl = 30 # 30 seconds
|
||||||
|
|
||||||
|
def increment_counter(self, name: str, value: int = 1, labels: Optional[Dict[str, str]] = None):
|
||||||
|
"""Increment a counter metric"""
|
||||||
|
with self._lock:
|
||||||
|
full_name = self._build_metric_name(name, labels)
|
||||||
|
self._counters[full_name] += value
|
||||||
|
|
||||||
|
# Also track in time series for rate calculations
|
||||||
|
if name not in self._metrics:
|
||||||
|
self._metrics[name] = TimeSeriesMetric(name)
|
||||||
|
self._metrics[name].add_value(value, labels)
|
||||||
|
|
||||||
|
def set_gauge(self, name: str, value: float, labels: Optional[Dict[str, str]] = None):
|
||||||
|
"""Set a gauge metric value"""
|
||||||
|
with self._lock:
|
||||||
|
full_name = self._build_metric_name(name, labels)
|
||||||
|
self._gauges[full_name] = value
|
||||||
|
|
||||||
|
def record_histogram(self, name: str, value: float, labels: Optional[Dict[str, str]] = None):
|
||||||
|
"""Record a histogram value"""
|
||||||
|
with self._lock:
|
||||||
|
full_name = self._build_metric_name(name, labels)
|
||||||
|
self._histograms[full_name].append(value)
|
||||||
|
|
||||||
|
# Keep only recent values (last 1000)
|
||||||
|
if len(self._histograms[full_name]) > 1000:
|
||||||
|
self._histograms[full_name] = self._histograms[full_name][-1000:]
|
||||||
|
|
||||||
|
# Also track in time series
|
||||||
|
if name not in self._metrics:
|
||||||
|
self._metrics[name] = TimeSeriesMetric(name)
|
||||||
|
self._metrics[name].add_value(value, labels)
|
||||||
|
|
||||||
|
def record_operation_duration(self, operation: str, duration: float, success: bool = True):
|
||||||
|
"""Record operation duration and outcome"""
|
||||||
|
with self._lock:
|
||||||
|
# Record duration
|
||||||
|
self.record_histogram(f"operation_duration_{operation}", duration)
|
||||||
|
|
||||||
|
# Record outcome
|
||||||
|
outcome = "success" if success else "error"
|
||||||
|
self.increment_counter(f"operation_total", 1, {"operation": operation, "outcome": outcome})
|
||||||
|
|
||||||
|
# Track active operations
|
||||||
|
if operation.endswith("_start"):
|
||||||
|
self._active_operations += 1
|
||||||
|
elif operation.endswith("_end"):
|
||||||
|
self._active_operations = max(0, self._active_operations - 1)
|
||||||
|
|
||||||
|
def record_error(self, error_type: str, error_message: str, operation: Optional[str] = None):
|
||||||
|
"""Record an error occurrence"""
|
||||||
|
with self._lock:
|
||||||
|
self._error_counts[error_type] += 1
|
||||||
|
|
||||||
|
error_info = {
|
||||||
|
"type": error_type,
|
||||||
|
"message": error_message,
|
||||||
|
"operation": operation,
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
self._last_errors.append(error_info)
|
||||||
|
|
||||||
|
# Increment error counter
|
||||||
|
labels = {"error_type": error_type}
|
||||||
|
if operation:
|
||||||
|
labels["operation"] = operation
|
||||||
|
self.increment_counter("errors_total", 1, labels)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def operation_timer(self, operation: str):
|
||||||
|
"""Context manager for timing operations"""
|
||||||
|
start_time = time.time()
|
||||||
|
success = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.record_operation_duration(f"{operation}_start", 0)
|
||||||
|
yield
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
self.record_error("operation_error", str(e), operation)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.record_operation_duration(operation, duration, success)
|
||||||
|
self.record_operation_duration(f"{operation}_end", 0)
|
||||||
|
|
||||||
|
def get_system_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Get current system resource metrics"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Use cached values if recent
|
||||||
|
if (now - self._system_stats_last_update) < self._system_stats_cache_ttl:
|
||||||
|
return self._system_stats_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
# CPU metrics
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||||
|
cpu_count = psutil.cpu_count()
|
||||||
|
|
||||||
|
# Memory metrics
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
|
||||||
|
# Disk metrics for database path
|
||||||
|
try:
|
||||||
|
from config.settings import config
|
||||||
|
db_disk = psutil.disk_usage(str(config.db_path.parent))
|
||||||
|
except:
|
||||||
|
db_disk = None
|
||||||
|
|
||||||
|
# Process metrics
|
||||||
|
process = psutil.Process()
|
||||||
|
process_memory = process.memory_info()
|
||||||
|
|
||||||
|
self._system_stats_cache = {
|
||||||
|
"cpu_percent": cpu_percent,
|
||||||
|
"cpu_count": cpu_count,
|
||||||
|
"memory_total": memory.total,
|
||||||
|
"memory_available": memory.available,
|
||||||
|
"memory_percent": memory.percent,
|
||||||
|
"process_memory_rss": process_memory.rss,
|
||||||
|
"process_memory_vms": process_memory.vms,
|
||||||
|
"db_disk_free": db_disk.free if db_disk else None,
|
||||||
|
"db_disk_total": db_disk.total if db_disk else None,
|
||||||
|
"active_operations": self._active_operations,
|
||||||
|
"uptime_seconds": now - self._start_time
|
||||||
|
}
|
||||||
|
|
||||||
|
self._system_stats_last_update = now
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Return basic metrics if detailed collection fails
|
||||||
|
self._system_stats_cache = {
|
||||||
|
"uptime_seconds": now - self._start_time,
|
||||||
|
"active_operations": self._active_operations,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return self._system_stats_cache
|
||||||
|
|
||||||
|
def get_processing_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Get processing-related metrics"""
|
||||||
|
with self._lock:
|
||||||
|
# Calculate rates and averages
|
||||||
|
webhook_rate = self._metrics.get("webhooks_received", TimeSeriesMetric("webhooks_received")).get_rate_per_minute()
|
||||||
|
nfo_rate = self._metrics.get("nfo_created", TimeSeriesMetric("nfo_created")).get_rate_per_minute()
|
||||||
|
|
||||||
|
avg_processing_time = 0.0
|
||||||
|
if "processing_duration" in self._metrics:
|
||||||
|
avg_processing_time = self._metrics["processing_duration"].get_average()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"webhooks_received_per_minute": webhook_rate,
|
||||||
|
"nfo_files_created_per_minute": nfo_rate,
|
||||||
|
"average_processing_time_seconds": avg_processing_time,
|
||||||
|
"active_operations": self._active_operations,
|
||||||
|
"total_webhooks": self._counters.get("webhooks_received", 0),
|
||||||
|
"total_nfo_created": self._counters.get("nfo_created", 0),
|
||||||
|
"total_errors": sum(self._error_counts.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_error_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Get error-related metrics"""
|
||||||
|
with self._lock:
|
||||||
|
recent_errors = []
|
||||||
|
cutoff_time = time.time() - 3600 # Last hour
|
||||||
|
|
||||||
|
for error in self._last_errors:
|
||||||
|
if error["timestamp"] > cutoff_time:
|
||||||
|
recent_errors.append({
|
||||||
|
"type": error["type"],
|
||||||
|
"message": error["message"][:100], # Truncate long messages
|
||||||
|
"operation": error["operation"],
|
||||||
|
"timestamp": error["timestamp"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"error_counts_by_type": dict(self._error_counts),
|
||||||
|
"recent_errors": recent_errors[-10:], # Last 10 errors
|
||||||
|
"total_errors": sum(self._error_counts.values()),
|
||||||
|
"error_rate_per_minute": len([e for e in self._last_errors if e["timestamp"] > time.time() - 300]) / 5
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_prometheus_metrics(self) -> str:
|
||||||
|
"""Generate Prometheus-compatible metrics format"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Add help and type information
|
||||||
|
lines.append("# HELP nfoguard_webhooks_total Total number of webhooks received")
|
||||||
|
lines.append("# TYPE nfoguard_webhooks_total counter")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Counters
|
||||||
|
for name, value in self._counters.items():
|
||||||
|
metric_name = f"nfoguard_{name.replace('-', '_')}"
|
||||||
|
lines.append(f"{metric_name} {value}")
|
||||||
|
|
||||||
|
# Gauges
|
||||||
|
lines.append("# HELP nfoguard_active_operations Current number of active operations")
|
||||||
|
lines.append("# TYPE nfoguard_active_operations gauge")
|
||||||
|
lines.append(f"nfoguard_active_operations {self._active_operations}")
|
||||||
|
|
||||||
|
# System metrics
|
||||||
|
system_metrics = self.get_system_metrics()
|
||||||
|
for key, value in system_metrics.items():
|
||||||
|
if isinstance(value, (int, float)) and value is not None:
|
||||||
|
metric_name = f"nfoguard_system_{key}"
|
||||||
|
lines.append(f"{metric_name} {value}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def get_all_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Get all metrics in a structured format"""
|
||||||
|
return {
|
||||||
|
"system": self.get_system_metrics(),
|
||||||
|
"processing": self.get_processing_metrics(),
|
||||||
|
"errors": self.get_error_metrics(),
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"uptime_seconds": time.time() - self._start_time
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset_metrics(self, metric_types: Optional[List[str]] = None):
|
||||||
|
"""Reset specific metric types or all metrics"""
|
||||||
|
with self._lock:
|
||||||
|
if not metric_types or "counters" in metric_types:
|
||||||
|
self._counters.clear()
|
||||||
|
|
||||||
|
if not metric_types or "histograms" in metric_types:
|
||||||
|
self._histograms.clear()
|
||||||
|
|
||||||
|
if not metric_types or "errors" in metric_types:
|
||||||
|
self._error_counts.clear()
|
||||||
|
self._last_errors.clear()
|
||||||
|
|
||||||
|
if not metric_types or "timeseries" in metric_types:
|
||||||
|
self._metrics.clear()
|
||||||
|
|
||||||
|
def _build_metric_name(self, name: str, labels: Optional[Dict[str, str]]) -> str:
|
||||||
|
"""Build metric name with labels"""
|
||||||
|
if not labels:
|
||||||
|
return name
|
||||||
|
|
||||||
|
label_str = ",".join(f"{k}={v}" for k, v in sorted(labels.items()))
|
||||||
|
return f"{name}{{{label_str}}}"
|
||||||
|
|
||||||
|
|
||||||
|
# Global metrics collector instance
|
||||||
|
metrics = MetricsCollector()
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience functions for common operations
|
||||||
|
def track_webhook_received(webhook_type: str):
|
||||||
|
"""Track webhook received"""
|
||||||
|
metrics.increment_counter("webhooks_received", 1, {"type": webhook_type})
|
||||||
|
|
||||||
|
|
||||||
|
def track_nfo_created(media_type: str, success: bool = True):
|
||||||
|
"""Track NFO file creation"""
|
||||||
|
outcome = "success" if success else "error"
|
||||||
|
metrics.increment_counter("nfo_created", 1, {"media_type": media_type, "outcome": outcome})
|
||||||
|
|
||||||
|
|
||||||
|
def track_api_call(api_name: str, duration: float, success: bool = True):
|
||||||
|
"""Track external API call"""
|
||||||
|
metrics.record_histogram(f"api_call_duration", duration, {"api": api_name})
|
||||||
|
outcome = "success" if success else "error"
|
||||||
|
metrics.increment_counter("api_calls_total", 1, {"api": api_name, "outcome": outcome})
|
||||||
|
|
||||||
|
|
||||||
|
def track_database_operation(operation: str, duration: float, success: bool = True):
|
||||||
|
"""Track database operation"""
|
||||||
|
metrics.record_histogram("database_operation_duration", duration, {"operation": operation})
|
||||||
|
outcome = "success" if success else "error"
|
||||||
|
metrics.increment_counter("database_operations_total", 1, {"operation": operation, "outcome": outcome})
|
||||||
|
|
||||||
|
|
||||||
|
def track_file_operation(operation: str, duration: float, success: bool = True):
|
||||||
|
"""Track file system operation"""
|
||||||
|
metrics.record_histogram("file_operation_duration", duration, {"operation": operation})
|
||||||
|
outcome = "success" if success else "error"
|
||||||
|
metrics.increment_counter("file_operations_total", 1, {"operation": operation, "outcome": outcome})
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"""
|
||||||
|
Performance Monitoring and Profiling for NFOGuard
|
||||||
|
Provides detailed performance analysis and optimization insights
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import functools
|
||||||
|
from typing import Dict, Any, List, Optional, Callable, TypeVar, Union
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
import traceback
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from monitoring.metrics import metrics
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PerformanceProfile:
|
||||||
|
"""Performance profile for an operation"""
|
||||||
|
operation_name: str
|
||||||
|
total_calls: int = 0
|
||||||
|
total_duration: float = 0.0
|
||||||
|
min_duration: float = float('inf')
|
||||||
|
max_duration: float = 0.0
|
||||||
|
recent_durations: deque = field(default_factory=lambda: deque(maxlen=100))
|
||||||
|
error_count: int = 0
|
||||||
|
concurrent_calls: int = 0
|
||||||
|
|
||||||
|
def add_measurement(self, duration: float, success: bool = True):
|
||||||
|
"""Add a performance measurement"""
|
||||||
|
self.total_calls += 1
|
||||||
|
self.total_duration += duration
|
||||||
|
self.min_duration = min(self.min_duration, duration)
|
||||||
|
self.max_duration = max(self.max_duration, duration)
|
||||||
|
self.recent_durations.append(duration)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
self.error_count += 1
|
||||||
|
|
||||||
|
def get_average_duration(self) -> float:
|
||||||
|
"""Get average duration across all calls"""
|
||||||
|
return self.total_duration / self.total_calls if self.total_calls > 0 else 0.0
|
||||||
|
|
||||||
|
def get_recent_average(self, window: int = 50) -> float:
|
||||||
|
"""Get average of recent calls"""
|
||||||
|
recent = list(self.recent_durations)[-window:]
|
||||||
|
return sum(recent) / len(recent) if recent else 0.0
|
||||||
|
|
||||||
|
def get_percentiles(self) -> Dict[str, float]:
|
||||||
|
"""Get duration percentiles for recent calls"""
|
||||||
|
recent = sorted(list(self.recent_durations))
|
||||||
|
if not recent:
|
||||||
|
return {"p50": 0, "p95": 0, "p99": 0}
|
||||||
|
|
||||||
|
length = len(recent)
|
||||||
|
return {
|
||||||
|
"p50": recent[int(length * 0.5)] if length > 0 else 0,
|
||||||
|
"p95": recent[int(length * 0.95)] if length > 0 else 0,
|
||||||
|
"p99": recent[int(length * 0.99)] if length > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for API responses"""
|
||||||
|
percentiles = self.get_percentiles()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"operation_name": self.operation_name,
|
||||||
|
"total_calls": self.total_calls,
|
||||||
|
"error_count": self.error_count,
|
||||||
|
"error_rate": self.error_count / self.total_calls if self.total_calls > 0 else 0,
|
||||||
|
"concurrent_calls": self.concurrent_calls,
|
||||||
|
"duration_stats": {
|
||||||
|
"average": round(self.get_average_duration(), 4),
|
||||||
|
"recent_average": round(self.get_recent_average(), 4),
|
||||||
|
"min": round(self.min_duration if self.min_duration != float('inf') else 0, 4),
|
||||||
|
"max": round(self.max_duration, 4),
|
||||||
|
"p50": round(percentiles["p50"], 4),
|
||||||
|
"p95": round(percentiles["p95"], 4),
|
||||||
|
"p99": round(percentiles["p99"], 4)
|
||||||
|
},
|
||||||
|
"performance_rating": self._get_performance_rating()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_performance_rating(self) -> str:
|
||||||
|
"""Get performance rating based on metrics"""
|
||||||
|
avg_duration = self.get_recent_average()
|
||||||
|
error_rate = self.error_count / self.total_calls if self.total_calls > 0 else 0
|
||||||
|
|
||||||
|
if error_rate > 0.1: # >10% error rate
|
||||||
|
return "poor"
|
||||||
|
elif avg_duration > 5.0: # >5 seconds average
|
||||||
|
return "slow"
|
||||||
|
elif avg_duration > 1.0: # >1 second average
|
||||||
|
return "acceptable"
|
||||||
|
else:
|
||||||
|
return "excellent"
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceMonitor:
|
||||||
|
"""Advanced performance monitoring system"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._profiles: Dict[str, PerformanceProfile] = {}
|
||||||
|
self._active_operations: Dict[str, float] = {} # operation_id -> start_time
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
# Slow operation tracking
|
||||||
|
self._slow_operation_threshold = 1.0 # 1 second
|
||||||
|
self._slow_operations = deque(maxlen=100)
|
||||||
|
|
||||||
|
# Memory monitoring
|
||||||
|
self._memory_samples = deque(maxlen=1000)
|
||||||
|
self._memory_monitoring_enabled = True
|
||||||
|
|
||||||
|
# Async operation tracking
|
||||||
|
self._async_tasks = {}
|
||||||
|
self._task_counter = 0
|
||||||
|
|
||||||
|
def get_profile(self, operation_name: str) -> PerformanceProfile:
|
||||||
|
"""Get or create performance profile for operation"""
|
||||||
|
with self._lock:
|
||||||
|
if operation_name not in self._profiles:
|
||||||
|
self._profiles[operation_name] = PerformanceProfile(operation_name)
|
||||||
|
return self._profiles[operation_name]
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def monitor_operation(self, operation_name: str, **kwargs):
|
||||||
|
"""Context manager for monitoring synchronous operations"""
|
||||||
|
start_time = time.time()
|
||||||
|
operation_id = f"{operation_name}_{id(threading.current_thread())}_{time.time()}"
|
||||||
|
success = True
|
||||||
|
|
||||||
|
profile = self.get_profile(operation_name)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
profile.concurrent_calls += 1
|
||||||
|
self._active_operations[operation_id] = start_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
metrics.record_error("performance_monitor", str(e), operation_name)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
end_time = time.time()
|
||||||
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
profile.concurrent_calls = max(0, profile.concurrent_calls - 1)
|
||||||
|
self._active_operations.pop(operation_id, None)
|
||||||
|
|
||||||
|
# Record measurement
|
||||||
|
profile.add_measurement(duration, success)
|
||||||
|
|
||||||
|
# Track slow operations
|
||||||
|
if duration > self._slow_operation_threshold:
|
||||||
|
self._slow_operations.append({
|
||||||
|
"operation": operation_name,
|
||||||
|
"duration": duration,
|
||||||
|
"timestamp": end_time,
|
||||||
|
"success": success,
|
||||||
|
"metadata": kwargs
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update metrics
|
||||||
|
metrics.record_histogram(f"operation_duration", duration, {"operation": operation_name})
|
||||||
|
if not success:
|
||||||
|
metrics.increment_counter("operation_errors", 1, {"operation": operation_name})
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def monitor_async_operation(self, operation_name: str, **kwargs):
|
||||||
|
"""Context manager for monitoring asynchronous operations"""
|
||||||
|
start_time = time.time()
|
||||||
|
task_id = f"{operation_name}_{self._task_counter}"
|
||||||
|
self._task_counter += 1
|
||||||
|
success = True
|
||||||
|
|
||||||
|
profile = self.get_profile(operation_name)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
profile.concurrent_calls += 1
|
||||||
|
self._async_tasks[task_id] = {
|
||||||
|
"operation": operation_name,
|
||||||
|
"start_time": start_time,
|
||||||
|
"metadata": kwargs
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
metrics.record_error("async_performance_monitor", str(e), operation_name)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
end_time = time.time()
|
||||||
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
profile.concurrent_calls = max(0, profile.concurrent_calls - 1)
|
||||||
|
self._async_tasks.pop(task_id, None)
|
||||||
|
|
||||||
|
# Record measurement
|
||||||
|
profile.add_measurement(duration, success)
|
||||||
|
|
||||||
|
# Track slow operations
|
||||||
|
if duration > self._slow_operation_threshold:
|
||||||
|
self._slow_operations.append({
|
||||||
|
"operation": operation_name,
|
||||||
|
"duration": duration,
|
||||||
|
"timestamp": end_time,
|
||||||
|
"success": success,
|
||||||
|
"async": True,
|
||||||
|
"metadata": kwargs
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update metrics
|
||||||
|
metrics.record_histogram(f"async_operation_duration", duration, {"operation": operation_name})
|
||||||
|
if not success:
|
||||||
|
metrics.increment_counter("async_operation_errors", 1, {"operation": operation_name})
|
||||||
|
|
||||||
|
def monitor_function(self, operation_name: Optional[str] = None):
|
||||||
|
"""Decorator for monitoring function performance"""
|
||||||
|
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||||
|
name = operation_name or f"{func.__module__}.{func.__name__}"
|
||||||
|
|
||||||
|
if asyncio.iscoroutinefunction(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
async with self.monitor_async_operation(name):
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return async_wrapper
|
||||||
|
else:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
with self.monitor_operation(name):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return sync_wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def get_performance_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Get comprehensive performance summary"""
|
||||||
|
with self._lock:
|
||||||
|
# Get top operations by various metrics
|
||||||
|
profiles = list(self._profiles.values())
|
||||||
|
|
||||||
|
# Sort by total calls
|
||||||
|
most_called = sorted(profiles, key=lambda p: p.total_calls, reverse=True)[:10]
|
||||||
|
|
||||||
|
# Sort by average duration
|
||||||
|
slowest_avg = sorted(profiles, key=lambda p: p.get_average_duration(), reverse=True)[:10]
|
||||||
|
|
||||||
|
# Sort by recent average
|
||||||
|
slowest_recent = sorted(profiles, key=lambda p: p.get_recent_average(), reverse=True)[:10]
|
||||||
|
|
||||||
|
# Sort by error rate
|
||||||
|
highest_errors = sorted(
|
||||||
|
[p for p in profiles if p.total_calls > 0],
|
||||||
|
key=lambda p: p.error_count / p.total_calls,
|
||||||
|
reverse=True
|
||||||
|
)[:10]
|
||||||
|
|
||||||
|
# Get active operations count
|
||||||
|
total_active = sum(p.concurrent_calls for p in profiles)
|
||||||
|
|
||||||
|
# Get slow operations
|
||||||
|
recent_slow = list(self._slow_operations)[-20:] # Last 20 slow operations
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overview": {
|
||||||
|
"total_operations_tracked": len(profiles),
|
||||||
|
"total_active_operations": total_active,
|
||||||
|
"slow_operation_threshold_seconds": self._slow_operation_threshold,
|
||||||
|
"total_slow_operations": len(self._slow_operations)
|
||||||
|
},
|
||||||
|
"top_operations": {
|
||||||
|
"most_called": [p.to_dict() for p in most_called],
|
||||||
|
"slowest_average": [p.to_dict() for p in slowest_avg],
|
||||||
|
"slowest_recent": [p.to_dict() for p in slowest_recent],
|
||||||
|
"highest_error_rate": [p.to_dict() for p in highest_errors]
|
||||||
|
},
|
||||||
|
"recent_slow_operations": recent_slow,
|
||||||
|
"performance_insights": self._generate_performance_insights(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_operation_detail(self, operation_name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get detailed performance data for specific operation"""
|
||||||
|
with self._lock:
|
||||||
|
if operation_name not in self._profiles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
profile = self._profiles[operation_name]
|
||||||
|
|
||||||
|
# Get related slow operations
|
||||||
|
related_slow = [
|
||||||
|
op for op in self._slow_operations
|
||||||
|
if op["operation"] == operation_name
|
||||||
|
]
|
||||||
|
|
||||||
|
detail = profile.to_dict()
|
||||||
|
detail.update({
|
||||||
|
"detailed_stats": {
|
||||||
|
"total_duration": round(profile.total_duration, 4),
|
||||||
|
"recent_durations": list(profile.recent_durations)[-20:], # Last 20 calls
|
||||||
|
"slow_operations_count": len(related_slow),
|
||||||
|
"recent_slow_operations": related_slow[-10:] # Last 10 slow calls
|
||||||
|
},
|
||||||
|
"recommendations": self._get_operation_recommendations(profile)
|
||||||
|
})
|
||||||
|
|
||||||
|
return detail
|
||||||
|
|
||||||
|
def _generate_performance_insights(self, profiles: List[PerformanceProfile]) -> List[str]:
|
||||||
|
"""Generate performance optimization insights"""
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
# Check for very slow operations
|
||||||
|
very_slow = [p for p in profiles if p.get_recent_average() > 5.0]
|
||||||
|
if very_slow:
|
||||||
|
insights.append(f"Found {len(very_slow)} operations with >5s average duration - consider optimization")
|
||||||
|
|
||||||
|
# Check for high error rates
|
||||||
|
high_error_rate = [p for p in profiles if p.total_calls > 10 and (p.error_count / p.total_calls) > 0.1]
|
||||||
|
if high_error_rate:
|
||||||
|
insights.append(f"Found {len(high_error_rate)} operations with >10% error rate - investigate failures")
|
||||||
|
|
||||||
|
# Check for high concurrency
|
||||||
|
high_concurrency = [p for p in profiles if p.concurrent_calls > 5]
|
||||||
|
if high_concurrency:
|
||||||
|
insights.append(f"Found {len(high_concurrency)} operations with high concurrency - may need rate limiting")
|
||||||
|
|
||||||
|
# Check total active operations
|
||||||
|
total_active = sum(p.concurrent_calls for p in profiles)
|
||||||
|
if total_active > 20:
|
||||||
|
insights.append(f"High total concurrent operations ({total_active}) - system may be under load")
|
||||||
|
|
||||||
|
# Performance trends
|
||||||
|
recent_slow_count = len([op for op in self._slow_operations if op["timestamp"] > time.time() - 300])
|
||||||
|
if recent_slow_count > 10:
|
||||||
|
insights.append(f"Many slow operations recently ({recent_slow_count} in last 5 minutes)")
|
||||||
|
|
||||||
|
if not insights:
|
||||||
|
insights.append("No significant performance issues detected")
|
||||||
|
|
||||||
|
return insights
|
||||||
|
|
||||||
|
def _get_operation_recommendations(self, profile: PerformanceProfile) -> List[str]:
|
||||||
|
"""Get recommendations for optimizing specific operation"""
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
avg_duration = profile.get_recent_average()
|
||||||
|
error_rate = profile.error_count / profile.total_calls if profile.total_calls > 0 else 0
|
||||||
|
|
||||||
|
if avg_duration > 5.0:
|
||||||
|
recommendations.append("Consider breaking down this operation into smaller parts")
|
||||||
|
recommendations.append("Review database queries and file I/O for optimization opportunities")
|
||||||
|
elif avg_duration > 1.0:
|
||||||
|
recommendations.append("Monitor for potential optimization opportunities")
|
||||||
|
|
||||||
|
if error_rate > 0.1:
|
||||||
|
recommendations.append("High error rate - investigate common failure causes")
|
||||||
|
recommendations.append("Consider adding retry logic or better error handling")
|
||||||
|
|
||||||
|
if profile.concurrent_calls > 5:
|
||||||
|
recommendations.append("High concurrency - consider adding rate limiting")
|
||||||
|
recommendations.append("Review resource usage and potential bottlenecks")
|
||||||
|
|
||||||
|
percentiles = profile.get_percentiles()
|
||||||
|
if percentiles["p99"] > percentiles["p50"] * 3:
|
||||||
|
recommendations.append("High latency variance - investigate outlier causes")
|
||||||
|
|
||||||
|
if not recommendations:
|
||||||
|
recommendations.append("Performance appears optimal for this operation")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
def set_slow_operation_threshold(self, threshold_seconds: float):
|
||||||
|
"""Set threshold for what constitutes a slow operation"""
|
||||||
|
with self._lock:
|
||||||
|
self._slow_operation_threshold = threshold_seconds
|
||||||
|
|
||||||
|
def clear_profiles(self, operation_names: Optional[List[str]] = None):
|
||||||
|
"""Clear performance profiles for specific operations or all"""
|
||||||
|
with self._lock:
|
||||||
|
if operation_names:
|
||||||
|
for name in operation_names:
|
||||||
|
self._profiles.pop(name, None)
|
||||||
|
else:
|
||||||
|
self._profiles.clear()
|
||||||
|
self._slow_operations.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Global performance monitor instance
|
||||||
|
performance_monitor = PerformanceMonitor()
|
||||||
|
|
||||||
|
# Decorator shortcuts
|
||||||
|
def monitor_performance(operation_name: Optional[str] = None):
|
||||||
|
"""Shortcut decorator for performance monitoring"""
|
||||||
|
return performance_monitor.monitor_function(operation_name)
|
||||||
|
|
||||||
|
def monitor_sync_operation(operation_name: str, **kwargs):
|
||||||
|
"""Shortcut for synchronous operation monitoring"""
|
||||||
|
return performance_monitor.monitor_operation(operation_name, **kwargs)
|
||||||
|
|
||||||
|
def monitor_async_operation(operation_name: str, **kwargs):
|
||||||
|
"""Shortcut for asynchronous operation monitoring"""
|
||||||
|
return performance_monitor.monitor_async_operation(operation_name, **kwargs)
|
||||||
+2554
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,742 @@
|
|||||||
|
"""
|
||||||
|
Movie Processor for NFOGuard
|
||||||
|
Handles movie processing and metadata management
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, List, Tuple
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
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.external_clients import ExternalClientManager
|
||||||
|
from config.settings import config
|
||||||
|
from utils.logging import _log
|
||||||
|
from utils.file_utils import find_media_path_by_imdb_and_title
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Final fallback to UTC
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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 using unified file utilities"""
|
||||||
|
return find_media_path_by_imdb_and_title(
|
||||||
|
title=movie_title,
|
||||||
|
imdb_id=imdb_id,
|
||||||
|
search_paths=config.movie_paths,
|
||||||
|
webhook_path=radarr_path,
|
||||||
|
path_mapper=self.path_mapper
|
||||||
|
)
|
||||||
|
|
||||||
|
def should_skip_movie(self, imdb_id: str, movie_name: str = "") -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Determine if we should skip processing this movie based on completion status
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_id: Movie IMDb ID
|
||||||
|
movie_name: Movie name for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(should_skip: bool, reason: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.db.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if self.db.db_type == "postgresql":
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT dateadded, source, has_video_file
|
||||||
|
FROM movies
|
||||||
|
WHERE imdb_id = %s
|
||||||
|
""", (imdb_id,))
|
||||||
|
else:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT dateadded, source, has_video_file
|
||||||
|
FROM movies
|
||||||
|
WHERE imdb_id = ?
|
||||||
|
""", (imdb_id,))
|
||||||
|
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
return False, "No database record found"
|
||||||
|
|
||||||
|
if self.db.db_type == "postgresql":
|
||||||
|
dateadded = result['dateadded']
|
||||||
|
source = result['source']
|
||||||
|
has_video_file = result['has_video_file']
|
||||||
|
else:
|
||||||
|
dateadded = result[0] if result[0] else None
|
||||||
|
source = result[1] if result[1] else None
|
||||||
|
has_video_file = result[2] if result[2] else False
|
||||||
|
|
||||||
|
# Skip if:
|
||||||
|
# 1. Movie has a valid dateadded timestamp
|
||||||
|
# 2. Source is valid (not 'unknown' or 'no_valid_date_source')
|
||||||
|
# 3. Has video file on disk
|
||||||
|
if (dateadded and
|
||||||
|
source and
|
||||||
|
source not in ['unknown', 'no_valid_date_source'] and
|
||||||
|
has_video_file):
|
||||||
|
return True, f"Complete: Has valid date '{dateadded}' from source '{source}'"
|
||||||
|
elif not dateadded:
|
||||||
|
return False, "Missing dateadded"
|
||||||
|
elif not source or source in ['unknown', 'no_valid_date_source']:
|
||||||
|
return False, f"Invalid source: '{source}'"
|
||||||
|
elif not has_video_file:
|
||||||
|
return False, "No video file detected"
|
||||||
|
else:
|
||||||
|
return False, "Incomplete movie data"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
|
||||||
|
return False, f"Error checking completion: {e}"
|
||||||
|
|
||||||
|
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, shutdown_event=None) -> str:
|
||||||
|
"""Process a movie directory"""
|
||||||
|
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
|
||||||
|
if not imdb_id:
|
||||||
|
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
# Handle TMDB ID fallback case
|
||||||
|
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||||
|
if is_tmdb_fallback:
|
||||||
|
_log("INFO", f"Processing movie: {movie_path.name} (TMDB: {imdb_id})")
|
||||||
|
else:
|
||||||
|
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
|
||||||
|
|
||||||
|
# Check if we should skip this movie (unless forced or webhook mode)
|
||||||
|
if not force_scan and not webhook_mode:
|
||||||
|
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
|
||||||
|
if should_skip:
|
||||||
|
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||||
|
# Still update the movie record to track that we've seen it
|
||||||
|
self.db.upsert_movie(imdb_id, str(movie_path))
|
||||||
|
return "skipped"
|
||||||
|
else:
|
||||||
|
_log("INFO", f"🎬 PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||||
|
elif force_scan:
|
||||||
|
_log("INFO", f"🔄 FORCE PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Force scan enabled")
|
||||||
|
else:
|
||||||
|
_log("INFO", f"📥 WEBHOOK PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Webhook mode")
|
||||||
|
|
||||||
|
# Check for shutdown signal early in processing
|
||||||
|
if shutdown_event and shutdown_event.is_set():
|
||||||
|
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
|
||||||
|
return "shutdown"
|
||||||
|
|
||||||
|
# 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} - skipping database entry")
|
||||||
|
return "no_video_files"
|
||||||
|
|
||||||
|
# TIER 1: Check database first (fastest - local lookup)
|
||||||
|
existing = self.db.get_movie_dates(imdb_id)
|
||||||
|
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
||||||
|
|
||||||
|
# Enhanced debug for database state
|
||||||
|
if existing:
|
||||||
|
has_dateadded = bool(existing.get("dateadded"))
|
||||||
|
source_value = existing.get("source")
|
||||||
|
_log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: has_dateadded={has_dateadded}, source='{source_value}', dateadded='{existing.get('dateadded')}'")
|
||||||
|
else:
|
||||||
|
_log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: No database record found")
|
||||||
|
|
||||||
|
# If we have complete data in database, use it and skip all other checks
|
||||||
|
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||||
|
_log("INFO", f"✅ TIER 1 - Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})")
|
||||||
|
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||||
|
|
||||||
|
# Convert datetime objects to strings for NFO manager
|
||||||
|
if hasattr(dateadded, 'isoformat'):
|
||||||
|
dateadded = dateadded.isoformat()
|
||||||
|
if released and hasattr(released, 'isoformat'):
|
||||||
|
released = released.isoformat()
|
||||||
|
|
||||||
|
# Create NFO with existing data and update files
|
||||||
|
if config.manage_nfo:
|
||||||
|
self.nfo_manager.create_movie_nfo(
|
||||||
|
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.fix_dir_mtimes and dateadded:
|
||||||
|
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||||
|
|
||||||
|
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
|
||||||
|
return "processed"
|
||||||
|
else:
|
||||||
|
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
|
||||||
|
|
||||||
|
# TIER 2: Check if NFO file has NFOGuard data and cache it in database
|
||||||
|
nfo_path = movie_path / "movie.nfo"
|
||||||
|
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
|
||||||
|
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
|
||||||
|
|
||||||
|
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||||
|
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
|
||||||
|
|
||||||
|
if nfo_data:
|
||||||
|
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
|
||||||
|
dateadded = nfo_data["dateadded"]
|
||||||
|
source = nfo_data["source"]
|
||||||
|
released = nfo_data.get("released")
|
||||||
|
|
||||||
|
# Cache NFO data in database for future lookups
|
||||||
|
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||||
|
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||||
|
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
|
||||||
|
|
||||||
|
# Update file mtimes if enabled (NFO is already correct)
|
||||||
|
if config.fix_dir_mtimes and dateadded:
|
||||||
|
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||||
|
|
||||||
|
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-cached]")
|
||||||
|
return "processed"
|
||||||
|
|
||||||
|
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
|
||||||
|
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
|
||||||
|
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
|
||||||
|
if existing_nfo_data and config.manual_scan_prioritize_nfo:
|
||||||
|
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||||
|
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
|
||||||
|
dateadded = existing_nfo_data["dateadded"]
|
||||||
|
source = existing_nfo_data["source"]
|
||||||
|
released = existing_nfo_data.get("released")
|
||||||
|
|
||||||
|
# Cache existing data in database and add proper NFOGuard formatting
|
||||||
|
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||||
|
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||||
|
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
|
||||||
|
|
||||||
|
# Update NFO file to add NFOGuard formatting (lockdata, comment)
|
||||||
|
if config.manage_nfo:
|
||||||
|
self.nfo_manager.create_movie_nfo(
|
||||||
|
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||||
|
)
|
||||||
|
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
|
||||||
|
|
||||||
|
# Update file mtimes if enabled
|
||||||
|
if config.fix_dir_mtimes and dateadded:
|
||||||
|
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||||
|
|
||||||
|
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [existing-nfo-enhanced]")
|
||||||
|
return "processed"
|
||||||
|
elif existing_nfo_data:
|
||||||
|
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||||
|
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
|
||||||
|
# Store NFO data as fallback but continue to TIER 3 to check external APIs
|
||||||
|
nfo_fallback_data = existing_nfo_data
|
||||||
|
|
||||||
|
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
|
||||||
|
if is_tmdb_fallback:
|
||||||
|
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
|
||||||
|
if tmdb_nfo_data:
|
||||||
|
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
|
||||||
|
dateadded = tmdb_nfo_data["dateadded"]
|
||||||
|
source = tmdb_nfo_data["source"]
|
||||||
|
released = tmdb_nfo_data.get("released")
|
||||||
|
|
||||||
|
# Create NFO with NFOGuard fields added
|
||||||
|
if config.manage_nfo:
|
||||||
|
self.nfo_manager.create_movie_nfo(
|
||||||
|
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update file mtimes if enabled
|
||||||
|
if config.fix_dir_mtimes and dateadded:
|
||||||
|
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}) [tmdb-nfo]")
|
||||||
|
return "processed"
|
||||||
|
|
||||||
|
# TIER 3: No cached data found - proceed with API lookups and verification
|
||||||
|
|
||||||
|
# Check for shutdown signal before expensive API operations
|
||||||
|
if shutdown_event and shutdown_event.is_set():
|
||||||
|
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing before API calls: {movie_path.name}")
|
||||||
|
return "shutdown"
|
||||||
|
|
||||||
|
# TIER 3: No cached data found - determine if we should query APIs
|
||||||
|
if webhook_mode:
|
||||||
|
_log("INFO", f"Webhook processing - no cached data found, using full date decision logic")
|
||||||
|
should_query = True # Always query for webhooks when no cached data exists
|
||||||
|
else:
|
||||||
|
# Manual scan mode - determine if we should query APIs
|
||||||
|
should_query = config.movie_poll_mode == "always"
|
||||||
|
_log("DEBUG", f"Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}")
|
||||||
|
|
||||||
|
# Use existing movie date decision logic
|
||||||
|
# Pass NFO fallback data if available for cases where external APIs don't have import history
|
||||||
|
nfo_fallback = locals().get('nfo_fallback_data', None)
|
||||||
|
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, nfo_fallback)
|
||||||
|
|
||||||
|
# Webhook fallback: if ALL date sources fail, use current timestamp
|
||||||
|
if webhook_mode and 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"
|
||||||
|
|
||||||
|
# If we don't have an import/download date but we have a release date, use it as dateadded
|
||||||
|
# This ensures we save digital release dates, theatrical dates, etc. to the database
|
||||||
|
final_dateadded = dateadded
|
||||||
|
final_source = source
|
||||||
|
|
||||||
|
if dateadded is None and released is not None:
|
||||||
|
final_dateadded = released
|
||||||
|
final_source = f"{source}_as_dateadded" if source else "release_date_fallback"
|
||||||
|
_log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})")
|
||||||
|
|
||||||
|
# Create NFO regardless of date availability (preserves existing metadata)
|
||||||
|
if config.debug:
|
||||||
|
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
|
||||||
|
if config.manage_nfo:
|
||||||
|
if config.debug:
|
||||||
|
print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}")
|
||||||
|
self.nfo_manager.create_movie_nfo(
|
||||||
|
movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if config.debug:
|
||||||
|
print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation")
|
||||||
|
|
||||||
|
# Skip remaining processing if no valid date found and file dates disabled
|
||||||
|
if final_dateadded is None:
|
||||||
|
_log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed")
|
||||||
|
self.db.upsert_movie_dates(imdb_id, released, None, source, True)
|
||||||
|
return "processed"
|
||||||
|
|
||||||
|
# Update dateadded and source for the rest of processing
|
||||||
|
dateadded = final_dateadded
|
||||||
|
source = final_source
|
||||||
|
|
||||||
|
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
|
||||||
|
|
||||||
|
# Update file mtimes (only if we have a valid date)
|
||||||
|
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
|
||||||
|
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||||
|
|
||||||
|
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
|
||||||
|
|
||||||
|
# Yield control briefly during movie processing to allow web interface requests
|
||||||
|
import time
|
||||||
|
time.sleep(0.005) # 5ms yield per movie to improve responsiveness
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
|
||||||
|
try:
|
||||||
|
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||||
|
_log("DEBUG", f"Database save completed for {imdb_id}")
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database save failed for {imdb_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||||
|
return "processed"
|
||||||
|
|
||||||
|
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||||
|
"""Extract date information from TMDB-based NFO file"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path)
|
||||||
|
|
||||||
|
# Look for premiered date (from TMDB)
|
||||||
|
premiered_elem = root.find('.//premiered')
|
||||||
|
if premiered_elem is not None and premiered_elem.text:
|
||||||
|
premiered_date = premiered_elem.text.strip()
|
||||||
|
print(f"✅ Found TMDB premiered date: {premiered_date}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dateadded": premiered_date,
|
||||||
|
"source": "tmdb:premiered_from_nfo",
|
||||||
|
"released": premiered_date
|
||||||
|
}
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception) as e:
|
||||||
|
print(f"⚠️ Error parsing TMDB NFO for dates: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||||
|
"""Extract any valid date information from NFO file, even without NFOGuard markers"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Look for dateadded element (indicates previously processed by NFOGuard or similar)
|
||||||
|
dateadded_elem = root.find('.//dateadded')
|
||||||
|
premiered_elem = root.find('.//premiered')
|
||||||
|
|
||||||
|
if dateadded_elem is not None and dateadded_elem.text:
|
||||||
|
dateadded = dateadded_elem.text.strip()
|
||||||
|
|
||||||
|
# Try to determine source from NFOGuard comment if present
|
||||||
|
source = "existing_nfo_data"
|
||||||
|
try:
|
||||||
|
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||||
|
import re
|
||||||
|
# Look for NFOGuard comment pattern
|
||||||
|
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
|
||||||
|
if source_match:
|
||||||
|
source = source_match.group(1).strip()
|
||||||
|
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
|
||||||
|
else:
|
||||||
|
# Try to infer source from dateadded format/content
|
||||||
|
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text):
|
||||||
|
source = "tmdb:digital"
|
||||||
|
elif "radarr" in nfo_content.lower():
|
||||||
|
source = "radarr:db.history.import"
|
||||||
|
else:
|
||||||
|
source = "existing_nfo_data"
|
||||||
|
_log("DEBUG", f"Inferred source from NFO content: {source}")
|
||||||
|
except Exception as e:
|
||||||
|
_log("DEBUG", f"Could not determine source from NFO content: {e}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"dateadded": dateadded,
|
||||||
|
"source": source
|
||||||
|
}
|
||||||
|
|
||||||
|
if premiered_elem is not None and premiered_elem.text:
|
||||||
|
result["released"] = premiered_elem.text.strip()
|
||||||
|
|
||||||
|
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception) as e:
|
||||||
|
_log("DEBUG", f"Error parsing NFO for existing date data: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
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"""
|
||||||
|
_log("DEBUG", f"_decide_movie_dates for {imdb_id}: should_query={should_query}, existing={existing}")
|
||||||
|
|
||||||
|
if not should_query and existing:
|
||||||
|
_log("DEBUG", f"Using existing data without querying: dateadded={existing.get('dateadded')}, source={existing.get('source')}")
|
||||||
|
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: check if we have NFO fallback data (when external APIs don't have import history)
|
||||||
|
if existing and existing.get('dateadded'):
|
||||||
|
_log("INFO", f"✅ Movie {imdb_id}: External APIs don't have import history, using NFO fallback date: {existing['dateadded']} (source: {existing['source']})")
|
||||||
|
return existing["dateadded"], f"nfo_fallback:{existing['source']}", existing.get("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")
|
||||||
|
|
||||||
|
# Log to failed movies debug file for troubleshooting
|
||||||
|
self._log_failed_movie(movie_path, imdb_id, "No import date, no release date, file date fallback disabled")
|
||||||
|
|
||||||
|
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 <premiered>YYYY-MM-DD</premiered>
|
||||||
|
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', 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 <premiered> tag found in existing NFO")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Error reading Radarr NFO file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None):
|
||||||
|
"""Log movies that failed to get valid dates to a debug file"""
|
||||||
|
try:
|
||||||
|
log_dir = Path("logs")
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
failed_log_path = log_dir / "failed_movies.log"
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
log_entry = f"[{timestamp}] {movie_path.name} | IMDb: {imdb_id} | Reason: {reason}"
|
||||||
|
if available_countries:
|
||||||
|
log_entry += f" | Available Countries: {', '.join(available_countries)}"
|
||||||
|
log_entry += "\n"
|
||||||
|
|
||||||
|
with open(failed_log_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(log_entry)
|
||||||
|
|
||||||
|
_log("INFO", f"📝 Logged failed movie to {failed_log_path}: {movie_path.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to write to failed movies log: {e}")
|
||||||
|
|
||||||
|
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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TV Series Processor - Clean implementation for TV episode processing
|
||||||
|
Handles manual scans and webhook processing with proper NFO filename matching
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import re
|
||||||
|
|
||||||
|
from core.database import NFOGuardDatabase
|
||||||
|
from core.episode_nfo_manager import EpisodeNFOManager
|
||||||
|
from core.nfo_manager import NFOManager
|
||||||
|
from core.path_mapper import PathMapper
|
||||||
|
from clients.sonarr_client import SonarrClient
|
||||||
|
from clients.external_clients import ExternalClientManager
|
||||||
|
from core.logging import _log, convert_utc_to_local
|
||||||
|
|
||||||
|
|
||||||
|
class TVSeriesProcessor:
|
||||||
|
"""Clean TV series processor with video filename matching"""
|
||||||
|
|
||||||
|
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager,
|
||||||
|
path_mapper: PathMapper, sonarr_client: SonarrClient):
|
||||||
|
self.db = db
|
||||||
|
self.nfo_manager = nfo_manager # Keep for series/season NFOs
|
||||||
|
self.episode_nfo_manager = EpisodeNFOManager()
|
||||||
|
self.path_mapper = path_mapper
|
||||||
|
self.sonarr = sonarr_client
|
||||||
|
self.external_manager = ExternalClientManager()
|
||||||
|
|
||||||
|
def process_series_manual_scan(self, series_path: Path) -> bool:
|
||||||
|
"""Process a TV series during manual scan"""
|
||||||
|
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||||
|
|
||||||
|
# Extract IMDb ID
|
||||||
|
imdb_id = self._extract_imdb_id(series_path)
|
||||||
|
if not imdb_id:
|
||||||
|
_log("ERROR", f"No IMDb ID found for series: {series_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Find all episodes on disk
|
||||||
|
episodes_on_disk = self._find_episodes_on_disk(series_path)
|
||||||
|
if not episodes_on_disk:
|
||||||
|
_log("WARNING", f"No episodes found on disk for: {series_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_log("INFO", f"Found {len(episodes_on_disk)} episodes on disk")
|
||||||
|
|
||||||
|
# Process each episode
|
||||||
|
episodes_processed = 0
|
||||||
|
for (season_num, episode_num), video_files in episodes_on_disk.items():
|
||||||
|
if self._process_episode_manual_scan(series_path, imdb_id, season_num, episode_num):
|
||||||
|
episodes_processed += 1
|
||||||
|
|
||||||
|
# Create series-level NFOs if any episodes were processed
|
||||||
|
if episodes_processed > 0:
|
||||||
|
self._create_series_nfos(series_path, imdb_id)
|
||||||
|
|
||||||
|
_log("INFO", f"Completed processing series: {series_path.name} ({episodes_processed} episodes)")
|
||||||
|
return episodes_processed > 0
|
||||||
|
|
||||||
|
def process_episode_webhook(self, webhook_data: Dict[str, Any]) -> bool:
|
||||||
|
"""Process a single episode from Sonarr webhook"""
|
||||||
|
# TODO: Parse webhook data and extract episode info
|
||||||
|
# This will be implemented when we add webhook support
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _extract_imdb_id(self, series_path: Path) -> Optional[str]:
|
||||||
|
"""Extract IMDb ID from series directory or files"""
|
||||||
|
# Try directory name first
|
||||||
|
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try tvshow.nfo if it exists
|
||||||
|
tvshow_nfo = series_path / "tvshow.nfo"
|
||||||
|
if tvshow_nfo.exists():
|
||||||
|
imdb_id = self.nfo_manager.parse_imdb_from_nfo(tvshow_nfo)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try any existing episode NFO files
|
||||||
|
for season_dir in series_path.iterdir():
|
||||||
|
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||||
|
for nfo_file in season_dir.glob("*.nfo"):
|
||||||
|
imdb_id = self.nfo_manager.parse_imdb_from_nfo(nfo_file)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_season_directory(self, dirname: str) -> bool:
|
||||||
|
"""Check if directory name matches season pattern"""
|
||||||
|
return bool(re.match(r'^[Ss]eason\s+\d+$', dirname, re.IGNORECASE))
|
||||||
|
|
||||||
|
def _find_episodes_on_disk(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||||
|
"""Find all episodes on disk, grouped by (season, episode)"""
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_log("DEBUG", f"Scanning for season directories in: {series_path}")
|
||||||
|
_log("DEBUG", f"Series path exists: {series_path.exists()}, is_dir: {series_path.is_dir()}")
|
||||||
|
|
||||||
|
if not series_path.exists() or not series_path.is_dir():
|
||||||
|
_log("ERROR", f"Series path does not exist or is not a directory: {series_path}")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
# List all items in directory for debugging
|
||||||
|
try:
|
||||||
|
items = list(series_path.iterdir())
|
||||||
|
_log("DEBUG", f"Found {len(items)} items in series directory")
|
||||||
|
for item in items:
|
||||||
|
_log("DEBUG", f" Item: {item.name} (is_dir: {item.is_dir()})")
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to list directory contents: {e}")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
for season_dir in series_path.iterdir():
|
||||||
|
_log("DEBUG", f"Checking directory: {season_dir.name} (is_dir: {season_dir.is_dir()})")
|
||||||
|
_log("DEBUG", f"Season directory regex test for '{season_dir.name}': {self._is_season_directory(season_dir.name)}")
|
||||||
|
|
||||||
|
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||||
|
season_num = self._extract_season_number(season_dir.name)
|
||||||
|
_log("DEBUG", f"Found season directory: {season_dir.name} → season {season_num}")
|
||||||
|
if season_num is None:
|
||||||
|
_log("WARNING", f"Could not extract season number from: {season_dir.name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find video files in this season
|
||||||
|
season_episodes = self.episode_nfo_manager.find_video_files_for_season(season_dir)
|
||||||
|
_log("DEBUG", f"Found {len(season_episodes)} episodes in {season_dir.name}: {list(season_episodes.keys())}")
|
||||||
|
|
||||||
|
# Add season directory info to episodes
|
||||||
|
for (s_num, e_num), video_files in season_episodes.items():
|
||||||
|
_log("DEBUG", f"Episode S{s_num:02d}E{e_num:02d}: season_dir={season_num}, filename_season={s_num}")
|
||||||
|
if s_num == season_num: # Verify season matches directory
|
||||||
|
episodes[(s_num, e_num)] = video_files
|
||||||
|
_log("DEBUG", f"Added episode S{s_num:02d}E{e_num:02d} to processing list")
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Season mismatch: directory={season_num}, filename={s_num} for S{s_num:02d}E{e_num:02d}")
|
||||||
|
|
||||||
|
_log("DEBUG", f"Total episodes found on disk: {len(episodes)}")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Exception in _find_episodes_on_disk: {e}")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
def _extract_season_number(self, dirname: str) -> Optional[int]:
|
||||||
|
"""Extract season number from directory name"""
|
||||||
|
match = re.search(r'[Ss]eason\s+(\d+)', dirname, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _process_episode_manual_scan(self, series_path: Path, imdb_id: str,
|
||||||
|
season_num: int, episode_num: int) -> bool:
|
||||||
|
"""Process a single episode during manual scan"""
|
||||||
|
season_dir = series_path / f"Season {season_num:02d}"
|
||||||
|
if not season_dir.exists():
|
||||||
|
# Try alternate format
|
||||||
|
season_dir = series_path / f"Season {season_num}"
|
||||||
|
if not season_dir.exists():
|
||||||
|
_log("ERROR", f"Season directory not found for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_log("DEBUG", f"Processing episode S{season_num:02d}E{episode_num:02d}")
|
||||||
|
|
||||||
|
# Step 1: Check for existing NFOGuard data
|
||||||
|
existing_nfo = self.episode_nfo_manager.find_nfo_for_episode(season_dir, season_num, episode_num)
|
||||||
|
if existing_nfo:
|
||||||
|
nfo_data = self.episode_nfo_manager.extract_nfoguard_data(existing_nfo)
|
||||||
|
if nfo_data:
|
||||||
|
# Verify against database
|
||||||
|
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||||
|
if db_data and db_data.get("dateadded") == nfo_data.get("dateadded"):
|
||||||
|
_log("DEBUG", f"Episode S{season_num:02d}E{episode_num:02d} already up to date")
|
||||||
|
# Still migrate filename if needed
|
||||||
|
self.episode_nfo_manager.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Step 2: Check database
|
||||||
|
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||||
|
if db_data and db_data.get("dateadded"):
|
||||||
|
_log("DEBUG", f"Using database data for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
aired = db_data.get("aired")
|
||||||
|
dateadded = db_data.get("dateadded")
|
||||||
|
source = db_data.get("source", "database")
|
||||||
|
else:
|
||||||
|
# Step 3: Query Sonarr for episode data
|
||||||
|
aired, dateadded, source = self._get_episode_dates_from_sonarr(imdb_id, season_num, episode_num)
|
||||||
|
|
||||||
|
# Step 4: Create/update NFO and database
|
||||||
|
if dateadded or aired:
|
||||||
|
# Get episode metadata for title/plot
|
||||||
|
title, plot = self._get_episode_metadata_from_sonarr(imdb_id, season_num, episode_num)
|
||||||
|
|
||||||
|
# Use aired date as dateadded if no import date found (user requirement)
|
||||||
|
if not dateadded and aired:
|
||||||
|
dateadded = aired
|
||||||
|
if source == "no_data_found":
|
||||||
|
source = "tmdb:air_date_fallback"
|
||||||
|
else:
|
||||||
|
source = f"{source}_used_as_dateadded"
|
||||||
|
_log("INFO", f"Using aired date as dateadded for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||||
|
|
||||||
|
# Create/update NFO with video filename
|
||||||
|
success = self.episode_nfo_manager.create_episode_nfo(
|
||||||
|
season_dir, season_num, episode_num, aired, dateadded, source, title, plot
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Update database
|
||||||
|
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
_log("WARNING", f"Could not get dates for episode S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
|
||||||
|
"""Get episode dates from Sonarr API with proper fallback"""
|
||||||
|
aired = None
|
||||||
|
dateadded = None
|
||||||
|
source = "no_data_found"
|
||||||
|
|
||||||
|
if not self.sonarr.enabled:
|
||||||
|
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find series in Sonarr using lookup endpoint first
|
||||||
|
series = self.sonarr.series_by_imdb(imdb_id)
|
||||||
|
if not series:
|
||||||
|
_log("WARNING", f"Series not found via Sonarr lookup for IMDb: {imdb_id}")
|
||||||
|
# Try direct method as fallback (slower but more reliable)
|
||||||
|
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
|
||||||
|
series = self.sonarr.series_by_imdb_direct(imdb_id)
|
||||||
|
if not series:
|
||||||
|
_log("WARNING", f"Series not found via direct search either for IMDb: {imdb_id}")
|
||||||
|
# Fall through to external API fallback
|
||||||
|
|
||||||
|
if series:
|
||||||
|
# Get episodes for series
|
||||||
|
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||||
|
target_episode = None
|
||||||
|
|
||||||
|
for episode in episodes:
|
||||||
|
if (episode.get("seasonNumber") == season_num and
|
||||||
|
episode.get("episodeNumber") == episode_num):
|
||||||
|
target_episode = episode
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_episode:
|
||||||
|
_log("WARNING", f"Episode S{season_num:02d}E{episode_num:02d} not found in Sonarr")
|
||||||
|
# Don't return here - fall through to external API fallback
|
||||||
|
else:
|
||||||
|
# Get airdate
|
||||||
|
aired = target_episode.get("airDateUtc")
|
||||||
|
|
||||||
|
# Try to get import history
|
||||||
|
episode_id = target_episode.get("id")
|
||||||
|
if episode_id:
|
||||||
|
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||||
|
if import_date:
|
||||||
|
dateadded = convert_utc_to_local(import_date)
|
||||||
|
source = "sonarr:history.import"
|
||||||
|
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
# Fallback to airdate if no import history
|
||||||
|
if aired:
|
||||||
|
dateadded = convert_utc_to_local(aired)
|
||||||
|
source = "sonarr:episode.airDateUtc"
|
||||||
|
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Sonarr API error for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||||
|
|
||||||
|
# Try external APIs for episode airdate
|
||||||
|
_log("INFO", f"Trying external APIs for episode S{season_num:02d}E{episode_num:02d} airdate")
|
||||||
|
aired, source = self._get_episode_airdate_from_external_apis(imdb_id, season_num, episode_num)
|
||||||
|
if aired:
|
||||||
|
dateadded = convert_utc_to_local(aired)
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
_log("ERROR", f"Could not get any date information for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
def _get_episode_metadata_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Get episode title and plot from Sonarr"""
|
||||||
|
if not self.sonarr.enabled:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
series = self.sonarr.series_by_imdb(imdb_id)
|
||||||
|
if not series:
|
||||||
|
# Try direct method as fallback
|
||||||
|
series = self.sonarr.series_by_imdb_direct(imdb_id)
|
||||||
|
|
||||||
|
if series:
|
||||||
|
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||||
|
for episode in episodes:
|
||||||
|
if (episode.get("seasonNumber") == season_num and
|
||||||
|
episode.get("episodeNumber") == episode_num):
|
||||||
|
title = episode.get("title")
|
||||||
|
plot = episode.get("overview")
|
||||||
|
return title, plot
|
||||||
|
except Exception as e:
|
||||||
|
_log("DEBUG", f"Could not get metadata for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _get_episode_airdate_from_external_apis(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], str]:
|
||||||
|
"""Get episode airdate from external APIs (TMDB, OMDb) as fallback"""
|
||||||
|
# Try TMDB first
|
||||||
|
if self.external_manager.tmdb.enabled:
|
||||||
|
try:
|
||||||
|
_log("DEBUG", f"Trying TMDB for episode S{season_num:02d}E{episode_num:02d} airdate")
|
||||||
|
|
||||||
|
# First convert IMDb to TMDB TV ID
|
||||||
|
tv_search = self.external_manager.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
|
||||||
|
if tv_search and tv_search.get("tv_results"):
|
||||||
|
tv_id = tv_search["tv_results"][0].get("id")
|
||||||
|
if tv_id:
|
||||||
|
_log("DEBUG", f"Found TMDB TV ID {tv_id} for {imdb_id}")
|
||||||
|
|
||||||
|
# Get episode details
|
||||||
|
episode_data = self.external_manager.tmdb._get(f"/tv/{tv_id}/season/{season_num}/episode/{episode_num}")
|
||||||
|
if episode_data and episode_data.get("air_date"):
|
||||||
|
airdate = episode_data["air_date"]
|
||||||
|
# Convert to ISO format with UTC timezone
|
||||||
|
iso_airdate = f"{airdate}T00:00:00Z"
|
||||||
|
_log("INFO", f"Found TMDB airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
|
||||||
|
return iso_airdate, "tmdb:episode.air_date"
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"TMDB episode lookup failed for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||||
|
|
||||||
|
# Try OMDb as fallback
|
||||||
|
if self.external_manager.omdb.enabled:
|
||||||
|
try:
|
||||||
|
_log("DEBUG", f"Trying OMDb for episode S{season_num:02d}E{episode_num:02d} airdate")
|
||||||
|
episode_dates = self.external_manager.omdb.get_tv_season_episodes(imdb_id, season_num)
|
||||||
|
if episode_num in episode_dates:
|
||||||
|
airdate = episode_dates[episode_num]
|
||||||
|
# Convert to ISO format
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
try:
|
||||||
|
# Try to parse OMDb date format (usually DD MMM YYYY)
|
||||||
|
dt = datetime.strptime(airdate, "%d %b %Y").replace(tzinfo=timezone.utc)
|
||||||
|
iso_airdate = dt.isoformat(timespec="seconds")
|
||||||
|
_log("INFO", f"Found OMDb airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
|
||||||
|
return iso_airdate, "omdb:episode.released"
|
||||||
|
except ValueError:
|
||||||
|
# Try other common formats
|
||||||
|
for fmt in ["%Y-%m-%d", "%d %B %Y"]:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(airdate, fmt).replace(tzinfo=timezone.utc)
|
||||||
|
iso_airdate = dt.isoformat(timespec="seconds")
|
||||||
|
_log("INFO", f"Found OMDb airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
|
||||||
|
return iso_airdate, "omdb:episode.released"
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"OMDb episode lookup failed for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||||
|
|
||||||
|
_log("WARNING", f"No external API airdate found for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return None, "no_external_data"
|
||||||
|
|
||||||
|
def _create_series_nfos(self, series_path: Path, imdb_id: str):
|
||||||
|
"""Create tvshow.nfo and season.nfo files only if they don't exist"""
|
||||||
|
# Create tvshow.nfo only if it doesn't exist
|
||||||
|
tvshow_nfo = series_path / "tvshow.nfo"
|
||||||
|
if not tvshow_nfo.exists():
|
||||||
|
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
|
||||||
|
else:
|
||||||
|
_log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}")
|
||||||
|
|
||||||
|
# Create season.nfo for each season directory only if they don't exist
|
||||||
|
for season_dir in series_path.iterdir():
|
||||||
|
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||||
|
season_num = self._extract_season_number(season_dir.name)
|
||||||
|
if season_num is not None:
|
||||||
|
season_nfo = season_dir / "season.nfo"
|
||||||
|
if not season_nfo.exists():
|
||||||
|
self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||||
|
else:
|
||||||
|
_log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
aiohttp==3.8.6
|
||||||
|
psutil==5.9.6
|
||||||
|
python-dotenv==1.0.0
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
/* NFOGuard Web Interface Styles */
|
||||||
|
:root {
|
||||||
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--success-color: #28a745;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--dark-color: #343a40;
|
||||||
|
--light-color: #f8f9fa;
|
||||||
|
--border-color: #dee2e6;
|
||||||
|
--text-muted: #6c757d;
|
||||||
|
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--dark-color);
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.app-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 0;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content p {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||||
|
.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||||
|
.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
|
||||||
|
.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--light-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Header */
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header h2 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box i {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
background-color: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background-color: var(--light-color);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background-color: rgba(0, 123, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table .loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-secondary {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-info {
|
||||||
|
margin: 0 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Higher z-index for edit modals that appear on top of other modals */
|
||||||
|
#edit-modal, #smart-fix-modal {
|
||||||
|
z-index: 1100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reports */
|
||||||
|
.report-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card span {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tools */
|
||||||
|
.tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--dark-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-display {
|
||||||
|
background: var(--light-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notifications */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-width: 300px;
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-left-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning {
|
||||||
|
border-left-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-left-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smart Fix Modal */
|
||||||
|
.smart-fix-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card {
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
display: block;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label input[type="radio"] {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-date-input {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card input[type="radio"]:checked + .option-content {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card:has(input[type="radio"]:checked) {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: rgba(0, 123, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional badge styles */
|
||||||
|
.badge-info {
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Edit Modal Date Options */
|
||||||
|
.date-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-card:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-label {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-label input[type="radio"] {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-content h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
color: var(--dark-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-content p {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-content small {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-card input[type="radio"]:checked + .date-option-content h4 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-option-card:has(input[type="radio"]:checked) {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: rgba(0, 123, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-controls {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.d-none { display: none; }
|
||||||
|
.d-block { display: block; }
|
||||||
|
.d-flex { display: flex; }
|
||||||
|
.justify-content-between { justify-content: space-between; }
|
||||||
|
.align-items-center { align-items: center; }
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NFOGuard - Database Management</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
|
||||||
|
<p>Database Management & Reporting</p>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<button class="nav-tab active" data-tab="dashboard">
|
||||||
|
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||||
|
</button>
|
||||||
|
<button class="nav-tab" data-tab="movies">
|
||||||
|
<i class="fas fa-film"></i> Movies
|
||||||
|
</button>
|
||||||
|
<button class="nav-tab" data-tab="tv">
|
||||||
|
<i class="fas fa-tv"></i> TV Series
|
||||||
|
</button>
|
||||||
|
<button class="nav-tab" data-tab="reports">
|
||||||
|
<i class="fas fa-chart-bar"></i> Reports
|
||||||
|
</button>
|
||||||
|
<button class="nav-tab" data-tab="tools">
|
||||||
|
<i class="fas fa-tools"></i> Tools
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Dashboard Tab -->
|
||||||
|
<div class="tab-content active" id="dashboard">
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon movies">
|
||||||
|
<i class="fas fa-film"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="movies-total">-</h3>
|
||||||
|
<p>Total Movies</p>
|
||||||
|
<small id="movies-with-dates">- with dates</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon tv">
|
||||||
|
<i class="fas fa-tv"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="series-total">-</h3>
|
||||||
|
<p>TV Series</p>
|
||||||
|
<small id="episodes-total">- episodes</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon missing">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="missing-dates-total">-</h3>
|
||||||
|
<p>Missing Dates</p>
|
||||||
|
<small id="no-valid-source-total">- no valid source</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon activity">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="recent-activity">-</h3>
|
||||||
|
<p>Recent Activity</p>
|
||||||
|
<small>Last 7 days</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-charts">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><i class="fas fa-chart-pie"></i> Movie Sources</h3>
|
||||||
|
<div id="movie-sources-chart" class="chart-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><i class="fas fa-chart-pie"></i> Episode Sources</h3>
|
||||||
|
<div id="episode-sources-chart" class="chart-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Movies Tab -->
|
||||||
|
<div class="tab-content" id="movies">
|
||||||
|
<div class="content-header">
|
||||||
|
<h2><i class="fas fa-film"></i> Movies Database</h2>
|
||||||
|
<div class="content-controls">
|
||||||
|
<div class="search-controls">
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text" id="movies-search" placeholder="Search title/path...">
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fas fa-hashtag"></i>
|
||||||
|
<input type="text" id="movies-imdb-search" placeholder="Search IMDb ID...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-controls">
|
||||||
|
<select id="movies-filter-date">
|
||||||
|
<option value="">All Movies</option>
|
||||||
|
<option value="true">With Dates</option>
|
||||||
|
<option value="false">Missing Dates</option>
|
||||||
|
</select>
|
||||||
|
<select id="movies-filter-source">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="refreshMovies()">
|
||||||
|
<i class="fas fa-sync"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table" id="movies-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>IMDb ID</th>
|
||||||
|
<th>Movie Released</th>
|
||||||
|
<th>Date Added to Library</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Date Type</th>
|
||||||
|
<th>Video File</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="movies-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="loading">Loading movies...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="movies-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TV Series Tab -->
|
||||||
|
<div class="tab-content" id="tv">
|
||||||
|
<div class="content-header">
|
||||||
|
<h2><i class="fas fa-tv"></i> TV Series Database</h2>
|
||||||
|
<div class="content-controls">
|
||||||
|
<div class="search-controls">
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text" id="series-search" placeholder="Search title/path...">
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fas fa-hashtag"></i>
|
||||||
|
<input type="text" id="series-imdb-search" placeholder="Search IMDb ID...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-controls">
|
||||||
|
<select id="series-filter-date">
|
||||||
|
<option value="">All Series</option>
|
||||||
|
<option value="complete">Fully Dated</option>
|
||||||
|
<option value="incomplete">Missing Dates</option>
|
||||||
|
<option value="none">No Dates</option>
|
||||||
|
</select>
|
||||||
|
<select id="series-filter-source">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="refreshSeries()">
|
||||||
|
<i class="fas fa-sync"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table" id="series-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Series Title</th>
|
||||||
|
<th>IMDb ID</th>
|
||||||
|
<th>Episodes</th>
|
||||||
|
<th>With Dates</th>
|
||||||
|
<th>With Video</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="series-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading">Loading series...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="series-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reports Tab -->
|
||||||
|
<div class="tab-content" id="reports">
|
||||||
|
<div class="content-header">
|
||||||
|
<h2><i class="fas fa-chart-bar"></i> Missing Dates Report</h2>
|
||||||
|
<div class="content-controls">
|
||||||
|
<button class="btn btn-primary" onclick="refreshReport()">
|
||||||
|
<i class="fas fa-sync"></i> Refresh Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-summary" id="report-summary">
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>Movies</h3>
|
||||||
|
<p><span id="report-movies-with">-</span> with dates</p>
|
||||||
|
<p><span id="report-movies-missing">-</span> missing dates</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>Episodes</h3>
|
||||||
|
<p><span id="report-episodes-with">-</span> with dates</p>
|
||||||
|
<p><span id="report-episodes-missing">-</span> missing dates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-content">
|
||||||
|
<div class="report-section">
|
||||||
|
<h3><i class="fas fa-film"></i> Movies Missing Dates</h3>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>IMDb ID</th>
|
||||||
|
<th>Released</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Smart Fix</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="report-movies-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading">Loading report...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-section">
|
||||||
|
<h3><i class="fas fa-tv"></i> Episodes Missing Dates</h3>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Series</th>
|
||||||
|
<th>Episode</th>
|
||||||
|
<th>IMDb ID</th>
|
||||||
|
<th>Aired</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Smart Fix</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="report-episodes-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading">Loading report...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tools Tab -->
|
||||||
|
<div class="tab-content" id="tools">
|
||||||
|
<div class="content-header">
|
||||||
|
<h2><i class="fas fa-tools"></i> Database Tools</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tools-grid">
|
||||||
|
<div class="tool-card">
|
||||||
|
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
|
||||||
|
<p>Change source for multiple items at once</p>
|
||||||
|
<form id="bulk-update-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Media Type:</label>
|
||||||
|
<select id="bulk-media-type" required>
|
||||||
|
<option value="">Select type...</option>
|
||||||
|
<option value="movies">Movies</option>
|
||||||
|
<option value="episodes">Episodes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>From Source:</label>
|
||||||
|
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>To Source:</label>
|
||||||
|
<select id="bulk-new-source" required>
|
||||||
|
<option value="">Select new source...</option>
|
||||||
|
<option value="airdate">Air Date</option>
|
||||||
|
<option value="digital_release">Digital Release</option>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
<option value="radarr:db.history.import">Radarr Import</option>
|
||||||
|
<option value="sonarr:history.import">Sonarr Import</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning">
|
||||||
|
<i class="fas fa-exchange-alt"></i> Update Sources
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-card">
|
||||||
|
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||||
|
<p>View detailed database information</p>
|
||||||
|
<div class="stats-display" id="detailed-stats">
|
||||||
|
<p>Click refresh to load detailed statistics</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" onclick="loadDetailedStats()">
|
||||||
|
<i class="fas fa-sync"></i> Refresh Stats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Smart Fix Modal -->
|
||||||
|
<div class="modal" id="smart-fix-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="smart-fix-title">Choose Date Source</h3>
|
||||||
|
<button class="modal-close" onclick="closeSmartFixModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="smart-fix-content">
|
||||||
|
<p>Loading available options...</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeSmartFixModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div class="modal" id="edit-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title">Edit Entry</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="edit-form">
|
||||||
|
<input type="hidden" id="edit-imdb-id">
|
||||||
|
<input type="hidden" id="edit-season">
|
||||||
|
<input type="hidden" id="edit-episode">
|
||||||
|
<input type="hidden" id="edit-media-type">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-dateadded">Date Added:</label>
|
||||||
|
<input type="datetime-local" id="edit-dateadded">
|
||||||
|
<small>Leave empty to clear date</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-source">Source:</label>
|
||||||
|
<select id="edit-source" required>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
<option value="airdate">Air Date</option>
|
||||||
|
<option value="digital_release">Digital Release</option>
|
||||||
|
<option value="radarr:db.history.import">Radarr Import</option>
|
||||||
|
<option value="sonarr:history.import">Sonarr Import</option>
|
||||||
|
<option value="no_valid_date_source">No Valid Source</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notifications -->
|
||||||
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
|||||||
|
"""
|
||||||
|
Async file utilities for NFOGuard
|
||||||
|
High-performance async file operations with concurrent processing
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import aiofiles
|
||||||
|
import aiofiles.os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional, Tuple, Any, Set
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.logging import _log
|
||||||
|
from utils.exceptions import FileOperationError, NFOCreationError
|
||||||
|
from utils.file_utils import VIDEO_EXTENSIONS, extract_episode_info, extract_imdb_id_from_path
|
||||||
|
from utils.nfo_patterns import parse_nfo_with_tolerance, write_nfo_file
|
||||||
|
|
||||||
|
|
||||||
|
async def async_read_text_file(file_path: Path, encoding: str = 'utf-8') -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Async read text file with error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file to read
|
||||||
|
encoding: Text encoding (default: utf-8)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File content as string or None if error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(file_path, 'r', encoding=encoding, errors='ignore') as f:
|
||||||
|
return await f.read()
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to read file {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_write_text_file(file_path: Path, content: str, encoding: str = 'utf-8') -> bool:
|
||||||
|
"""
|
||||||
|
Async write text file with error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file to write
|
||||||
|
content: Content to write
|
||||||
|
encoding: Text encoding (default: utf-8)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure parent directory exists
|
||||||
|
await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
|
||||||
|
|
||||||
|
async with aiofiles.open(file_path, 'w', encoding=encoding) as f:
|
||||||
|
await f.write(content)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to write file {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def async_file_exists(file_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Async check if file exists
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if file exists, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await aiofiles.os.path.exists(file_path)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_file_mtime(file_path: Path) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Async get file modification time
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modification time as timestamp or None if error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stat_result = await aiofiles.os.stat(file_path)
|
||||||
|
return stat_result.st_mtime
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_set_file_mtime(file_path: Path, mtime: float) -> bool:
|
||||||
|
"""
|
||||||
|
Async set file modification time
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
mtime: New modification time as timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await aiofiles.os.utime(file_path, (mtime, mtime))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to set mtime for {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def async_find_video_files(directory: Path, recursive: bool = True) -> List[Path]:
|
||||||
|
"""
|
||||||
|
Async find all video files in a directory
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to search
|
||||||
|
recursive: Whether to search recursively
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of video file paths
|
||||||
|
"""
|
||||||
|
if not await async_file_exists(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
video_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if recursive:
|
||||||
|
# Use os.walk equivalent for async
|
||||||
|
async def _walk_directory(path: Path):
|
||||||
|
try:
|
||||||
|
entries = await aiofiles.os.listdir(path)
|
||||||
|
for entry in entries:
|
||||||
|
entry_path = path / entry
|
||||||
|
if await aiofiles.os.path.isfile(entry_path):
|
||||||
|
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
video_files.append(entry_path)
|
||||||
|
elif await aiofiles.os.path.isdir(entry_path):
|
||||||
|
await _walk_directory(entry_path)
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to scan directory {path}: {e}")
|
||||||
|
|
||||||
|
await _walk_directory(directory)
|
||||||
|
else:
|
||||||
|
# Non-recursive scan
|
||||||
|
try:
|
||||||
|
entries = await aiofiles.os.listdir(directory)
|
||||||
|
for entry in entries:
|
||||||
|
entry_path = directory / entry
|
||||||
|
if await aiofiles.os.path.isfile(entry_path):
|
||||||
|
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
video_files.append(entry_path)
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to scan directory {directory}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to find video files in {directory}: {e}")
|
||||||
|
|
||||||
|
return video_files
|
||||||
|
|
||||||
|
|
||||||
|
async def async_find_episodes_on_disk(series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||||
|
"""
|
||||||
|
Async find all episodes on disk with concurrent processing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_path: Path to series directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping (season, episode) tuples to lists of video files
|
||||||
|
"""
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
if not await async_file_exists(series_path):
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
# Get all video files concurrently
|
||||||
|
video_files = await async_find_video_files(series_path, recursive=True)
|
||||||
|
|
||||||
|
# Process files to extract episode information
|
||||||
|
for video_file in video_files:
|
||||||
|
episode_info = extract_episode_info(video_file.name)
|
||||||
|
if episode_info:
|
||||||
|
season, episode = episode_info["season"], episode_info["episode"]
|
||||||
|
key = (season, episode)
|
||||||
|
if key not in episodes:
|
||||||
|
episodes[key] = []
|
||||||
|
episodes[key].append(video_file)
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
async def async_read_nfo_file(nfo_path: Path) -> Optional[ET.Element]:
|
||||||
|
"""
|
||||||
|
Async read and parse NFO file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path to NFO file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
XML root element if successful, None otherwise
|
||||||
|
"""
|
||||||
|
if not await async_file_exists(nfo_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await async_read_text_file(nfo_path)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse XML content
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
return root
|
||||||
|
except ET.ParseError:
|
||||||
|
# Try with tolerance (sync operation for now)
|
||||||
|
return parse_nfo_with_tolerance(nfo_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to read NFO file {nfo_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_write_nfo_file(
|
||||||
|
nfo_path: Path,
|
||||||
|
root: ET.Element,
|
||||||
|
lock_metadata: bool = True
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Async write NFO XML content to file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path where to write the NFO file
|
||||||
|
root: XML root element to write
|
||||||
|
lock_metadata: Whether to add file locking attributes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure parent directory exists
|
||||||
|
await aiofiles.os.makedirs(nfo_path.parent, exist_ok=True)
|
||||||
|
|
||||||
|
# Add file locking if requested
|
||||||
|
if lock_metadata:
|
||||||
|
root.set('nfoguard_managed', 'true')
|
||||||
|
root.set('last_updated', datetime.now().isoformat())
|
||||||
|
|
||||||
|
# Create tree and format
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
ET.indent(tree, space=" ", level=0) # Pretty formatting
|
||||||
|
|
||||||
|
# Convert to string
|
||||||
|
xml_str = ET.tostring(root, encoding='unicode', xml_declaration=False)
|
||||||
|
xml_content = f'<?xml version="1.0" encoding="utf-8"?>\n{xml_str}'
|
||||||
|
|
||||||
|
# Write asynchronously
|
||||||
|
success = await async_write_text_file(nfo_path, xml_content)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
_log("DEBUG", f"Successfully wrote NFO file: {nfo_path}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to write NFO file {nfo_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def async_batch_process_files(
|
||||||
|
file_paths: List[Path],
|
||||||
|
process_func,
|
||||||
|
max_concurrent: int = 10,
|
||||||
|
progress_callback: Optional[callable] = None
|
||||||
|
) -> List[Any]:
|
||||||
|
"""
|
||||||
|
Process multiple files concurrently with controlled concurrency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: List of file paths to process
|
||||||
|
process_func: Async function to process each file
|
||||||
|
max_concurrent: Maximum number of concurrent operations
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of results from processing each file
|
||||||
|
"""
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
async def _process_with_semaphore(file_path: Path, index: int) -> Any:
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
result = await process_func(file_path)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(index + 1, len(file_paths), file_path)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to process {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create tasks for all files
|
||||||
|
tasks = [
|
||||||
|
_process_with_semaphore(file_path, i)
|
||||||
|
for i, file_path in enumerate(file_paths)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Execute all tasks concurrently with controlled concurrency
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def async_batch_nfo_operations(
|
||||||
|
nfo_operations: List[Dict[str, Any]],
|
||||||
|
max_concurrent: int = 5
|
||||||
|
) -> List[bool]:
|
||||||
|
"""
|
||||||
|
Batch NFO operations (read/write) with controlled concurrency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_operations: List of operation dictionaries with 'type', 'path', and other params
|
||||||
|
max_concurrent: Maximum number of concurrent operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of success/failure results
|
||||||
|
"""
|
||||||
|
async def _execute_nfo_operation(operation: Dict[str, Any]) -> bool:
|
||||||
|
try:
|
||||||
|
op_type = operation.get('type')
|
||||||
|
path = operation.get('path')
|
||||||
|
|
||||||
|
if op_type == 'read':
|
||||||
|
result = await async_read_nfo_file(path)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
elif op_type == 'write':
|
||||||
|
root = operation.get('root')
|
||||||
|
lock_metadata = operation.get('lock_metadata', True)
|
||||||
|
return await async_write_nfo_file(path, root, lock_metadata)
|
||||||
|
|
||||||
|
else:
|
||||||
|
_log("ERROR", f"Unknown NFO operation type: {op_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to execute NFO operation: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await async_batch_process_files(
|
||||||
|
[op.get('path') for op in nfo_operations],
|
||||||
|
lambda path: _execute_nfo_operation(next(op for op in nfo_operations if op.get('path') == path)),
|
||||||
|
max_concurrent
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_concurrent_episode_processing(
|
||||||
|
episodes_data: List[Dict[str, Any]],
|
||||||
|
process_episode_func,
|
||||||
|
max_concurrent: int = 3
|
||||||
|
) -> List[Any]:
|
||||||
|
"""
|
||||||
|
Process multiple episodes concurrently
|
||||||
|
|
||||||
|
Args:
|
||||||
|
episodes_data: List of episode data dictionaries
|
||||||
|
process_episode_func: Async function to process each episode
|
||||||
|
max_concurrent: Maximum number of concurrent episode processes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processing results
|
||||||
|
"""
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
|
||||||
|
async def _process_episode_with_semaphore(episode_data: Dict[str, Any]) -> Any:
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
return await process_episode_func(episode_data)
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to process episode {episode_data}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create tasks for all episodes
|
||||||
|
tasks = [
|
||||||
|
_process_episode_with_semaphore(episode_data)
|
||||||
|
for episode_data in episodes_data
|
||||||
|
]
|
||||||
|
|
||||||
|
# Execute all tasks concurrently
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def async_directory_scan_with_stats(
|
||||||
|
directories: List[Path],
|
||||||
|
file_extensions: Optional[Set[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Async scan multiple directories and gather statistics
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directories: List of directories to scan
|
||||||
|
file_extensions: Optional set of file extensions to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with scan statistics and file lists
|
||||||
|
"""
|
||||||
|
if file_extensions is None:
|
||||||
|
file_extensions = VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_files': 0,
|
||||||
|
'total_directories': len(directories),
|
||||||
|
'files_by_directory': {},
|
||||||
|
'scan_errors': [],
|
||||||
|
'total_size_bytes': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _scan_single_directory(directory: Path) -> Dict[str, Any]:
|
||||||
|
dir_stats = {
|
||||||
|
'path': str(directory),
|
||||||
|
'files': [],
|
||||||
|
'file_count': 0,
|
||||||
|
'size_bytes': 0,
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not await async_file_exists(directory):
|
||||||
|
dir_stats['error'] = 'Directory does not exist'
|
||||||
|
return dir_stats
|
||||||
|
|
||||||
|
files = await async_find_video_files(directory, recursive=True)
|
||||||
|
dir_stats['files'] = [str(f) for f in files]
|
||||||
|
dir_stats['file_count'] = len(files)
|
||||||
|
|
||||||
|
# Calculate total size
|
||||||
|
for file_path in files:
|
||||||
|
try:
|
||||||
|
stat_result = await aiofiles.os.stat(file_path)
|
||||||
|
dir_stats['size_bytes'] += stat_result.st_size
|
||||||
|
except Exception:
|
||||||
|
pass # Skip files we can't stat
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
dir_stats['error'] = str(e)
|
||||||
|
stats['scan_errors'].append(f"{directory}: {e}")
|
||||||
|
|
||||||
|
return dir_stats
|
||||||
|
|
||||||
|
# Scan all directories concurrently
|
||||||
|
directory_results = await asyncio.gather(
|
||||||
|
*[_scan_single_directory(directory) for directory in directories],
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate results
|
||||||
|
for result in directory_results:
|
||||||
|
if isinstance(result, dict) and not result.get('error'):
|
||||||
|
stats['files_by_directory'][result['path']] = result
|
||||||
|
stats['total_files'] += result['file_count']
|
||||||
|
stats['total_size_bytes'] += result['size_bytes']
|
||||||
|
|
||||||
|
return stats
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
Error handling utilities for NFOGuard
|
||||||
|
Provides structured error handling, retry mechanisms, and error reporting
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import functools
|
||||||
|
from typing import Callable, Optional, Type, Union, List, Any
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from utils.logging import _log
|
||||||
|
from utils.exceptions import (
|
||||||
|
NFOGuardException,
|
||||||
|
RetryableError,
|
||||||
|
NetworkRetryableError,
|
||||||
|
TemporaryFileError,
|
||||||
|
ExternalAPIError,
|
||||||
|
FileOperationError
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def with_error_handling(
|
||||||
|
operation_name: str,
|
||||||
|
log_errors: bool = True,
|
||||||
|
reraise: bool = True,
|
||||||
|
fallback_value: Any = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decorator for standardized error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation_name: Name of the operation for logging
|
||||||
|
log_errors: Whether to log errors automatically
|
||||||
|
reraise: Whether to reraise exceptions after logging
|
||||||
|
fallback_value: Value to return if error occurs and reraise=False
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except NFOGuardException as e:
|
||||||
|
if log_errors:
|
||||||
|
_log("ERROR", f"{operation_name} failed: {e.message}")
|
||||||
|
if e.details:
|
||||||
|
_log("DEBUG", f"{operation_name} error details: {e.details}")
|
||||||
|
if reraise:
|
||||||
|
raise
|
||||||
|
return fallback_value
|
||||||
|
except Exception as e:
|
||||||
|
if log_errors:
|
||||||
|
_log("ERROR", f"{operation_name} failed with unexpected error: {e}")
|
||||||
|
if reraise:
|
||||||
|
# Wrap unexpected errors in our custom exception
|
||||||
|
raise NFOGuardException(
|
||||||
|
f"{operation_name} failed: {str(e)}",
|
||||||
|
{"original_error": str(e), "error_type": type(e).__name__}
|
||||||
|
)
|
||||||
|
return fallback_value
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def with_retry(
|
||||||
|
max_attempts: int = 3,
|
||||||
|
delay: float = 1.0,
|
||||||
|
backoff_factor: float = 2.0,
|
||||||
|
retry_on: Union[Type[Exception], List[Type[Exception]]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decorator for retry logic on retryable errors
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_attempts: Maximum number of retry attempts
|
||||||
|
delay: Initial delay between retries in seconds
|
||||||
|
backoff_factor: Factor to multiply delay by after each attempt
|
||||||
|
retry_on: Exception types to retry on (defaults to RetryableError)
|
||||||
|
"""
|
||||||
|
if retry_on is None:
|
||||||
|
retry_on = [RetryableError]
|
||||||
|
elif not isinstance(retry_on, list):
|
||||||
|
retry_on = [retry_on]
|
||||||
|
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
current_delay = delay
|
||||||
|
last_exception = None
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
|
||||||
|
# Check if this is a retryable error
|
||||||
|
should_retry = any(isinstance(e, exc_type) for exc_type in retry_on)
|
||||||
|
|
||||||
|
if not should_retry or attempt == max_attempts - 1:
|
||||||
|
# Don't retry or max attempts reached
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Use custom retry delay if available
|
||||||
|
retry_delay = current_delay
|
||||||
|
if isinstance(e, RetryableError) and e.retry_after:
|
||||||
|
retry_delay = e.retry_after
|
||||||
|
|
||||||
|
_log("WARNING", f"Attempt {attempt + 1}/{max_attempts} failed: {e}. Retrying in {retry_delay}s...")
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
current_delay *= backoff_factor
|
||||||
|
|
||||||
|
# This should never be reached, but just in case
|
||||||
|
raise last_exception
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def safe_file_operation(
|
||||||
|
operation: str,
|
||||||
|
file_path: Union[str, Path],
|
||||||
|
operation_func: Callable,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Safely perform file operations with error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Description of the operation
|
||||||
|
file_path: Path to the file being operated on
|
||||||
|
operation_func: Function to execute
|
||||||
|
*args, **kwargs: Arguments to pass to operation_func
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of operation_func or None if error
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileOperationError: If file operation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return operation_func(*args, **kwargs)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise FileOperationError(operation, str(file_path), f"Permission denied: {e}")
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise FileOperationError(operation, str(file_path), f"File not found: {e}")
|
||||||
|
except OSError as e:
|
||||||
|
# Check if this might be a temporary error
|
||||||
|
if e.errno in [28, 122]: # No space left, quota exceeded
|
||||||
|
raise TemporaryFileError(str(file_path), operation, f"Disk space issue: {e}")
|
||||||
|
raise FileOperationError(operation, str(file_path), f"OS error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise FileOperationError(operation, str(file_path), f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_api_call(
|
||||||
|
api_name: str,
|
||||||
|
operation: str,
|
||||||
|
api_func: Callable,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Safely perform API calls with error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_name: Name of the API (e.g., "Sonarr", "TMDB")
|
||||||
|
operation: Description of the operation
|
||||||
|
api_func: Function to execute
|
||||||
|
*args, **kwargs: Arguments to pass to api_func
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of api_func
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ExternalAPIError: If API call fails
|
||||||
|
NetworkRetryableError: If network error that can be retried
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return api_func(*args, **kwargs)
|
||||||
|
except ConnectionError as e:
|
||||||
|
raise NetworkRetryableError(f"{api_name} API", f"Connection error: {e}")
|
||||||
|
except TimeoutError as e:
|
||||||
|
raise NetworkRetryableError(f"{api_name} API", f"Timeout error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
# Check if it's an HTTP error with status code
|
||||||
|
status_code = getattr(e, 'status_code', None) or getattr(e, 'response', {}).get('status_code')
|
||||||
|
response_text = getattr(e, 'text', None) or str(e)
|
||||||
|
|
||||||
|
# Retry on certain HTTP status codes
|
||||||
|
if status_code in [429, 502, 503, 504]: # Rate limit, bad gateway, service unavailable, gateway timeout
|
||||||
|
raise NetworkRetryableError(f"{api_name} API", f"HTTP {status_code}: {response_text}")
|
||||||
|
|
||||||
|
raise ExternalAPIError(api_name, operation, status_code, response_text)
|
||||||
|
|
||||||
|
|
||||||
|
def log_structured_error(error: NFOGuardException, context: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Log structured error information
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: NFOGuardException to log
|
||||||
|
context: Additional context for the error
|
||||||
|
"""
|
||||||
|
error_dict = error.to_dict()
|
||||||
|
if context:
|
||||||
|
error_dict['context'] = context
|
||||||
|
|
||||||
|
_log("ERROR", f"Structured error: {error.message}")
|
||||||
|
_log("DEBUG", f"Error details: {error_dict}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_error_response(error: NFOGuardException, include_details: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Create standardized error response for API endpoints
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: NFOGuardException to convert
|
||||||
|
include_details: Whether to include detailed error information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary suitable for JSON response
|
||||||
|
"""
|
||||||
|
response = {
|
||||||
|
"status": "error",
|
||||||
|
"error_type": error.__class__.__name__,
|
||||||
|
"message": error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_details and error.details:
|
||||||
|
response["details"] = error.details
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_config(config_dict: dict, required_keys: List[str]) -> None:
|
||||||
|
"""
|
||||||
|
Validate that required configuration keys are present and not empty
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dict: Configuration dictionary to validate
|
||||||
|
required_keys: List of required configuration keys
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If required configuration is missing or invalid
|
||||||
|
"""
|
||||||
|
from utils.exceptions import ConfigurationError
|
||||||
|
|
||||||
|
missing_keys = []
|
||||||
|
empty_keys = []
|
||||||
|
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in config_dict:
|
||||||
|
missing_keys.append(key)
|
||||||
|
elif not config_dict[key]:
|
||||||
|
empty_keys.append(key)
|
||||||
|
|
||||||
|
if missing_keys:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"missing_required_config",
|
||||||
|
f"Missing required configuration keys: {missing_keys}",
|
||||||
|
{"missing_keys": missing_keys}
|
||||||
|
)
|
||||||
|
|
||||||
|
if empty_keys:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"empty_required_config",
|
||||||
|
f"Required configuration keys are empty: {empty_keys}",
|
||||||
|
{"empty_keys": empty_keys}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorContext:
|
||||||
|
"""Context manager for adding context to errors"""
|
||||||
|
|
||||||
|
def __init__(self, context: str):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if exc_type and issubclass(exc_type, NFOGuardException):
|
||||||
|
exc_val.details = exc_val.details or {}
|
||||||
|
exc_val.details['error_context'] = self.context
|
||||||
|
return False # Don't suppress the exception
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
Custom exceptions for NFOGuard
|
||||||
|
Provides structured error handling and better error reporting
|
||||||
|
"""
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class NFOGuardException(Exception):
|
||||||
|
"""Base exception for all NFOGuard errors"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert exception to dictionary for structured logging"""
|
||||||
|
return {
|
||||||
|
"error_type": self.__class__.__name__,
|
||||||
|
"message": self.message,
|
||||||
|
"details": self.details
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPathNotFoundError(NFOGuardException):
|
||||||
|
"""Raised when media directory cannot be found"""
|
||||||
|
|
||||||
|
def __init__(self, media_type: str, title: str, imdb_id: Optional[str] = None, search_paths: Optional[list] = None):
|
||||||
|
details = {
|
||||||
|
"media_type": media_type,
|
||||||
|
"title": title,
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"search_paths": [str(p) for p in (search_paths or [])]
|
||||||
|
}
|
||||||
|
message = f"{media_type.title()} directory not found: {title}"
|
||||||
|
if imdb_id:
|
||||||
|
message += f" (IMDb: {imdb_id})"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class IMDbIDNotFoundError(NFOGuardException):
|
||||||
|
"""Raised when IMDb ID cannot be extracted from path or files"""
|
||||||
|
|
||||||
|
def __init__(self, path: str, media_type: str = "media"):
|
||||||
|
details = {
|
||||||
|
"path": path,
|
||||||
|
"media_type": media_type
|
||||||
|
}
|
||||||
|
message = f"No IMDb ID found for {media_type}: {path}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookProcessingError(NFOGuardException):
|
||||||
|
"""Raised when webhook processing fails"""
|
||||||
|
|
||||||
|
def __init__(self, webhook_type: str, reason: str, payload: Optional[Dict] = None):
|
||||||
|
details = {
|
||||||
|
"webhook_type": webhook_type,
|
||||||
|
"reason": reason,
|
||||||
|
"payload": payload
|
||||||
|
}
|
||||||
|
message = f"{webhook_type} webhook processing failed: {reason}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAPIError(NFOGuardException):
|
||||||
|
"""Raised when external API calls fail"""
|
||||||
|
|
||||||
|
def __init__(self, api_name: str, operation: str, status_code: Optional[int] = None, response: Optional[str] = None):
|
||||||
|
details = {
|
||||||
|
"api_name": api_name,
|
||||||
|
"operation": operation,
|
||||||
|
"status_code": status_code,
|
||||||
|
"response": response
|
||||||
|
}
|
||||||
|
message = f"{api_name} API error during {operation}"
|
||||||
|
if status_code:
|
||||||
|
message += f" (HTTP {status_code})"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(NFOGuardException):
|
||||||
|
"""Raised when database operations fail"""
|
||||||
|
|
||||||
|
def __init__(self, operation: str, table: Optional[str] = None, original_error: Optional[Exception] = None):
|
||||||
|
details = {
|
||||||
|
"operation": operation,
|
||||||
|
"table": table,
|
||||||
|
"original_error": str(original_error) if original_error else None
|
||||||
|
}
|
||||||
|
message = f"Database error during {operation}"
|
||||||
|
if table:
|
||||||
|
message += f" on table {table}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class NFOCreationError(NFOGuardException):
|
||||||
|
"""Raised when NFO file creation fails"""
|
||||||
|
|
||||||
|
def __init__(self, nfo_path: str, reason: str, media_type: str = "media"):
|
||||||
|
details = {
|
||||||
|
"nfo_path": nfo_path,
|
||||||
|
"reason": reason,
|
||||||
|
"media_type": media_type
|
||||||
|
}
|
||||||
|
message = f"Failed to create {media_type} NFO file: {reason}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(NFOGuardException):
|
||||||
|
"""Raised when configuration is invalid or missing"""
|
||||||
|
|
||||||
|
def __init__(self, setting: str, reason: str, current_value: Optional[Any] = None):
|
||||||
|
details = {
|
||||||
|
"setting": setting,
|
||||||
|
"reason": reason,
|
||||||
|
"current_value": current_value
|
||||||
|
}
|
||||||
|
message = f"Configuration error for {setting}: {reason}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class FileOperationError(NFOGuardException):
|
||||||
|
"""Raised when file operations fail"""
|
||||||
|
|
||||||
|
def __init__(self, operation: str, file_path: str, reason: str):
|
||||||
|
details = {
|
||||||
|
"operation": operation,
|
||||||
|
"file_path": file_path,
|
||||||
|
"reason": reason
|
||||||
|
}
|
||||||
|
message = f"File {operation} failed for {file_path}: {reason}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class DateProcessingError(NFOGuardException):
|
||||||
|
"""Raised when date processing or parsing fails"""
|
||||||
|
|
||||||
|
def __init__(self, date_value: str, operation: str, media_type: str = "media"):
|
||||||
|
details = {
|
||||||
|
"date_value": date_value,
|
||||||
|
"operation": operation,
|
||||||
|
"media_type": media_type
|
||||||
|
}
|
||||||
|
message = f"Date processing error during {operation} for {media_type}: {date_value}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
class RetryableError(NFOGuardException):
|
||||||
|
"""Base class for errors that can be retried"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None, retry_after: Optional[int] = None):
|
||||||
|
super().__init__(message, details)
|
||||||
|
self.retry_after = retry_after # Seconds to wait before retry
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkRetryableError(RetryableError):
|
||||||
|
"""Network errors that can be retried"""
|
||||||
|
|
||||||
|
def __init__(self, url: str, reason: str, retry_after: Optional[int] = 30):
|
||||||
|
details = {"url": url, "reason": reason}
|
||||||
|
message = f"Network error for {url}: {reason}"
|
||||||
|
super().__init__(message, details, retry_after)
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryFileError(RetryableError):
|
||||||
|
"""Temporary file system errors that can be retried"""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str, operation: str, reason: str, retry_after: Optional[int] = 5):
|
||||||
|
details = {"file_path": file_path, "operation": operation, "reason": reason}
|
||||||
|
message = f"Temporary file error during {operation} for {file_path}: {reason}"
|
||||||
|
super().__init__(message, details, retry_after)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
File utility functions for NFOGuard
|
||||||
|
Common file operations to eliminate code duplication
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict, Tuple, Union
|
||||||
|
|
||||||
|
from utils.logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
# Video file extensions used throughout the application
|
||||||
|
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.m4v', '.mov', '.ts'}
|
||||||
|
|
||||||
|
# Episode pattern for TV series files
|
||||||
|
EPISODE_PATTERN = re.compile(
|
||||||
|
r'.*[sS](\d{1,2})[eE](\d{1,3}).*|.*(\d{1,2})x(\d{1,3}).*'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_media_path_by_imdb_and_title(
|
||||||
|
title: str,
|
||||||
|
imdb_id: str,
|
||||||
|
search_paths: List[Path],
|
||||||
|
webhook_path: Optional[str] = None,
|
||||||
|
path_mapper = None
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Unified media path finder for both TV series and movies
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Media title to search for
|
||||||
|
imdb_id: IMDb ID to search for
|
||||||
|
search_paths: List of paths to search in (tv_paths or movie_paths)
|
||||||
|
webhook_path: Optional webhook path to try first
|
||||||
|
path_mapper: Optional path mapper for webhook path conversion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to media directory if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Try webhook path first if provided
|
||||||
|
if webhook_path and path_mapper:
|
||||||
|
try:
|
||||||
|
if hasattr(path_mapper, 'sonarr_path_to_container_path'):
|
||||||
|
container_path = path_mapper.sonarr_path_to_container_path(webhook_path)
|
||||||
|
elif hasattr(path_mapper, 'radarr_path_to_container_path'):
|
||||||
|
container_path = path_mapper.radarr_path_to_container_path(webhook_path)
|
||||||
|
else:
|
||||||
|
container_path = webhook_path
|
||||||
|
|
||||||
|
path_obj = Path(container_path)
|
||||||
|
if path_obj.exists():
|
||||||
|
return path_obj
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to process webhook path {webhook_path}: {e}")
|
||||||
|
|
||||||
|
# Search by IMDb ID or title in configured paths
|
||||||
|
for media_path in search_paths:
|
||||||
|
if not media_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Search by IMDb ID first (more reliable)
|
||||||
|
if imdb_id:
|
||||||
|
# Use proper glob pattern - escape brackets to match literal [imdb-ID]
|
||||||
|
pattern = str(media_path / f"*\\[imdb-{imdb_id}\\]*")
|
||||||
|
matches = glob.glob(pattern)
|
||||||
|
if matches:
|
||||||
|
return Path(matches[0])
|
||||||
|
|
||||||
|
# Search by title as fallback
|
||||||
|
if title:
|
||||||
|
title_clean = clean_title_for_search(title)
|
||||||
|
for item in media_path.iterdir():
|
||||||
|
if item.is_dir() and "[imdb-" in item.name.lower():
|
||||||
|
item_clean = clean_title_for_search(item.name)
|
||||||
|
if title_clean in item_clean:
|
||||||
|
return item
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title_for_search(title: str) -> str:
|
||||||
|
"""
|
||||||
|
Clean title for fuzzy matching
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Raw title string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cleaned title for comparison
|
||||||
|
"""
|
||||||
|
return title.lower().replace(" ", "").replace("-", "").replace(".", "")
|
||||||
|
|
||||||
|
|
||||||
|
def find_video_files(directory: Path, recursive: bool = True) -> List[Path]:
|
||||||
|
"""
|
||||||
|
Find all video files in a directory
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to search
|
||||||
|
recursive: Whether to search recursively
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of video file paths
|
||||||
|
"""
|
||||||
|
if not directory.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
video_files = []
|
||||||
|
|
||||||
|
if recursive:
|
||||||
|
for item in directory.rglob('*'):
|
||||||
|
if item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
video_files.append(item)
|
||||||
|
else:
|
||||||
|
for item in directory.iterdir():
|
||||||
|
if item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
video_files.append(item)
|
||||||
|
|
||||||
|
return video_files
|
||||||
|
|
||||||
|
|
||||||
|
def extract_episode_info(filename: str) -> Optional[Tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
Extract season and episode numbers from filename
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Video filename to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (season, episode) if found, None otherwise
|
||||||
|
"""
|
||||||
|
match = EPISODE_PATTERN.match(filename)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if match.group(1) and match.group(2): # SxxExx format
|
||||||
|
season = int(match.group(1))
|
||||||
|
episode = int(match.group(2))
|
||||||
|
elif match.group(3) and match.group(4): # NxNN format
|
||||||
|
season = int(match.group(3))
|
||||||
|
episode = int(match.group(4))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (season, episode)
|
||||||
|
|
||||||
|
|
||||||
|
def find_episodes_on_disk(series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||||
|
"""
|
||||||
|
Find all episodes on disk and return mapping of (season, episode) -> [video_files]
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_path: Path to series directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping (season, episode) tuples to lists of video files
|
||||||
|
"""
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
if not series_path.exists():
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
for video_file in find_video_files(series_path, recursive=True):
|
||||||
|
episode_info = extract_episode_info(video_file.name)
|
||||||
|
if episode_info:
|
||||||
|
season, episode = episode_info
|
||||||
|
key = (season, episode)
|
||||||
|
if key not in episodes:
|
||||||
|
episodes[key] = []
|
||||||
|
episodes[key].append(video_file)
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title_from_directory_name(directory_name: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract clean title from directory name, removing year and IMDb ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory_name: Directory name to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cleaned title or None if no title found
|
||||||
|
"""
|
||||||
|
name = directory_name
|
||||||
|
|
||||||
|
# Remove IMDb ID part: [imdb-ttXXXXXX] or [ttXXXXXX]
|
||||||
|
name = re.sub(r'\s*\[imdb-?tt\d+\]', '', name, flags=re.IGNORECASE)
|
||||||
|
name = re.sub(r'\s*\[tt\d+\]', '', name, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Remove year in parentheses: (YYYY)
|
||||||
|
name = re.sub(r'\s*\(\d{4}\)', '', name)
|
||||||
|
|
||||||
|
# Clean up extra spaces
|
||||||
|
name = ' '.join(name.split())
|
||||||
|
|
||||||
|
return name.strip() if name.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_imdb_id_from_path(path: Union[str, Path]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract IMDb ID from directory or file path
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to examine (string or Path object)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IMDb ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
path_str = str(path)
|
||||||
|
|
||||||
|
# Look for [imdb-ttXXXXXX] or [ttXXXXXX] patterns
|
||||||
|
patterns = [
|
||||||
|
r'\[imdb-(tt\d+)\]', # [imdb-tt1234567]
|
||||||
|
r'\[(tt\d+)\]', # [tt1234567]
|
||||||
|
r'imdb[_-]?(tt\d+)', # imdb_tt1234567 or imdb-tt1234567
|
||||||
|
r'(tt\d{7,})', # standalone tt1234567 (7+ digits)
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, path_str, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
imdb_id = match.group(1)
|
||||||
|
# Ensure it starts with 'tt'
|
||||||
|
if not imdb_id.startswith('tt'):
|
||||||
|
imdb_id = f'tt{imdb_id}'
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_video_file(file_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a file is a video file based on extension
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if it's a video file, False otherwise
|
||||||
|
"""
|
||||||
|
return file_path.suffix.lower() in VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def safe_directory_scan(directory: Path, pattern: str = "*") -> List[Path]:
|
||||||
|
"""
|
||||||
|
Safely scan directory with error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to scan
|
||||||
|
pattern: Glob pattern to match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching paths, empty list if scan fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not directory.exists():
|
||||||
|
return []
|
||||||
|
return list(directory.glob(pattern))
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
_log("WARNING", f"Failed to scan directory {directory}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_separators(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize path separators for cross-platform compatibility
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path string to normalize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized path string
|
||||||
|
"""
|
||||||
|
return str(Path(path).as_posix())
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Logging utilities for NFOGuard
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize logging and load environment files
|
||||||
|
_setup_file_logging()
|
||||||
|
_load_environment_files()
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
NFO parsing patterns and utilities for NFOGuard
|
||||||
|
Consolidates common NFO parsing logic and patterns
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.logging import _log
|
||||||
|
from utils.exceptions import NFOCreationError, FileOperationError
|
||||||
|
from utils.validation import validate_imdb_id, validate_date_string
|
||||||
|
|
||||||
|
|
||||||
|
# Common IMDb ID patterns used across the application
|
||||||
|
IMDB_PATTERNS = [
|
||||||
|
r'\[imdb-?(tt\d+)\]', # [imdb-tt1234567] or [imdb-1234567]
|
||||||
|
r'\[(tt\d+)\]', # [tt1234567]
|
||||||
|
r'\{imdb-?(tt\d+)\}', # {imdb-tt1234567} or {imdb-1234567}
|
||||||
|
r'\(imdb-?(tt\d+)\)', # (imdb-tt1234567) or (imdb-1234567)
|
||||||
|
r'[-_\s](tt\d+)$', # tt1234567 at end of string
|
||||||
|
r'imdb[_-]?(tt\d+)', # imdb_tt1234567 or imdb-tt1234567
|
||||||
|
]
|
||||||
|
|
||||||
|
# Episode filename patterns
|
||||||
|
EPISODE_PATTERNS = [
|
||||||
|
r'.*[sS](\d{1,2})[eE](\d{1,3}).*', # S01E01
|
||||||
|
r'.*(\d{1,2})x(\d{1,3}).*', # 1x01
|
||||||
|
r'.*[sS](\d{1,2})\.?[eE](\d{1,3}).*', # S01.E01
|
||||||
|
r'.*Season[_\s]?(\d{1,2})[_\s]?Episode[_\s]?(\d{1,3}).*', # Season 1 Episode 1
|
||||||
|
]
|
||||||
|
|
||||||
|
# Common NFO XML namespaces
|
||||||
|
NFO_NAMESPACES = {
|
||||||
|
'kodi': 'http://kodi.tv/moviedb',
|
||||||
|
'tmdb': 'https://www.themoviedb.org',
|
||||||
|
'imdb': 'https://www.imdb.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_imdb_id_from_text(text: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract IMDb ID from text using consolidated patterns
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to search for IMDb ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IMDb ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
for pattern in IMDB_PATTERNS:
|
||||||
|
match = re.search(pattern, text_lower)
|
||||||
|
if match:
|
||||||
|
imdb_id = match.group(1)
|
||||||
|
# Ensure it starts with 'tt'
|
||||||
|
if not imdb_id.startswith('tt'):
|
||||||
|
imdb_id = f'tt{imdb_id}'
|
||||||
|
|
||||||
|
if validate_imdb_id(imdb_id):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_episode_info_from_filename(filename: str) -> Optional[Dict[str, int]]:
|
||||||
|
"""
|
||||||
|
Extract season and episode information from filename
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Filename to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with 'season' and 'episode' keys if found, None otherwise
|
||||||
|
"""
|
||||||
|
for pattern in EPISODE_PATTERNS:
|
||||||
|
match = re.search(pattern, filename, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
season = int(match.group(1))
|
||||||
|
episode = int(match.group(2))
|
||||||
|
|
||||||
|
# Validate reasonable ranges
|
||||||
|
if 0 <= season <= 99 and 1 <= episode <= 999:
|
||||||
|
return {"season": season, "episode": episode}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nfo_with_tolerance(nfo_path: Path) -> Optional[ET.Element]:
|
||||||
|
"""
|
||||||
|
Parse NFO file with error tolerance
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path to NFO file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
XML root element if successful, None otherwise
|
||||||
|
"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try normal parsing first
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
return tree.getroot()
|
||||||
|
except ET.ParseError as e:
|
||||||
|
_log("WARNING", f"NFO parse error for {nfo_path}: {e}. Trying with tolerance...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try reading and cleaning the content
|
||||||
|
content = nfo_path.read_text(encoding='utf-8', errors='ignore')
|
||||||
|
|
||||||
|
# Basic cleanup for common issues
|
||||||
|
content = content.replace('&', '&') # Fix unescaped ampersands
|
||||||
|
content = re.sub(r'<(\w+)([^>]*?)(?<!/)>', r'<\1\2/>', content) # Fix unclosed tags
|
||||||
|
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
return root
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to parse NFO file {nfo_path} even with tolerance: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_nfo_element(root: ET.Element, xpath: str, namespaces: Optional[Dict] = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract text content from NFO element using XPath
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: XML root element
|
||||||
|
xpath: XPath expression
|
||||||
|
namespaces: Optional namespace dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Text content if found, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if namespaces:
|
||||||
|
elements = root.findall(xpath, namespaces)
|
||||||
|
else:
|
||||||
|
elements = root.findall(xpath)
|
||||||
|
|
||||||
|
if elements and elements[0].text:
|
||||||
|
return elements[0].text.strip()
|
||||||
|
except Exception as e:
|
||||||
|
_log("DEBUG", f"Failed to extract text from XPath {xpath}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_imdb_from_nfo_content(root: ET.Element) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract IMDb ID from NFO XML content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: XML root element
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IMDb ID if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Common XPath patterns for IMDb ID
|
||||||
|
imdb_xpaths = [
|
||||||
|
'.//imdb',
|
||||||
|
'.//imdbid',
|
||||||
|
'.//id[@type="imdb"]',
|
||||||
|
'.//uniqueid[@type="imdb"]',
|
||||||
|
'.//uniqueid[@default="true"]',
|
||||||
|
'.//id',
|
||||||
|
'.//uniqueid'
|
||||||
|
]
|
||||||
|
|
||||||
|
for xpath in imdb_xpaths:
|
||||||
|
imdb_text = extract_text_from_nfo_element(root, xpath)
|
||||||
|
if imdb_text:
|
||||||
|
imdb_id = extract_imdb_id_from_text(imdb_text)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Check in plot/overview text as fallback
|
||||||
|
plot_xpaths = ['.//plot', './/overview', './/summary']
|
||||||
|
for xpath in plot_xpaths:
|
||||||
|
plot_text = extract_text_from_nfo_element(root, xpath)
|
||||||
|
if plot_text:
|
||||||
|
imdb_id = extract_imdb_id_from_text(plot_text)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dates_from_nfo(root: ET.Element) -> Dict[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Extract various date fields from NFO content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: XML root element
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with date fields (premiered, aired, dateadded, etc.)
|
||||||
|
"""
|
||||||
|
date_fields = {
|
||||||
|
'premiered': ['.//premiered', './/releasedate', './/year'],
|
||||||
|
'aired': ['.//aired', './/firstaired'],
|
||||||
|
'dateadded': ['.//dateadded', './/added'],
|
||||||
|
'lastplayed': ['.//lastplayed'],
|
||||||
|
'filelastmodified': ['.//filelastmodified']
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for field_name, xpaths in date_fields.items():
|
||||||
|
for xpath in xpaths:
|
||||||
|
date_text = extract_text_from_nfo_element(root, xpath)
|
||||||
|
if date_text and validate_date_string(date_text):
|
||||||
|
result[field_name] = date_text
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
result[field_name] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_basic_nfo_structure(
|
||||||
|
media_type: str,
|
||||||
|
title: str,
|
||||||
|
imdb_id: Optional[str] = None,
|
||||||
|
dates: Optional[Dict[str, str]] = None,
|
||||||
|
additional_fields: Optional[Dict[str, str]] = None
|
||||||
|
) -> ET.Element:
|
||||||
|
"""
|
||||||
|
Create basic NFO XML structure
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_type: Type of media ('movie', 'tvshow', 'episode')
|
||||||
|
title: Media title
|
||||||
|
imdb_id: Optional IMDb ID
|
||||||
|
dates: Optional dictionary of date fields
|
||||||
|
additional_fields: Optional additional fields to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
XML root element
|
||||||
|
"""
|
||||||
|
root = ET.Element(media_type)
|
||||||
|
|
||||||
|
# Add title
|
||||||
|
title_elem = ET.SubElement(root, 'title')
|
||||||
|
title_elem.text = title
|
||||||
|
|
||||||
|
# Add IMDb ID if provided
|
||||||
|
if imdb_id and validate_imdb_id(imdb_id):
|
||||||
|
imdb_elem = ET.SubElement(root, 'imdb')
|
||||||
|
imdb_elem.text = imdb_id
|
||||||
|
|
||||||
|
# Also add as uniqueid
|
||||||
|
uniqueid_elem = ET.SubElement(root, 'uniqueid', type='imdb', default='true')
|
||||||
|
uniqueid_elem.text = imdb_id
|
||||||
|
|
||||||
|
# Add dates if provided
|
||||||
|
if dates:
|
||||||
|
for field_name, date_value in dates.items():
|
||||||
|
if date_value and validate_date_string(date_value):
|
||||||
|
date_elem = ET.SubElement(root, field_name)
|
||||||
|
date_elem.text = date_value
|
||||||
|
|
||||||
|
# Add additional fields
|
||||||
|
if additional_fields:
|
||||||
|
for field_name, field_value in additional_fields.items():
|
||||||
|
if field_value:
|
||||||
|
field_elem = ET.SubElement(root, field_name)
|
||||||
|
field_elem.text = str(field_value)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def write_nfo_file(
|
||||||
|
nfo_path: Path,
|
||||||
|
root: ET.Element,
|
||||||
|
lock_metadata: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Write NFO XML content to file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path where to write the NFO file
|
||||||
|
root: XML root element to write
|
||||||
|
lock_metadata: Whether to add file locking attributes
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NFOCreationError: If writing fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure parent directory exists
|
||||||
|
nfo_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Add file locking if requested
|
||||||
|
if lock_metadata:
|
||||||
|
root.set('nfoguard_managed', 'true')
|
||||||
|
root.set('last_updated', datetime.now().isoformat())
|
||||||
|
|
||||||
|
# Create tree and write
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
ET.indent(tree, space=" ", level=0) # Pretty formatting
|
||||||
|
|
||||||
|
# Write with proper XML declaration
|
||||||
|
with open(nfo_path, 'wb') as f:
|
||||||
|
tree.write(f, encoding='utf-8', xml_declaration=True)
|
||||||
|
|
||||||
|
_log("DEBUG", f"Successfully wrote NFO file: {nfo_path}")
|
||||||
|
|
||||||
|
except (OSError, ET.ParseError) as e:
|
||||||
|
raise NFOCreationError(
|
||||||
|
str(nfo_path),
|
||||||
|
f"Failed to write NFO file: {e}",
|
||||||
|
"unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_nfo_managed_by_nfoguard(nfo_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check if NFO file is managed by NFOGuard
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path to NFO file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if managed by NFOGuard, False otherwise
|
||||||
|
"""
|
||||||
|
root = parse_nfo_with_tolerance(nfo_path)
|
||||||
|
if root is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return root.get('nfoguard_managed') == 'true'
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title_from_directory_name(directory_name: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract clean title from directory name
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory_name: Directory name to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cleaned title or None if no title found
|
||||||
|
"""
|
||||||
|
name = directory_name
|
||||||
|
|
||||||
|
# Remove IMDb ID patterns
|
||||||
|
for pattern in IMDB_PATTERNS:
|
||||||
|
name = re.sub(pattern, '', name, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Remove year in parentheses: (YYYY)
|
||||||
|
name = re.sub(r'\s*\(\d{4}\)', '', name)
|
||||||
|
|
||||||
|
# Remove common release info patterns
|
||||||
|
release_patterns = [
|
||||||
|
r'\s*\[.*?\]', # [1080p], [BluRay], etc.
|
||||||
|
r'\s*\{.*?\}', # {edition info}
|
||||||
|
r'\s*\(.*?\)', # (additional info)
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in release_patterns:
|
||||||
|
name = re.sub(pattern, '', name, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Clean up extra spaces and special characters
|
||||||
|
name = re.sub(r'[._-]+', ' ', name) # Replace dots, underscores, dashes with spaces
|
||||||
|
name = ' '.join(name.split()) # Normalize whitespace
|
||||||
|
|
||||||
|
return name.strip() if name.strip() else None
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""
|
||||||
|
Validation utilities for NFOGuard
|
||||||
|
Provides runtime validation and type checking for critical paths
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union, Callable, TypeVar, Type
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.exceptions import ConfigurationError, NFOGuardException
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
def validate_imdb_id(imdb_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate IMDb ID format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_id: IMDb ID to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
"""
|
||||||
|
if not imdb_id or not isinstance(imdb_id, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Must start with 'tt' followed by 7+ digits
|
||||||
|
return bool(re.match(r'^tt\d{7,}$', imdb_id))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tmdb_id(tmdb_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate TMDB ID format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_id: TMDB ID to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
"""
|
||||||
|
if not tmdb_id or not isinstance(tmdb_id, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Can be numeric or have tmdb- prefix
|
||||||
|
if tmdb_id.startswith('tmdb-'):
|
||||||
|
return tmdb_id[5:].isdigit()
|
||||||
|
return tmdb_id.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_season_episode(season: int, episode: int) -> bool:
|
||||||
|
"""
|
||||||
|
Validate season and episode numbers
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Season number
|
||||||
|
episode: Episode number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
isinstance(season, int) and season >= 0 and
|
||||||
|
isinstance(episode, int) and episode >= 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_date_string(date_str: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate date string format (ISO format)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: Date string to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid ISO date, False otherwise
|
||||||
|
"""
|
||||||
|
if not date_str or not isinstance(date_str, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_path_exists(path: Union[str, Path]) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that a path exists
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if path exists, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Path(path).exists()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_video_file(file_path: Union[str, Path]) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that a file is a video file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid video file, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = Path(file_path)
|
||||||
|
video_extensions = {'.mkv', '.mp4', '.avi', '.m4v', '.mov', '.ts'}
|
||||||
|
return path.is_file() and path.suffix.lower() in video_extensions
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_webhook_payload(payload: Dict[str, Any], required_fields: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Validate webhook payload has required fields
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Webhook payload to validate
|
||||||
|
required_fields: List of required field names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of missing field names (empty if all present)
|
||||||
|
"""
|
||||||
|
missing_fields = []
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in payload or payload[field] is None:
|
||||||
|
missing_fields.append(field)
|
||||||
|
return missing_fields
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config_paths(paths: List[Union[str, Path]], path_type: str) -> None:
|
||||||
|
"""
|
||||||
|
Validate configuration paths exist and are directories
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: List of paths to validate
|
||||||
|
path_type: Type of paths for error messages (e.g., "TV", "Movie")
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If any paths are invalid
|
||||||
|
"""
|
||||||
|
invalid_paths = []
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
path_obj = Path(path)
|
||||||
|
if not path_obj.exists():
|
||||||
|
invalid_paths.append(f"{path} (does not exist)")
|
||||||
|
elif not path_obj.is_dir():
|
||||||
|
invalid_paths.append(f"{path} (not a directory)")
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
invalid_paths.append(f"{path} (invalid: {e})")
|
||||||
|
|
||||||
|
if invalid_paths:
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"{path_type.lower()}_paths",
|
||||||
|
f"Invalid {path_type} paths found",
|
||||||
|
{"invalid_paths": invalid_paths}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_type(value: Any, expected_type: Type[T], name: str) -> T:
|
||||||
|
"""
|
||||||
|
Require value to be of specific type
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to check
|
||||||
|
expected_type: Expected type
|
||||||
|
name: Name of the value for error messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The value if it matches the type
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If value is not of expected type
|
||||||
|
"""
|
||||||
|
if not isinstance(value, expected_type):
|
||||||
|
raise TypeError(
|
||||||
|
f"{name} must be {expected_type.__name__}, got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def require_non_empty(value: Optional[str], name: str) -> str:
|
||||||
|
"""
|
||||||
|
Require string value to be non-empty
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: String value to check
|
||||||
|
name: Name of the value for error messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The value if it's non-empty
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If value is None or empty
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"{name} cannot be None or empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_clean_imdb_id(imdb_id: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Validate and clean IMDb ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_id: IMDb ID to validate and clean
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cleaned IMDb ID or None if invalid
|
||||||
|
"""
|
||||||
|
if not imdb_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Clean the ID
|
||||||
|
cleaned = imdb_id.strip().lower()
|
||||||
|
|
||||||
|
# Remove common prefixes
|
||||||
|
if cleaned.startswith('imdb-'):
|
||||||
|
cleaned = cleaned[5:]
|
||||||
|
elif cleaned.startswith('imdb_'):
|
||||||
|
cleaned = cleaned[5:]
|
||||||
|
|
||||||
|
# Ensure it starts with 'tt'
|
||||||
|
if not cleaned.startswith('tt'):
|
||||||
|
cleaned = f'tt{cleaned}'
|
||||||
|
|
||||||
|
# Validate format
|
||||||
|
if validate_imdb_id(cleaned):
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_validator(validation_func: Callable[[Any], bool], error_message: str) -> Callable:
|
||||||
|
"""
|
||||||
|
Create a validator decorator
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validation_func: Function that returns True if value is valid
|
||||||
|
error_message: Error message to raise if validation fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorator function
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# Apply validation to first argument
|
||||||
|
if args and not validation_func(args[0]):
|
||||||
|
raise ValueError(error_message.format(args[0]))
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# Common validators
|
||||||
|
validate_imdb_required = create_validator(
|
||||||
|
lambda x: validate_imdb_id(x),
|
||||||
|
"Invalid IMDb ID format: {}"
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_path_required = create_validator(
|
||||||
|
lambda x: validate_path_exists(x),
|
||||||
|
"Path does not exist: {}"
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_date_required = create_validator(
|
||||||
|
lambda x: validate_date_string(x),
|
||||||
|
"Invalid date format: {}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(NFOGuardException):
|
||||||
|
"""Raised when validation fails"""
|
||||||
|
|
||||||
|
def __init__(self, field_name: str, value: Any, reason: str):
|
||||||
|
details = {
|
||||||
|
"field_name": field_name,
|
||||||
|
"value": str(value),
|
||||||
|
"reason": reason
|
||||||
|
}
|
||||||
|
message = f"Validation failed for {field_name}: {reason}"
|
||||||
|
super().__init__(message, details)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_episode_file_pattern(filename: str) -> Optional[Dict[str, int]]:
|
||||||
|
"""
|
||||||
|
Validate and extract episode information from filename
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Filename to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with season and episode if valid, None otherwise
|
||||||
|
"""
|
||||||
|
# Episode patterns
|
||||||
|
patterns = [
|
||||||
|
r'[sS](\d{1,2})[eE](\d{1,3})', # S01E01
|
||||||
|
r'(\d{1,2})x(\d{1,3})', # 1x01
|
||||||
|
r'[sS](\d{1,2})\.?[eE](\d{1,3})', # S01.E01
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, filename)
|
||||||
|
if match:
|
||||||
|
season = int(match.group(1))
|
||||||
|
episode = int(match.group(2))
|
||||||
|
if validate_season_episode(season, episode):
|
||||||
|
return {"season": season, "episode": episode}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize filename by removing invalid characters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Filename to sanitize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized filename
|
||||||
|
"""
|
||||||
|
# Remove invalid characters for most filesystems
|
||||||
|
invalid_chars = r'<>:"/\|?*'
|
||||||
|
for char in invalid_chars:
|
||||||
|
filename = filename.replace(char, '_')
|
||||||
|
|
||||||
|
# Remove leading/trailing dots and spaces
|
||||||
|
filename = filename.strip('. ')
|
||||||
|
|
||||||
|
# Ensure it's not empty
|
||||||
|
if not filename:
|
||||||
|
filename = 'unnamed'
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url_format(url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate URL format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid URL format, False otherwise
|
||||||
|
"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Basic URL validation
|
||||||
|
url_pattern = re.compile(
|
||||||
|
r'^https?://' # http:// or https://
|
||||||
|
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
|
||||||
|
r'localhost|' # localhost...
|
||||||
|
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||||
|
r'(?::\d+)?' # optional port
|
||||||
|
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||||
|
|
||||||
|
return bool(url_pattern.match(url))
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Webhook Batching System for NFOGuard
|
||||||
|
Handles batching and processing of webhook events to avoid processing storms
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Set
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from config.settings import config
|
||||||
|
from utils.logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookBatcher:
|
||||||
|
"""Batches webhook events to avoid processing storms"""
|
||||||
|
|
||||||
|
def __init__(self, nfo_manager=None):
|
||||||
|
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)
|
||||||
|
# Will be set by the application when processors are available
|
||||||
|
self.tv_processor = None
|
||||||
|
self.movie_processor = None
|
||||||
|
# NFO manager for comprehensive IMDb detection
|
||||||
|
self.nfo_manager = nfo_manager
|
||||||
|
|
||||||
|
def set_processors(self, tv_processor, movie_processor):
|
||||||
|
"""Set the processor instances"""
|
||||||
|
self.tv_processor = tv_processor
|
||||||
|
self.movie_processor = movie_processor
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Use comprehensive IMDb detection (directory, filenames, NFO files)
|
||||||
|
if self.nfo_manager:
|
||||||
|
detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj)
|
||||||
|
imdb_match = False
|
||||||
|
if detected_imdb:
|
||||||
|
# Compare with and without 'tt' prefix for flexibility
|
||||||
|
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
|
||||||
|
imdb_match = True
|
||||||
|
|
||||||
|
if not imdb_match:
|
||||||
|
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}")
|
||||||
|
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
|
||||||
|
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
|
||||||
|
return
|
||||||
|
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
|
||||||
|
else:
|
||||||
|
# Fallback to simple string search if nfo_manager not available
|
||||||
|
if expected_imdb not in path_str.lower():
|
||||||
|
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)")
|
||||||
|
_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 (fallback mode)")
|
||||||
|
|
||||||
|
# CRITICAL: Validate that the path contains the expected IMDb ID for TV shows
|
||||||
|
if media_type == 'tv':
|
||||||
|
expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key
|
||||||
|
|
||||||
|
# Use comprehensive IMDb detection (directory, filenames, tvshow.nfo files)
|
||||||
|
if self.nfo_manager:
|
||||||
|
detected_imdb = self.nfo_manager.find_series_imdb_id(path_obj)
|
||||||
|
imdb_match = False
|
||||||
|
if detected_imdb:
|
||||||
|
# Compare with and without 'tt' prefix for flexibility
|
||||||
|
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
|
||||||
|
imdb_match = True
|
||||||
|
|
||||||
|
if not imdb_match:
|
||||||
|
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in TV path {path_str}")
|
||||||
|
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
|
||||||
|
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
|
||||||
|
return
|
||||||
|
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
|
||||||
|
else:
|
||||||
|
# Fallback to simple string search if nfo_manager not available
|
||||||
|
if expected_imdb not in path_str.lower():
|
||||||
|
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in TV path {path_str} (fallback mode)")
|
||||||
|
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
|
||||||
|
return
|
||||||
|
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
|
||||||
|
|
||||||
|
if not self.tv_processor:
|
||||||
|
_log("ERROR", "TV processor not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
self.tv_processor.process_webhook_episodes(path_obj, episodes_data)
|
||||||
|
else:
|
||||||
|
_log("INFO", f"Using series processing mode (fallback or configured)")
|
||||||
|
self.tv_processor.process_series(path_obj)
|
||||||
|
|
||||||
|
elif media_type == 'movie':
|
||||||
|
if not self.movie_processor:
|
||||||
|
_log("ERROR", "Movie processor not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Shutdown the webhook batcher gracefully"""
|
||||||
|
_log("INFO", "Shutting down webhook batcher...")
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
# Cancel all pending timers
|
||||||
|
for timer in self.timers.values():
|
||||||
|
try:
|
||||||
|
timer.cancel()
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Error canceling timer: {e}")
|
||||||
|
|
||||||
|
self.timers.clear()
|
||||||
|
|
||||||
|
# Log any remaining items
|
||||||
|
if self.pending:
|
||||||
|
_log("WARNING", f"Shutting down with {len(self.pending)} pending items")
|
||||||
|
if self.processing:
|
||||||
|
_log("INFO", f"Waiting for {len(self.processing)} items to finish processing...")
|
||||||
|
|
||||||
|
# Shutdown the thread pool executor
|
||||||
|
try:
|
||||||
|
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
|
||||||
|
_log("INFO", "Thread pool executor shut down successfully")
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Error shutting down thread pool: {e}")
|
||||||
|
|
||||||
|
_log("INFO", "Webhook batcher shutdown complete")
|
||||||
Reference in New Issue
Block a user