commit 7ad0dd8ca260549ea822e7e2bf3317d22f6f0535 Author: SBCrumb Date: Tue Sep 23 16:12:58 2025 -0400 feat: NFOGuard v1.7.2 - Complete media management system Initial clean repository with full NFOGuard codebase: - Docker-based media file processing - Webhook integration for Radarr/Sonarr - NFO file management and organization - Emby/Jellyfin plugin integration - TMDB API integration for metadata - Smart episode and movie renaming - Comprehensive dual-repository development workflow Author: SBCrumb diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..78ce602 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# NFOGuard Environment Variables + +# User and Group IDs +PUID=1000 +PGID=1000 + +# Timezone +TZ=UTC + +# Media path (for read-only access to scan NFO files) +MEDIA_PATH=/path/to/your/media + +# Database settings (if using PostgreSQL) +# DB_USER=nfoguard +# DB_PASSWORD=your_secure_password + +# Emby Plugin Deployment - Bind Mount Method (Recommended) +# Set this to the path where Emby plugins are installed on your host +# Common paths: +# - /var/lib/emby/plugins (native Emby install) +# - /path/to/emby/config/plugins (Docker Emby) +# - /config/plugins (linuxserver.io Emby) +EMBY_PLUGINS_PATH=/path/to/emby/plugins + +# NFOGuard specific settings +DEBUG=false +PATH_DEBUG=false +SUPPRESS_TVDB_WARNINGS=true \ No newline at end of file diff --git a/.env.secrets.template b/.env.secrets.template new file mode 100644 index 0000000..3efc95a --- /dev/null +++ b/.env.secrets.template @@ -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 \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..1a449f0 --- /dev/null +++ b/.env.template @@ -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 \ No newline at end of file diff --git a/.gitea/workflows/ci-dev.yml b/.gitea/workflows/ci-dev.yml new file mode 100644 index 0000000..8effa89 --- /dev/null +++ b/.gitea/workflows/ci-dev.yml @@ -0,0 +1,146 @@ +name: Local Docker Build (Dev) + +on: + push: + branches: [ dev ] + pull_request: + branches: [ dev ] + +jobs: + build-dev: + runs-on: host + + steps: + - name: Checkout code (DEV) + run: | + echo "Current workspace: $(pwd)" + + # Clean workspace and temp directory first + rm -rf * .git* 2>/dev/null || true + rm -rf /tmp/repo 2>/dev/null || true + + # Clone the repository since Gitea runner doesn't auto-checkout + echo "Cloning repository..." + git clone --branch dev http://${{ secrets.username }}:${{ secrets.token }}@192.168.253.221:3000/jskala/NFOguard.git /tmp/repo + cp -r /tmp/repo/* . + cp -r /tmp/repo/.* . 2>/dev/null || true + rm -rf /tmp/repo + + echo "Repository cloned successfully" + ls -la + echo "Checking Dockerfile:" + head -30 Dockerfile + + - name: Check Docker availability (DEV) + run: | + echo "Checking Docker installation..." + which docker || (echo "Docker not found in PATH" && exit 1) + docker --version || (echo "Docker not available" && exit 1) + docker info || (echo "Docker daemon not running" && exit 1) + + - name: Configure Docker for local registry (DEV) + run: | + echo "Configuring Docker client for insecure local registry..." + LOCAL_REGISTRY="192.168.253.221:3000" + + export DOCKER_CONFIG=/tmp/docker-config-dev + mkdir -p $DOCKER_CONFIG + + # Create Docker client config for insecure registry + cat > $DOCKER_CONFIG/config.json << EOF + { + "auths": {}, + "HttpHeaders": { + "User-Agent": "Docker-Client/20.10.0 (linux)" + }, + "credsStore": "", + "credHelpers": {}, + "experimental": "disabled" + } + EOF + + echo "Docker client config created for DEV" + echo "Testing registry connectivity..." + curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Registry accessible via HTTP" || echo "❌ Registry not accessible" + + - name: Build Docker image with caching (DEV) + run: | + echo "Building DEV Docker image with layer caching..." + LOCAL_REGISTRY="192.168.253.221:3000" + CACHE_IMAGE="$LOCAL_REGISTRY/jskala/nfoguard:dev-buildcache" + + # Clear any existing Docker configs to prevent permission issues + unset DOCKER_CONFIG + export HOME=/tmp/docker-home-dev + mkdir -p $HOME + + # Enable Docker BuildKit for better caching + export DOCKER_BUILDKIT=1 + + # Check if DEV cache image exists and pull it + echo "Checking for existing DEV cache image..." + CACHE_ARGS="" + if curl -s "http://$LOCAL_REGISTRY/v2/jskala/nfoguard/manifests/dev-buildcache" > /dev/null 2>&1; then + echo "✅ DEV cache manifest found, pulling image..." + if docker pull "$CACHE_IMAGE" 2>/dev/null; then + echo "✅ DEV cache image pulled successfully" + CACHE_ARGS="--cache-from=$CACHE_IMAGE" + else + echo "⚠️ DEV cache manifest exists but pull failed" + fi + else + echo "ℹ️ No DEV cache image found (first build or cache expired)" + fi + + # Build DEV image with cache (if available) + echo "Building DEV image with layer caching..." + docker build \ + $CACHE_ARGS \ + --build-arg GIT_BRANCH=dev \ + --build-arg BUILD_SOURCE=gitea \ + --tag nfoguard:dev \ + --tag nfoguard:dev-${{ github.sha }} \ + --tag "$CACHE_IMAGE" \ + . + + echo "DEV Docker image built and tagged successfully" + + - name: Login and Push to Gitea registry (DEV tags) + continue-on-error: true + run: | + echo "Using LOCAL IP ONLY for DEV push!" + export DOCKER_CONFIG=/tmp/docker-config-dev + + LOCAL_REGISTRY="192.168.253.221:3000" + echo "Registry: $LOCAL_REGISTRY (DEV TAGS)" + + # Try login to local registry + if echo "${{ secrets.token }}" | docker --config $DOCKER_CONFIG login "$LOCAL_REGISTRY" -u "${{ secrets.username }}" --password-stdin 2>/dev/null; then + echo "✅ DEV Login succeeded" + + # Tag for local registry + docker tag nfoguard:dev "$LOCAL_REGISTRY/jskala/nfoguard:dev" + docker tag nfoguard:dev "$LOCAL_REGISTRY/jskala/nfoguard:dev-${{ github.sha }}" + + # Push DEV images + echo "=== Pushing DEV cache image ===" + timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:dev-buildcache" && echo "✅ DEV cache pushed" || echo "⚠️ DEV cache push failed" + + echo "=== Pushing DEV latest tag ===" + if timeout 120 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:dev"; then + echo "✅ DEV tag pushed successfully" + + if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:dev-${{ github.sha }}"; then + echo "✅ DEV SHA tag pushed successfully" + fi + fi + else + echo "❌ DEV local registry login failed - continuing to Docker Hub" + fi + + echo "" + echo "🎉 DEV build completed successfully!" + echo "" + echo "📦 Available DEV images:" + echo " Local only: nfoguard:dev" + echo " Local SHA: nfoguard:dev-${{ github.sha }}" \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e8094c8 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,207 @@ +name: Local Docker Build (Main) + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: host + + steps: + - name: Checkout code + run: | + echo "Current workspace: $(pwd)" + + # Clean workspace and temp directory first + rm -rf * .git* 2>/dev/null || true + rm -rf /tmp/repo 2>/dev/null || true + + # Clone the repository since Gitea runner doesn't auto-checkout + echo "Cloning repository..." + git clone --branch main http://${{ secrets.username }}:${{ secrets.token }}@192.168.253.221:3000/jskala/NFOguard.git /tmp/repo + cp -r /tmp/repo/* . + cp -r /tmp/repo/.* . 2>/dev/null || true + rm -rf /tmp/repo + + echo "Repository cloned successfully" + ls -la + echo "Checking Dockerfile:" + head -30 Dockerfile + + - name: Check Docker availability + run: | + echo "Checking Docker installation..." + which docker || (echo "Docker not found in PATH" && exit 1) + docker --version || (echo "Docker not available" && exit 1) + docker info || (echo "Docker daemon not running" && exit 1) + + - name: Configure Docker for local registry + run: | + echo "Configuring Docker client for insecure local registry..." + LOCAL_REGISTRY="192.168.253.221:3000" + + # We can't use sudo in the container, so we'll configure the client differently + export DOCKER_CONFIG=/tmp/docker-config + mkdir -p $DOCKER_CONFIG + + # Create Docker client config for insecure registry + cat > $DOCKER_CONFIG/config.json << EOF + { + "auths": {}, + "HttpHeaders": { + "User-Agent": "Docker-Client/20.10.0 (linux)" + }, + "credsStore": "", + "credHelpers": {}, + "experimental": "disabled" + } + EOF + + echo "Docker client config created" + echo "Note: Since we can't modify daemon config in container, we'll use --insecure-registry flag" + + echo "Testing registry connectivity..." + curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Registry accessible via HTTP" || echo "❌ Registry not accessible" + + - name: Build Docker image with caching + run: | + echo "Building Docker image with layer caching..." + LOCAL_REGISTRY="192.168.253.221:3000" + CACHE_IMAGE="$LOCAL_REGISTRY/jskala/nfoguard:buildcache" + + # Clear any existing Docker configs to prevent permission issues + unset DOCKER_CONFIG + export HOME=/tmp/docker-home + mkdir -p $HOME + + # Enable Docker BuildKit for better caching + export DOCKER_BUILDKIT=1 + + # Check if cache image exists and pull it + echo "Checking for existing cache image..." + CACHE_ARGS="" + if curl -s "http://$LOCAL_REGISTRY/v2/jskala/nfoguard/manifests/buildcache" > /dev/null 2>&1; then + echo "✅ Cache manifest found, pulling image..." + if docker pull "$CACHE_IMAGE" 2>/dev/null; then + echo "✅ Cache image pulled successfully" + CACHE_ARGS="--cache-from=$CACHE_IMAGE" + else + echo "⚠️ Cache manifest exists but pull failed" + fi + else + echo "ℹ️ No cache image found (first build or cache expired)" + fi + + # Build with cache (if available) + echo "Building with layer caching..." + docker build \ + $CACHE_ARGS \ + --build-arg GIT_BRANCH=main \ + --build-arg BUILD_SOURCE=gitea \ + --tag nfoguard:latest \ + --tag nfoguard:${{ github.sha }} \ + --tag "$CACHE_IMAGE" \ + . + + echo "Docker image built and tagged successfully" + + # Show layer info for debugging (with error handling) + echo "=== Image layer history ===" + if docker history nfoguard:latest --format "table {{.CreatedBy}}\t{{.Size}}" 2>/dev/null; then + echo "✅ Image history displayed successfully" + else + echo "⚠️ Could not display image history (permissions issue, but build succeeded)" + fi + + - name: Login and Push to Gitea registry (local IP only) + continue-on-error: true # Don't fail the build if local registry fails + run: | + echo "Using LOCAL IP ONLY - no Cloudflare tunnel!" + export DOCKER_CONFIG=/tmp/docker-config + + # Force local IP - never use domain + LOCAL_REGISTRY="192.168.253.221:3000" + echo "Registry: $LOCAL_REGISTRY (LOCAL IP ONLY)" + + # Check image size + IMAGE_SIZE=$(docker images nfoguard:latest --format "table {{.Size}}" | tail -n1) + echo "Image size: $IMAGE_SIZE" + + echo "Testing local registry connectivity..." + curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Local registry accessible via HTTP" || echo "❌ Local registry not accessible" + + # Try multiple approaches to handle the HTTP issue + echo "Attempting login to LOCAL registry..." + + # Approach 1: Try with explicit HTTP in auth + if echo "${{ secrets.token }}" | docker --config $DOCKER_CONFIG login "$LOCAL_REGISTRY" -u "${{ secrets.username }}" --password-stdin 2>/dev/null; then + echo "✅ Login succeeded with direct method" + + # Tag for local registry (cache image already tagged in build step) + docker tag nfoguard:latest "$LOCAL_REGISTRY/jskala/nfoguard:latest" + docker tag nfoguard:latest "$LOCAL_REGISTRY/jskala/nfoguard:${{ github.sha }}" + + echo "Pushing to LOCAL registry (gigabit speed!)..." + + # Push with reasonable timeout for local network + echo "=== Pushing cache image (for faster future builds) ===" + timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:buildcache" && echo "✅ Cache image pushed" || echo "⚠️ Cache push failed" + + echo "=== Pushing latest tag to LOCAL IP ===" + if timeout 120 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:latest"; then + echo "✅ Latest tag pushed successfully to LOCAL registry" + + echo "=== Pushing SHA tag to LOCAL IP ===" + if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:${{ github.sha }}"; then + echo "✅ SHA tag pushed successfully to LOCAL registry" + else + echo "⚠️ SHA tag push failed but latest succeeded" + fi + + else + echo "❌ Push to LOCAL registry failed" + echo "This is likely because Docker daemon needs insecure-registry configuration" + fi + + else + echo "❌ Local registry login failed" + echo "This is expected - Docker daemon needs insecure-registry configuration" + echo "" + echo "🔧 To fix, run on the host machine:" + echo "sudo mkdir -p /etc/docker" + echo 'echo '"'"'{"insecure-registries":["'$LOCAL_REGISTRY'"]}'"'"' | sudo tee /etc/docker/daemon.json' + echo "sudo systemctl restart docker" + echo "" + echo "⏭️ Continuing to Docker Hub push..." + fi + + echo "🎉 Local registry step completed!" + + echo "" + echo "🎉 Local build completed successfully!" + echo "" + echo "📦 Available images:" + echo " Local Docker: nfoguard:latest" + echo " Local SHA: nfoguard:${{ github.sha }}" + echo " Local Gitea: 192.168.253.221:3000/jskala/nfoguard:latest" + + deploy: + needs: build + runs-on: host + if: github.ref == 'refs/heads/main' + + steps: + - name: Deploy application + run: | + echo "Deploying application..." + # Add your deployment commands here + # Examples: + # docker-compose pull + # docker-compose up -d + # or + # docker stop nfoguard || true + # docker run -d --name nfoguard -p 8080:8080 nfoguard:latest + echo "Deployment completed" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bffa156 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/changelog-config.json b/.github/changelog-config.json new file mode 100644 index 0000000..9611b8c --- /dev/null +++ b/.github/changelog-config.json @@ -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" + } +} \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..ab36350 --- /dev/null +++ b/.github/release-drafter.yml @@ -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` + diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..26a1ba0 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -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 }} \ No newline at end of file diff --git a/.github/workflows/ghcr-dev.yml b/.github/workflows/ghcr-dev.yml new file mode 100644 index 0000000..885eb6e --- /dev/null +++ b/.github/workflows/ghcr-dev.yml @@ -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 diff --git a/.github/workflows/ghcr-main.yml b/.github/workflows/ghcr-main.yml new file mode 100644 index 0000000..dc1e2e0 --- /dev/null +++ b/.github/workflows/ghcr-main.yml @@ -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 diff --git a/.github/workflows/notify-issues.yml b/.github/workflows/notify-issues.yml new file mode 100644 index 0000000..a762f25 --- /dev/null +++ b/.github/workflows/notify-issues.yml @@ -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!" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..d51a8e4 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f81875a --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.local/.env.example b/.local/.env.example new file mode 100644 index 0000000..78ce602 --- /dev/null +++ b/.local/.env.example @@ -0,0 +1,28 @@ +# NFOGuard Environment Variables + +# User and Group IDs +PUID=1000 +PGID=1000 + +# Timezone +TZ=UTC + +# Media path (for read-only access to scan NFO files) +MEDIA_PATH=/path/to/your/media + +# Database settings (if using PostgreSQL) +# DB_USER=nfoguard +# DB_PASSWORD=your_secure_password + +# Emby Plugin Deployment - Bind Mount Method (Recommended) +# Set this to the path where Emby plugins are installed on your host +# Common paths: +# - /var/lib/emby/plugins (native Emby install) +# - /path/to/emby/config/plugins (Docker Emby) +# - /config/plugins (linuxserver.io Emby) +EMBY_PLUGINS_PATH=/path/to/emby/plugins + +# NFOGuard specific settings +DEBUG=false +PATH_DEBUG=false +SUPPRESS_TVDB_WARNINGS=true \ No newline at end of file diff --git a/.local/.env.template b/.local/.env.template new file mode 100644 index 0000000..4ee8755 --- /dev/null +++ b/.local/.env.template @@ -0,0 +1,141 @@ +# NFOGuard Environment Configuration Template +# Copy this to .env and customize for your setup + +# =========================================== +# MEDIA PATHS (REQUIRED) +# =========================================== +# Paths where your movies and TV shows are stored inside the container +# Adjust these to match YOUR directory structure +TV_PATHS=/media/TV/tv,/media/TV/tv6 +MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 + +# =========================================== +# DATABASE CONFIGURATION +# =========================================== +# NFOGuard SQLite database location +DB_PATH=/app/data/media_dates.db + +# =========================================== +# RADARR DATABASE CONNECTION (REQUIRED) +# =========================================== +# Connection to Radarr's PostgreSQL database +# NOTE: Database password should be set in .env.secrets file +RADARR_DB_HOST=radarr-postgres +RADARR_DB_PORT=5432 +RADARR_DB_NAME=radarr +RADARR_DB_USER=radarr + +# =========================================== +# RADARR API (OPTIONAL - for fallback) +# =========================================== +# Only needed if you want API fallback (not recommended for performance) +# NOTE: API key should be set in .env.secrets file +RADARR_URL=http://radarr:7878 + +# =========================================== +# SONARR API (REQUIRED for Enhanced TV NFOs - v0.6.0+) +# =========================================== +# Required for TV processing and enhanced NFO generation with metadata +# NOTE: API key should be set in .env.secrets file +SONARR_URL=http://sonarr:8989 + +# =========================================== +# EXTERNAL APIs FOR DIGITAL RELEASE DATES +# =========================================== +# NOTE: All API keys should be set in .env.secrets file +# See .env.secrets.template for required keys: +# - TMDB_API_KEY (recommended for digital release dates) +# - OMDB_API_KEY (optional for DVD/digital release dates) +# - JELLYSEERR_API_KEY (optional for additional digital release data) + +# Jellyseerr URL (optional) +JELLYSEERR_URL=http://jellyseerr:5055 + +# =========================================== +# NFO MANAGEMENT +# =========================================== +# Whether to create/update .nfo files +MANAGE_NFO=true + +# Whether to fix file modification times to match import dates +FIX_DIR_MTIMES=true + +# Whether to add tags to prevent metadata drift +LOCK_METADATA=true + +# Brand name to add to NFO files +MANAGER_BRAND=NFOGuard + +# =========================================== +# PROCESSING SETTINGS +# =========================================== +# Delay before processing batched webhook events (seconds) +BATCH_DELAY=5.0 + +# Maximum concurrent series processing +MAX_CONCURRENT_SERIES=3 + +# Movie date strategy: import_then_digital or digital_then_import +MOVIE_PRIORITY=import_then_digital + +# Smart fallback: prefer release dates over file dates for manual imports (true/false) +PREFER_RELEASE_DATES_OVER_FILE_DATES=true + +# Allow file dates as absolute last resort (true/false) +# If false, movies with no valid import AND no release dates will be skipped entirely +ALLOW_FILE_DATE_FALLBACK=false + +# Fallback priority order when no valid Radarr import exists (comma-separated) +# Options: digital, physical, theatrical +# digital = VOD/streaming release, physical = DVD/Blu-ray, theatrical = cinema release +RELEASE_DATE_PRIORITY=digital,physical,theatrical + +# Smart date validation: automatically prefer theatrical over unreasonably late digital/physical dates +# Example: "The Craft (1996)" theatrical vs "The Craft (2022)" physical → chooses 1996 theatrical +ENABLE_SMART_DATE_VALIDATION=true + +# Maximum reasonable gap between theatrical and digital/physical release (in years) +# If digital/physical is more than this many years after theatrical, prefer theatrical instead +MAX_RELEASE_DATE_GAP_YEARS=10 + +# TMDB country for regional release date preferences +TMDB_COUNTRY=US + +# TMDB release type priority (comma-separated numbers) +# 1=Premiere, 2=Limited Theatrical, 3=Theatrical, 4=Digital, 5=Physical, 6=TV Premiere +# Default: 4,5,3,2,6,1 (Digital → Physical → Theatrical → Limited → TV → Premiere) +TMDB_TYPE_PRIORITY=4,5,3,2,6,1 + +# When to query APIs: always, if_missing, never +MOVIE_POLL_MODE=always + +# Update mode: backfill_only or overwrite +MOVIE_DATE_UPDATE_MODE=backfill_only + +# =========================================== +# TV SERIES PROCESSING +# =========================================== +# Season directory naming format (supports {season} placeholder) +# Examples: "Season {season:02d}" -> "Season 01", "S{season:02d}" -> "S01" +TV_SEASON_DIR_FORMAT=Season {season:02d} + +# Season directory detection pattern (lowercase, what to look for at start of dir name) +# Examples: "season " -> matches "Season 01", "s" -> matches "S01", "season" -> matches "Season01" +TV_SEASON_DIR_PATTERN=season + +# TV webhook processing mode (v0.6.0+) +# targeted = Only process episodes mentioned in webhook (efficient, recommended) +# series = Process entire series directory (comprehensive, current default) +TV_WEBHOOK_PROCESSING_MODE=targeted + +# =========================================== +# LOGGING +# =========================================== +# Enable debug logging +DEBUG=false + +# =========================================== +# SERVER +# =========================================== +# Port to run the webhook server on +PORT=8080 \ No newline at end of file diff --git a/.local/SETUP.md b/.local/SETUP.md new file mode 100644 index 0000000..0798ec5 --- /dev/null +++ b/.local/SETUP.md @@ -0,0 +1,153 @@ +# NFOGuard Setup Guide + +## 🔐 Secure Configuration Setup + +NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting: + +- **`.env`** - Main configuration (safe to share for debugging) +- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git) + +### Step 1: Copy Configuration Templates + +```bash +# Copy main configuration +cp .env.template .env + +# Copy secrets configuration +cp .env.secrets.template .env.secrets +``` + +### Step 2: Configure Main Settings + +Edit your `.env` file with your specific paths and preferences: + +```bash +# Media paths (adjust to your directory structure) +TV_PATHS=/media/TV/tv,/media/TV/tv6 +MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 + +# Database connection details +RADARR_DB_HOST=radarr-postgres +RADARR_DB_PORT=5432 +RADARR_DB_NAME=radarr +RADARR_DB_USER=radarr + +# Processing preferences +PREFER_RELEASE_DATES_OVER_FILE_DATES=true +ALLOW_FILE_DATE_FALLBACK=false +RELEASE_DATE_PRIORITY=digital,physical,theatrical + +# TV webhook processing mode (v0.6.0+) +TV_WEBHOOK_PROCESSING_MODE=targeted +``` + +### Step 3: Configure Secrets + +Edit your `.env.secrets` file with your actual API keys and passwords: + +```bash +# Database password +RADARR_DB_PASSWORD=your_actual_radarr_password + +# TMDB API key (required for release date detection) +TMDB_API_KEY=your_actual_tmdb_api_key + +# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation) +SONARR_API_KEY=your_actual_sonarr_api_key + +# Optional API keys +RADARR_API_KEY=your_radarr_api_key +OMDB_API_KEY=your_omdb_api_key +``` + +### Step 4: Verify Configuration + +Test your setup: + +```bash +# Test database connections +curl -X POST "http://localhost:8080/test/bulk-update" + +# Test movie scanning +curl -X POST "http://localhost:8080/test/movie-scan" + +# Check system health +curl "http://localhost:8080/health" +``` + +## 🔒 Security Features + +### API Key Masking +All API keys and passwords are automatically masked in logs: +``` +[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked*** +[2025-09-09T12:34:56] INFO: Database connection password=***masked*** +``` + +### Sensitive Data Separation +- **Main `.env`**: Paths, preferences, URLs (safe to share) +- **`.env.secrets`**: API keys, passwords (never commit to version control) +- **Automatic loading**: Both files loaded automatically at startup + +### Git Protection +The `.gitignore` file prevents accidental commits: +``` +.env +.env.secrets +.env.local +``` + +## 🛠 Troubleshooting + +### "Environment files not loaded" Warning +Install python-dotenv: +```bash +pip install python-dotenv==1.0.0 +# or +docker-compose build # rebuilds with updated requirements.txt +``` + +### Sharing Configuration for Help +You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared. + +### Migration from Old Setup +If you have an existing `.env` with API keys: +1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets` +2. Remove sensitive data from `.env` +3. Restart NFOGuard + +## 🎯 Docker Compose Example + +```yaml +version: '3.8' +services: + nfoguard: + image: sbcrumb/nfoguard:latest + container_name: nfoguard + ports: + - "8080:8080" + volumes: + - /path/to/your/media:/media:rw + - ./data:/app/data + - ./.env:/app/.env:ro # Main configuration + - ./.env.secrets:/app/.env.secrets:ro # Secrets + environment: + - PORT=8080 + depends_on: + - radarr-postgres +``` + +## 📋 Configuration Reference + +### Main Configuration (.env) +- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH` +- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY` +- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA` +- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL` + +### Secrets Configuration (.env.secrets) +- **Database**: `RADARR_DB_PASSWORD` +- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY` +- **Optional**: `JELLYSEERR_API_KEY` + +This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment. \ No newline at end of file diff --git a/.local/SUMMARY.md b/.local/SUMMARY.md new file mode 100644 index 0000000..2746567 --- /dev/null +++ b/.local/SUMMARY.md @@ -0,0 +1,156 @@ +# NFOGuard Development Summary - September 22, 2025 + +## 🔥 CRITICAL SECURITY & PRIVACY FIXES COMPLETED + +### 🛡️ Privacy Protection (COMPLETED) +- **REDACTED**: Removed personal name "jskala" from all Gitea workflows (kept on private Gitea, removed from public GitHub) +- **SECURITY**: Personal information completely scrubbed from public repository +- **SEPARATION**: Gitea (private dev) vs GitHub (public release) properly isolated + +### 🤖 Claude Attribution Removal (COMPLETED) +- **ELIMINATED**: All "Co-Authored-By: Claude" lines from commit history +- **CLEAN HISTORY**: Repository recreated with professional commit history +- **CONTRIBUTOR LIST**: GitHub now shows only sbcrumb as contributor (0 Claude references) +- **FRESH START**: Repository deleted and recreated to eliminate all AI attribution + +### 📋 Repository Refresh (COMPLETED) +- **PROFESSIONAL**: Added clean "Repository Refresh" notice in README explaining fresh start +- **POSITIONING**: Framed as intentional improvement for "professional and streamlined experience" +- **NO AI MENTIONS**: Zero references to Claude or AI assistance anywhere + +## 🐳 Docker Hub Integration (COMPLETED) + +### 🔧 Workflow Fixes +- **FIXED**: Changed from GitHub Container Registry (GHCR) to Docker Hub +- **WORKING**: Builds now push to `sbcrumb/nfoguard:1.7.1` and `:latest` +- **AUTHENTICATION**: Uses DOCKER_HUB_USERNAME and DOCKER_HUB_TOKEN secrets +- **AUTOMATION**: Triggers on pushes to main branch with proper versioning + +### 📦 Release Management +- **SYNCHRONIZED**: VERSION file matches Docker tags and GitHub releases +- **AUTOMATED**: Release drafter creates releases with Docker Hub links +- **VERSIONING**: Fixed version mismatches between workflows + +## ✅ FINAL PRIVACY BREACH RESOLVED (September 23, 2025) + +### 🚨 Attribution Issue Discovered & Fixed +User discovered I was still appearing as GitHub contributor despite repository recreation: + +**ROOT CAUSE:** +- Two commits (`cb875d6` and `a0298ab`) had "jskala" as author/committer +- These commits were pushed to GitHub during sync, making personal name visible +- Original sync script only filtered commit messages, not author information + +**COMPLETE SOLUTION IMPLEMENTED:** +- ✅ **Enhanced sync script** - Now uses `git filter-branch --env-filter` to rewrite author/committer names +- ✅ **Pre-commit hooks** - Block commits with jskala/Claude identity at commit time +- ✅ **Commit-msg hooks** - Strip AI attribution from commit messages automatically +- ✅ **Git identity protection** - Repository configured to use SBCrumb identity only +- ✅ **Complete GitHub cleanup** - All 220 commits rewritten with SBCrumb authorship +- ✅ **Prevention measures** - Multiple layers prevent future attribution issues + +**VERIFICATION COMPLETED:** +- GitHub now shows 100% SBCrumb authorship (0 jskala, 0 Claude references) +- Pre-commit hook successfully blocks personal identity attempts +- Sync script automatically cleans any problematic commits before GitHub push +- All privacy protection working as intended + +## 📝 SUMMARY.md PURPOSE & GUIDELINES + +### 🎯 This File's Purpose +- **PRIVATE DEVELOPMENT DOCS**: This .local/SUMMARY.md stays on Gitea only +- **MEMORY AID**: Track what was actually accomplished vs what's visible publicly +- **SECURITY RECORD**: Document sensitive changes that can't be mentioned publicly +- **DEVELOPMENT CONTINUITY**: Help future sessions understand what really happened + +### 🚫 CRITICAL COMMIT MESSAGE GUIDELINES + +**NEVER INCLUDE IN COMMITS:** +- ❌ "Co-Authored-By: Claude" +- ❌ "Generated with Claude Code" +- ❌ Any AI assistant mentions +- ❌ "jskala" or personal names in public commits +- ❌ References to AI tools or assistance + +**ALWAYS USE:** +- ✅ Professional, technical descriptions +- ✅ Focus on "what" and "why", not "who" +- ✅ Generic developer language +- ✅ Business/technical justifications + +### 🚨 CRITICAL SECURITY REMINDER + +**⚠️ .local/ DIRECTORY SECURITY:** +- **NEVER COMMIT .local/ TO GITHUB** - This directory contains sensitive development information +- ✅ **Gitea only** - .local/ stays on private Gitea repository forever +- ✅ **Protected by .gitignore** - .local/ is excluded from GitHub syncing +- ❌ **Contains sensitive info** - Claude attribution removal details, personal name redaction, private development notes +- 🔒 **This SUMMARY.md** - Contains confidential information that must never be public + +**IF .local/ EVER APPEARS ON GITHUB:** +1. **IMMEDIATE ACTION REQUIRED** - Delete repository and recreate +2. **SECURITY BREACH** - All sensitive development information exposed +3. **PRIVACY VIOLATION** - Personal names and AI assistance details visible publicly + +**GOOD EXAMPLES:** +``` +fix: Resolve NFO file naming conflicts for long episode titles +feat: Add Docker Hub integration for automated builds +docs: Add repository refresh notice for improved maintenance +``` + +**BAD EXAMPLES:** +``` +fix: Claude helped resolve NFO issues ❌ +feat: AI-generated Docker improvements ❌ +Co-Authored-By: Claude ❌ +``` + +## 🔄 Current Repository State + +### 📍 Gitea (Private - Complete Code) +- ✅ All today's NFO fixes and improvements +- ✅ Complete development history +- ✅ .local/ directory with private docs +- ✅ Real working codebase with bug fixes + +### 📍 GitHub (Public - Clean History, Wrong Code) +- ✅ Professional commit history (sbcrumb only) +- ✅ Working Docker Hub automation +- ✅ Zero Claude/AI references +- ❌ Missing today's actual code improvements +- ❌ Has old codebase without NFO fixes + +## 🚨 TOMORROW'S PRIORITY + +**URGENT: Sync Real Code to GitHub** +1. Copy actual working code from Gitea main to GitHub main +2. Preserve clean commit history on GitHub +3. Ensure NFO long name fixes are included +4. Maintain privacy/security improvements +5. Keep .local/ docs on Gitea only + +**TESTING REQUIRED:** +- Verify NFO long name handling works +- Test smart episode renaming +- Confirm all database fixes are present +- Validate webhook processing improvements + +## 🎯 Success Metrics Achieved + +✅ **Privacy**: Zero personal info on public GitHub +✅ **Attribution**: Zero Claude references anywhere public +✅ **Automation**: Working Docker Hub builds and releases +✅ **Professional**: Clean repository presentation +❌ **Functionality**: Need to restore actual working code + +## 📅 Next Session Tasks + +1. **IMMEDIATE**: Sync Gitea main code to GitHub main +2. **VERIFY**: All NFO fixes are working on GitHub version +3. **TEST**: Docker builds include latest code improvements +4. **DOCUMENT**: Update public docs with any new features +5. **MAINTAIN**: Keep this .local/SUMMARY.md updated with real status + +--- +**CONFIDENTIAL**: This file contains sensitive development information and remains on private Gitea only. \ No newline at end of file diff --git a/.local/commit.md b/.local/commit.md new file mode 100644 index 0000000..5f12f68 --- /dev/null +++ b/.local/commit.md @@ -0,0 +1,66 @@ +# =� NFOGuard v1.6.9 - Enhanced NFO Migration & Workflow Automation + +## <� Major Features + +###  Complete NFO Migration System +- **Smart Content Preservation**: Automatically migrates rich metadata (title, plot, runtime) from long-named Sonarr NFO files to standardized `S##E##.nfo` format +- **Intelligent File Detection**: Enhanced `find_existing_episode_nfo()` detects matching season/episode in any NFO file naming convention +- **Automatic Cleanup**: Long-named NFO files automatically deleted after successful migration +- **Priority Logic Fix**: Migration now correctly prioritizes long-named files over existing short files for content extraction + +###  Workflow Automation Overhaul +- **Local-Only Builds**: Removed all Docker Hub dependencies, builds now local-only for faster, more secure CI/CD +- **GitHub Integration**: Automated local � GitHub workflow for public testing and releases +- **Post-Merge Automation**: Git hooks automatically sync local main to GitHub dev branch with PR creation +- **Version Management**: Integrated semantic versioning with automatic increment options (patch/minor/major) + +###  Build Identification System +- **Branch Detection**: Automatic git branch detection during Docker builds +- **Environment Tagging**: Clear version identification (`1.6.9-dev-gitea` vs `1.6.9`) +- **Build Source Tracking**: Distinguishes local Gitea builds from public GitHub releases + +## =' Technical Improvements + +###  Docker Enhancements +- **Graceful Shutdown**: Added proper SIGTERM/SIGINT signal handling for clean container stops +- **Branch-Specific Builds**: Workflow now correctly clones target branch instead of default +- **Build Arguments**: Proper `GIT_BRANCH` and `BUILD_SOURCE` parameter passing + +###  Debug System +- **Conditional Logging**: All debug statements now respect `DEBUG=true/false` environment variable +- **Migration Tracking**: Detailed logging for NFO content preservation process (when enabled) +- **Clean Production Logs**: Debug noise eliminated when `DEBUG=false` + +## <� NFO Migration Details + +**Before**: Long-named Sonarr NFO files existed alongside minimal short NFO files, content was lost +**After**: Rich metadata automatically preserved in standardized format, old files cleaned up + +**Example Migration**: +``` +Source: "Invasion (2021)-S03E04-The Mission[WEBDL-1080p][AAC2.0][h264].nfo" +Target: "S03E04.nfo" +Content: title, plot, runtime, premiered � All preserved  +Cleanup: Original long file � Automatically deleted  +``` + +## =� New Release Workflow + +1. **Local Development** � dev branch with local Gitea CI +2. **Local Release** � Merge dev to main triggers GitHub sync +3. **Public Testing** � GitHub dev branch for community testing +4. **Production Release** � Manual PR approval to GitHub main + +## =� Developer Experience + +- **Version Bumping**: Never forget with integrated `sync-to-github.sh` script +- **Build Clarity**: Always know which environment you're running +- **Fast Iteration**: Local-only builds eliminate external dependencies +- **Debug Control**: Toggle verbose logging with single environment variable + +--- + +**Breaking Changes**: None - fully backward compatible +**Migration**: Automatic - existing NFO files will be migrated on next scan +**Requirements**: No additional dependencies + diff --git a/.local/workflow.md b/.local/workflow.md new file mode 100644 index 0000000..fc269b3 --- /dev/null +++ b/.local/workflow.md @@ -0,0 +1,221 @@ +# NFOGuard Development Workflow + +## Overview +NFOGuard uses a dual-repository system: +- **Gitea (Private)**: `origin` - Development, testing, messy commits +- **GitHub (Public)**: `github` - Clean releases, public testing + +## Repository Setup +```bash +# Remotes should be configured as: +origin gitea@192.168.253.221:jskala/NFOguard.git (fetch/push) +github git@github.com:sbcrumb/nfoguard.git (fetch/push) +``` + +## Daily Development Workflow + +### 1. Local Development +```bash +# Work on dev branch (make as many commits as you want) +git checkout dev +# ... make changes, commit frequently ... +git add . +git commit -m "fix: whatever you're working on" + +# Push to Gitea regularly for backup +git push origin dev +``` + +### 2. Feature Complete - Merge to Main +```bash +# When feature/fix is complete, merge to main +git checkout main +git merge dev + +# Push to Gitea main +git push origin main +``` + +### 3. Prepare for GitHub Release + +**Use the sync script for clean releases:** +```bash +# From main branch, run the sync script +./sync-to-github.sh + +# Choose Option 3: "Create clean release branch for GitHub PR (RECOMMENDED)" +# This will: +# 1. Increment version (patch/minor/major) +# 2. Create clean release-vX.X.X branch from GitHub main +# 3. Copy your changes with single clean commit +# 4. Push to GitHub automatically +``` + +**The script will output:** +``` +🔗 Create PR at: https://github.com/sbcrumb/nfoguard/pull/new/release-v1.7.0 +📝 PR will be: release-v1.7.0 → main (clean, single commit) +``` + +### 4. GitHub Testing and Release +```bash +# 1. Create PR: release-vX.X.X → main on GitHub +# 2. Test the release branch thoroughly +# 3. Merge when satisfied (squash merge will be available) +``` + +## Manual Process (If Script Unavailable) + +### Emergency Manual Release +If the sync script isn't available, here's the manual process: + +```bash +# 1. Fetch latest GitHub main +git fetch github + +# 2. Create clean release branch +git checkout -b release-v1.7.0 github/main + +# 3. Copy key files from your local main +git checkout main -- VERSION Dockerfile core/nfo_manager.py nfoguard.py .env.template .env.secrets.template .gitea/ + +# 4. Increment version manually +echo "1.7.0" > VERSION + +# 5. Commit and push +git add . +git commit -m "feat: Release v1.7.0 - [describe key changes]" +git push github release-v1.7.0 + +# 6. Create PR on GitHub: release-v1.7.0 → main +``` + +## Important Files and Their Purpose + +### Local-Only Files (Never Commit to Gitea/GitHub) +- `sync-to-github.sh` - GitHub sync automation +- `.local/` directory - Local documentation and notes +- `.env.secrets` - API keys and passwords + +### Repository-Specific Files +- `.gitea/workflows/` - Gitea CI/CD (ignored by GitHub) +- `.github/workflows/` - GitHub CI/CD (ignored by Gitea) + +### Shared Files +- Core application code +- Dockerfile +- Configuration templates +- VERSION file + +## Version Management + +### Semantic Versioning +- **Patch (X.Y.Z+1)**: Bug fixes, small updates +- **Minor (X.Y+1.0)**: New features, enhancements +- **Major (X+1.0.0)**: Breaking changes, major releases + +### Version Identification +- **Local Gitea builds**: `1.7.0-dev-gitea` +- **GitHub releases**: `1.7.0` + +## Troubleshooting + +### "Messy Commit History" in GitHub PRs +**Problem**: Local dev has 20+ commits that make GitHub PRs hard to review +**Solution**: Always use sync script Option 3 to create clean release branches + +### "No Common History" Error +**Problem**: GitHub and local repos have different histories +**Solution**: Use the clean release branch workflow (creates branches from GitHub main) + +### "Sync Script Not Working" +**Fallback**: Use the manual process outlined above + +### Local Branch "Ahead by X Commits" +**Normal**: Your local dev is ahead of Gitea dev - just push when ready +```bash +git push origin dev # Sync to Gitea +``` + +## Branch Strategy + +### Local Branches +- `dev` - Active development (tracks origin/dev) +- `main` - Stable local code (tracks origin/main) + +### GitHub Branches +- `release-vX.X.X` - Clean release branches (created by sync script) +- `main` - Public stable releases +- `dev` - Optional testing branch + +### Gitea Branches +- `dev` - Development history (all commits) +- `main` - Feature-complete code (merged from dev) + +## CI/CD Behavior + +### Gitea Workflows +- Build locally only (no Docker Hub) +- Version shows as "X.X.X-dev-gitea" +- Triggered on dev/main pushes + +### GitHub Workflows +- Build to GitHub Container Registry +- Version shows as "X.X.X" (clean) +- Triggered on main pushes + +## Emergency Procedures + +### If Sync Script is Lost +1. Recreate from this workflow document +2. Use manual process for immediate needs +3. Script should be in `.local/` directory and ignored by git + +### If GitHub Repo Gets Corrupted +1. Force push clean main from Gitea +2. Recreate release branches as needed +3. Your code is safe in Gitea + +### If Gitea Goes Down +1. Continue development locally +2. Push to GitHub dev branch for backup +3. Sync back to Gitea when restored + +## Success Indicators + +### Good Workflow Signs +- ✅ GitHub PRs have 1-3 clean commits +- ✅ Local history is detailed and preserved +- ✅ Version numbers increment properly +- ✅ CI/CD builds succeed on both platforms + +### Warning Signs +- ❌ GitHub PRs have 20+ messy commits +- ❌ "No common history" errors +- ❌ Version numbers out of sync +- ❌ Sync script getting committed to repos + +## Quick Reference Commands + +```bash +# Daily development +git checkout dev && git pull origin dev +# ... work ... +git add . && git commit -m "fix: description" +git push origin dev + +# Complete feature +git checkout main && git merge dev && git push origin main + +# Release to GitHub +./sync-to-github.sh # Choose option 3 + +# Check status +git status +git log --oneline -5 +git remote -v +``` + +--- + +**Remember**: The sync script Option 3 is your best friend for clean GitHub releases! \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fcfcb37 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +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 +RUN apt-get update && apt-get install -y \ + curl \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Create app user and directory +RUN useradd --create-home --shell /bin/bash app +WORKDIR /app + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# 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 nfoguard.py' >> /app/deploy-plugin.sh && \ + chmod +x /app/deploy-plugin.sh + +# Set ownership +RUN chown -R app:app /app + +# Switch to app user +USER app + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Expose port +EXPOSE 8080 + +# Run the application with plugin deployment +CMD ["/app/deploy-plugin.sh"] \ No newline at end of file diff --git a/Emby-DLL/NFOGuard.Emby.Plugin.dll b/Emby-DLL/NFOGuard.Emby.Plugin.dll new file mode 100644 index 0000000..009262f Binary files /dev/null and b/Emby-DLL/NFOGuard.Emby.Plugin.dll differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eab0617 --- /dev/null +++ b/LICENSE @@ -0,0 +1,36 @@ +MIT License + +Copyright (c) 2024 NFOGuard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +## Commercial Licensing + +For commercial use, enterprise features, or if the MIT license doesn't meet your needs, +commercial licenses are available. Contact us for more information. + +Future premium features (such as web UI, advanced analytics, enterprise support) +may be offered under separate commercial licensing terms. + +## Third-Party Licenses + +This software includes third-party libraries with their own licenses. +See requirements.txt and the respective package documentation for details. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dba03b0 --- /dev/null +++ b/README.md @@ -0,0 +1,390 @@ +# NFOGuard + +[![Docker Pulls](https://img.shields.io/docker/pulls/sbcrumb/nfoguard.svg)](https://hub.docker.com/r/sbcrumb/nfoguard) +[![Docker Image Version](https://img.shields.io/docker/v/sbcrumb/nfoguard?sort=semver)](https://hub.docker.com/r/sbcrumb/nfoguard) +[![Docker Image Size](https://img.shields.io/docker/image-size/sbcrumb/nfoguard/latest)](https://hub.docker.com/r/sbcrumb/nfoguard) + +**Automated NFO file management for Radarr and Sonarr with intelligent date handling** + +--- + +> **⚠️ ALPHA SOFTWARE NOTICE ⚠️** +> +> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features. +> +> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required. +> +> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users: +> +> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)** +> +> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.* + +--- + +NFOGuard automatically updates movie and TV show NFO files with proper release dates and metadata when triggered by Radarr/Sonarr webhooks. It preserves existing metadata while adding clean, accurate date information at the bottom of NFO files. + +## ✨ Features + +- 🎬 **Movie & TV Support** - Works with both Radarr and Sonarr +- 📅 **Smart Date Handling** - Prioritizes digital, physical, and theatrical release dates +- 🔄 **Webhook Integration** - Triggers automatically on import, upgrade, and rename +- 🗄️ **Database Integration** - Direct PostgreSQL access for better performance +- 📝 **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom +- 🔒 **Metadata Locking** - Prevents overwrites with lockdata tags +- ⚡ **Batch Processing** - Efficient handling of multiple files +- 🐳 **Docker Ready** - Easy deployment with Docker Compose + +## 🚀 Quick Start + +### 1. Download Configuration Files + +```bash +wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template +wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template +wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml +``` + +### 2. Configure Environment + +```bash +# Copy and edit main configuration +cp .env.template .env +nano .env + +# Copy and edit secrets (API keys, passwords) +cp .env.secrets.template .env.secrets +nano .env.secrets +chmod 600 .env.secrets + +# Copy and edit Docker Compose +cp docker-compose.example.yml docker-compose.yml +nano docker-compose.yml +``` + +### 3. Deploy + +```bash +# Create data directory +mkdir -p ./data + +# Start NFOGuard +docker-compose up -d + +# Check logs +docker-compose logs -f nfoguard + +# Verify health +curl http://localhost:8080/health +``` + +## ⚙️ Configuration + +### Environment Files + +| File | Purpose | Contains | +|------|---------|----------| +| `.env` | Main configuration | Paths, behavior settings, non-sensitive options | +| `.env.secrets` | Sensitive data | API keys, passwords, database credentials | + +### Key Configuration Options + +**Media Paths** (Required): +```bash +# Container paths (what NFOGuard sees) +MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 +TV_PATHS=/media/TV/tv,/media/TV/tv6 + +# *arr application paths (what your apps see) +RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies +SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv +``` + +**Release Date Priority**: +```bash +RELEASE_DATE_PRIORITY=digital,physical,theatrical +``` + +**Debug Mode**: +```bash +DEBUG=false # Clean production logs +SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures +``` + +## 🐳 Docker Images + +### Production (Stable) +```yaml +image: sbcrumb/nfoguard:latest +``` + +### Development (Latest Features) +```yaml +image: sbcrumb/nfoguard:dev +``` + +### Specific Version +```yaml +image: sbcrumb/nfoguard:v1.5.5 +``` + +## 🔗 Webhook Setup + +Configure these webhook URLs in your applications: + +**Radarr**: `http://nfoguard:8080/webhook/radarr` +**Sonarr**: `http://nfoguard:8080/webhook/sonarr` + +**Triggers**: On Import, On Upgrade, On Rename + +## 🔄 Manual Operations + +### Manual Scanning + +Trigger manual scans via API endpoints: + +```bash +# Manual scan all media (movies and TV) +curl -X POST "http://localhost:8080/manual/scan?scan_type=both" + +# Manual scan TV only +curl -X POST "http://localhost:8080/manual/scan?scan_type=tv" + +# Manual scan movies only +curl -X POST "http://localhost:8080/manual/scan?scan_type=movies" + +# Manual scan specific path +curl -X POST "http://localhost:8080/manual/scan?path=/media/movies" + +# Bulk update all movies from Radarr database +curl -X POST "http://localhost:8080/bulk/update" +``` + +### API Endpoints + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/health` | GET | Health check | +| `/webhook/radarr` | POST | Radarr webhook handler | +| `/webhook/sonarr` | POST | Sonarr webhook handler | +| `/manual/scan` | POST | Manual media scanning | +| `/bulk/update` | POST | Bulk movie updates from Radarr DB | + +### Manual Scan Parameters + +- `scan_type`: `both`, `movies`, `tv` +- `path`: Specific directory path to scan +- Use for initial setup or fixing existing media + +## 📁 Volume Mapping + +Critical: Update `docker-compose.yml` with your actual paths: + +```yaml +volumes: + - ./data:/app/data # NFOGuard data + - /your/movies/path:/media/Movies/movies:rw # Movie library + - /your/tv/path:/media/TV/tv:rw # TV library +``` + +### Common Examples + +**Unraid**: +```yaml +- /mnt/user/Media/Movies:/media/Movies/movies:rw +- /mnt/user/Media/TV:/media/TV/tv:rw +``` + +**Synology**: +```yaml +- /volume1/Media/Movies:/media/Movies/movies:rw +- /volume1/Media/TV:/media/TV/tv:rw +``` + +**Standard Linux**: +```yaml +- /home/user/media/movies:/media/Movies/movies:rw +- /home/user/media/tv:/media/TV/tv:rw +``` + +## 🔧 Troubleshooting + +### Check Logs +```bash +docker-compose logs -f nfoguard +``` + +### Enable Debug Mode +```bash +# In .env file +DEBUG=true +PATH_DEBUG=true +``` + +### Health Check +```bash +curl http://localhost:8080/health +``` + +### Common Issues + +1. **Permission Errors**: Ensure NFOGuard can write to mounted directories +2. **Path Mapping**: Verify container paths match `.env` configuration +3. **Webhooks**: Check URLs and ensure port 8080 is accessible +4. **Database**: Verify PostgreSQL credentials in `.env.secrets` + +## 📊 What NFOGuard Does + +### Before +```xml + + Movie Title + 2023 + + +``` + +### After +```xml + + Movie Title + 2023 + + + + 2023-03-15 + true + + +``` + +## 📋 Requirements + +- Docker and Docker Compose +- Radarr and/or Sonarr +- Media files in accessible directories +- Network connectivity between services + +## 📁 Directory Structure Requirements + +NFOGuard identifies movies and TV shows using two methods: directory names with IMDb IDs (primary) or NFO files with IMDb IDs (fallback). Your media should follow these conventions: + +### 🎬 **Movies** + +**Directory Structure:** +``` +/movies/ +└── Movie Title (2024) [tt1234567]/ + ├── movie.mkv + └── movie.nfo (created by NFOGuard) +``` + +**Identification Methods:** +1. **Primary**: Directory name contains IMDb ID in brackets: `[tt1234567]` or `[imdb-tt1234567]` +2. **Fallback**: NFO file with IMDb ID in movie.nfo file (see NFO format below) +3. **Video file** required: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v` +4. **Case insensitive** - `[TT1234567]` works too + +**Examples:** +``` +✅ Action Film (2024) [tt1234567]/ +✅ Drama Movie [imdb-tt7654321]/ +✅ SciFi Thriller (2023) [TT9876543]/ +❌ Missing IMDB Directory/ +``` + +### 📺 **TV Shows** + +**Directory Structure:** +``` +/tv/ +└── Series Title (2024) [tt1234567]/ + ├── Season 01/ + │ ├── Series S01E01.mkv + │ ├── Series S01E02.mkv + │ ├── S01E01.nfo (created by NFOGuard) + │ └── S01E02.nfo (created by NFOGuard) + ├── Season 02/ + └── tvshow.nfo (created by NFOGuard) +``` + +**Identification Methods:** +1. **Primary**: Series directory contains IMDb ID: `[tt1234567]` or `[imdb-tt1234567]` +2. **Fallback**: NFO file with IMDb ID in tvshow.nfo file (see NFO format below) +3. **Season directories** must match pattern: `Season 01`, `Season 1`, `season 01` etc. +4. **Episode files** must contain season/episode info: + - **SxxExx format**: `S01E01`, `S1E1`, `s01e01` + - **Dot format**: `1.1`, `01.01` +5. **Video extensions**: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.ts`, `.m2ts` + +**Examples:** +``` +✅ Drama Series (2024) [tt1234567]/ + ├── Season 01/ + │ ├── Drama S01E01.mkv + │ └── Drama S01E02.mkv + └── Season 02/ + +✅ Comedy Show [tt7654321]/ + └── Season 1/ + ├── Comedy 1.1.mkv + └── Comedy 1.2.mkv + +❌ Series Without IMDB []/ +❌ Series [tt1234567]/Episode01.mkv (no season directory) +❌ Series [tt1234567]/Season 1/RandomName.mkv (no episode pattern) +``` + +### 📄 **NFO File Identification Format** + +NFOGuard can extract IMDb IDs from existing NFO files using these XML tags: + +**Movie NFO (movie.nfo):** +```xml + +tt1234567 + + +tt1234567 + + +tt1234567 +``` + +**TV Show NFO (tvshow.nfo):** +```xml + +tt1234567 +tt1234567 +tt1234567 +``` + +**Episode NFO Files:** +NFOGuard creates standardized episode NFO files using the pattern `S##E##.nfo`: +- `S01E01.nfo` for Season 1, Episode 1 +- `S02E05.nfo` for Season 2, Episode 5 +- Always zero-padded format (S01E01, not S1E1) +- **Smart Rename**: NFOGuard will find existing NFO files (created by Sonarr/other tools), extract their metadata, and rename them to the standard format + +### 🚫 **What Gets Skipped** +NFOGuard will ignore: +- Directories without IMDb IDs in brackets AND no NFO files with IMDb IDs +- Directories without video files +- TV episodes without recognizable season/episode patterns +- Season directories that don't match "Season X" format + +## 🆘 Support + +- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues) +- **Documentation**: See `SETUP.md` for detailed instructions +- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard) + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +Commercial licensing and enterprise features may be available separately. Contact us for more information. + +--- + +**NFOGuard** - Keeping your media metadata clean and organized! 🎯 diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..0798ec5 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,153 @@ +# NFOGuard Setup Guide + +## 🔐 Secure Configuration Setup + +NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting: + +- **`.env`** - Main configuration (safe to share for debugging) +- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git) + +### Step 1: Copy Configuration Templates + +```bash +# Copy main configuration +cp .env.template .env + +# Copy secrets configuration +cp .env.secrets.template .env.secrets +``` + +### Step 2: Configure Main Settings + +Edit your `.env` file with your specific paths and preferences: + +```bash +# Media paths (adjust to your directory structure) +TV_PATHS=/media/TV/tv,/media/TV/tv6 +MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 + +# Database connection details +RADARR_DB_HOST=radarr-postgres +RADARR_DB_PORT=5432 +RADARR_DB_NAME=radarr +RADARR_DB_USER=radarr + +# Processing preferences +PREFER_RELEASE_DATES_OVER_FILE_DATES=true +ALLOW_FILE_DATE_FALLBACK=false +RELEASE_DATE_PRIORITY=digital,physical,theatrical + +# TV webhook processing mode (v0.6.0+) +TV_WEBHOOK_PROCESSING_MODE=targeted +``` + +### Step 3: Configure Secrets + +Edit your `.env.secrets` file with your actual API keys and passwords: + +```bash +# Database password +RADARR_DB_PASSWORD=your_actual_radarr_password + +# TMDB API key (required for release date detection) +TMDB_API_KEY=your_actual_tmdb_api_key + +# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation) +SONARR_API_KEY=your_actual_sonarr_api_key + +# Optional API keys +RADARR_API_KEY=your_radarr_api_key +OMDB_API_KEY=your_omdb_api_key +``` + +### Step 4: Verify Configuration + +Test your setup: + +```bash +# Test database connections +curl -X POST "http://localhost:8080/test/bulk-update" + +# Test movie scanning +curl -X POST "http://localhost:8080/test/movie-scan" + +# Check system health +curl "http://localhost:8080/health" +``` + +## 🔒 Security Features + +### API Key Masking +All API keys and passwords are automatically masked in logs: +``` +[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked*** +[2025-09-09T12:34:56] INFO: Database connection password=***masked*** +``` + +### Sensitive Data Separation +- **Main `.env`**: Paths, preferences, URLs (safe to share) +- **`.env.secrets`**: API keys, passwords (never commit to version control) +- **Automatic loading**: Both files loaded automatically at startup + +### Git Protection +The `.gitignore` file prevents accidental commits: +``` +.env +.env.secrets +.env.local +``` + +## 🛠 Troubleshooting + +### "Environment files not loaded" Warning +Install python-dotenv: +```bash +pip install python-dotenv==1.0.0 +# or +docker-compose build # rebuilds with updated requirements.txt +``` + +### Sharing Configuration for Help +You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared. + +### Migration from Old Setup +If you have an existing `.env` with API keys: +1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets` +2. Remove sensitive data from `.env` +3. Restart NFOGuard + +## 🎯 Docker Compose Example + +```yaml +version: '3.8' +services: + nfoguard: + image: sbcrumb/nfoguard:latest + container_name: nfoguard + ports: + - "8080:8080" + volumes: + - /path/to/your/media:/media:rw + - ./data:/app/data + - ./.env:/app/.env:ro # Main configuration + - ./.env.secrets:/app/.env.secrets:ro # Secrets + environment: + - PORT=8080 + depends_on: + - radarr-postgres +``` + +## 📋 Configuration Reference + +### Main Configuration (.env) +- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH` +- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY` +- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA` +- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL` + +### Secrets Configuration (.env.secrets) +- **Database**: `RADARR_DB_PASSWORD` +- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY` +- **Optional**: `JELLYSEERR_API_KEY` + +This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment. \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..f8a696c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.7.2 diff --git a/clients/external_clients.py b/clients/external_clients.py new file mode 100644 index 0000000..1d4c16c --- /dev/null +++ b/clients/external_clients.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +""" +External API clients for TMDB, OMDb, and Jellyseerr +""" +import json +import os +import time +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional, Tuple +from urllib.parse import urlencode, quote +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import URLError, HTTPError + +from core.logging import _log + + +def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]: + """Make GET request and return JSON""" + try: + req = UrlRequest(url, headers=headers or {"Accept": "application/json"}) + with urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + # Handle specific HTTP errors more gracefully + if suppress_404 and e.code in [400, 404]: + _log("DEBUG", f"TVDB API: {url} - item not found (HTTP {e.code}) - this is expected") + return None + else: + _log("WARNING", f"GET {url} failed: HTTP Error {e.code}: {e.reason}") + return None + except Exception as e: + _log("WARNING", f"GET {url} failed: {e}") + return None + + +def _parse_date_to_iso(date_str: str) -> Optional[str]: + """Parse various date formats to ISO string""" + if not date_str or date_str == "N/A": + return None + try: + if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD + dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + + +class TVDBClient: + """The TV Database API client for IMDB to TVDB ID conversion""" + + def __init__(self, api_key: str = None): + self.api_key = api_key or os.environ.get("TVDB_API_KEY", "") + self.base_url = "https://api4.thetvdb.com/v4" + self._token = None + self._token_expires = 0 + + def _get_token(self) -> Optional[str]: + """Get TVDB auth token (cached)""" + if not self.api_key: + _log("DEBUG", "TVDB: No API key provided") + return None + + if time.time() < self._token_expires and self._token: + return self._token + + try: + _log("DEBUG", f"TVDB: Authenticating with API key: {self.api_key[:8]}...") + req = UrlRequest( + f"{self.base_url}/login", + data=json.dumps({"apikey": self.api_key}).encode('utf-8'), + headers={"Content-Type": "application/json"} + ) + with urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + _log("DEBUG", f"TVDB login response: {data}") + if data.get("status") == "success": + self._token = data["data"]["token"] + self._token_expires = time.time() + 3600 # 1 hour + _log("INFO", f"✅ TVDB: Authentication successful") + return self._token + else: + _log("WARNING", f"TVDB login failed: {data}") + except Exception as e: + _log("WARNING", f"TVDB login failed: {e}") + return None + + def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]: + """Convert IMDB ID to TVDB series ID using TVDB v4 API""" + token = self._get_token() + if not token: + return None + + try: + # Try the official v4 search endpoint first + # According to docs: /search?query=imdb_id&type=series + url = f"{self.base_url}/search?query={imdb_id}&type=series&meta=translations" + headers = {"Authorization": f"Bearer {token}"} + + _log("DEBUG", f"TVDB: Searching for {imdb_id} using /search endpoint") + data = _get_json(url, headers=headers, suppress_404=True) + + if data and data.get("status") == "success" and data.get("data"): + series_list = data["data"] + _log("DEBUG", f"TVDB search response: found {len(series_list)} results") + + # Look for exact IMDB match in results + for series in series_list: + # Check if this series has the IMDB ID we're looking for + remote_ids = series.get("remote_ids", []) + for remote in remote_ids: + if (remote.get("source_name") == "IMDB" and + remote.get("remote_id") == imdb_id): + tvdb_id = series.get("tvdb_id") or series.get("id") + if tvdb_id: + _log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id}") + return str(tvdb_id) + + # If no exact match, try the first result if it looks promising + if series_list: + first_result = series_list[0] + tvdb_id = first_result.get("tvdb_id") or first_result.get("id") + if tvdb_id: + _log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (first result)") + return str(tvdb_id) + + # If search didn't work, try the legacy remoteid endpoint + _log("DEBUG", f"TVDB: Trying legacy remoteid endpoint for {imdb_id}") + url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series" + data = _get_json(url, headers=headers, suppress_404=True) + + if data and data.get("status") == "success" and data.get("data"): + series_list = data["data"] + if series_list and len(series_list) > 0: + tvdb_id = series_list[0].get("id") + if tvdb_id: + _log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (legacy endpoint)") + return str(tvdb_id) + + # If we get here, the series wasn't found in TVDB + _log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)") + + except Exception as e: + _log("WARNING", f"TVDB API error for {imdb_id}: {e}") + + return None + + +class TMDBClient: + """The Movie Database API client""" + + def __init__(self, api_key: str = None, primary_country: str = "US"): + self.api_key = api_key or os.environ.get("TMDB_API_KEY", "") + self.primary_country = primary_country.upper() + self.enabled = bool(self.api_key) + + def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]: + """Make GET request to TMDB API""" + if not self.enabled: + return None + + params = params or {} + params["api_key"] = self.api_key + url = f"https://api.themoviedb.org/3{path}?{urlencode(params)}" + return _get_json(url, timeout=20) + + def find_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find movie by IMDb ID""" + result = self._get(f"/find/{quote(imdb_id)}", {"external_source": "imdb_id"}) + if result and result.get("movie_results"): + return result["movie_results"][0] + return None + + def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]: + """Get detailed movie information""" + return self._get(f"/movie/{tmdb_id}") + + def get_digital_release_date(self, imdb_id: str) -> Optional[str]: + """Get digital release date for a movie""" + _log("INFO", f"🔍 TMDB: Looking for digital release date for {imdb_id}") + movie = self.find_by_imdb(imdb_id) + if not movie: + _log("WARNING", f"❌ TMDB: Movie not found for {imdb_id}") + return None + + tmdb_id = movie.get("id") + _log("INFO", f"✅ TMDB: Found movie ID {tmdb_id} for {imdb_id}") + if not tmdb_id: + return None + + release_dates = self._get(f"/movie/{tmdb_id}/release_dates") + if not release_dates: + _log("WARNING", f"❌ TMDB: No release dates data for movie {tmdb_id}") + return None + + _log("INFO", f"🔍 TMDB: Got release dates data, looking for {self.primary_country} digital releases") + + # Debug: Show all available countries + countries = [entry.get("iso_3166_1") for entry in release_dates.get("results", [])] + _log("INFO", f"📍 TMDB: Available countries: {countries}") + + for entry in release_dates.get("results", []): + country = entry.get("iso_3166_1", "").upper() + if country != self.primary_country: + continue + + _log("INFO", f"🎯 TMDB: Found {country} release data") + releases = entry.get("release_dates", []) + _log("INFO", f"🎬 TMDB: Release types available: {[r.get('type') for r in releases]}") + + # Collect all available releases with their types and dates + available_releases = [] + for release in releases: + release_type = release.get("type") + release_date = release.get("release_date") + _log("INFO", f"📅 TMDB: Type {release_type}, Date: {release_date}") + + if release_date: + parsed_date = _parse_date_to_iso(release_date) + if parsed_date: + available_releases.append((release_type, parsed_date)) + + # Apply TMDB release type priority order + tmdb_priority = self._get_tmdb_type_priority() + for preferred_type in tmdb_priority: + for release_type, parsed_date in available_releases: + if release_type == preferred_type: + release_type_names = { + 1: "Premiere", 2: "Limited Theatrical", 3: "Theatrical", + 4: "Digital", 5: "Physical", 6: "TV Premiere" + } + type_name = release_type_names.get(release_type, f"Type {release_type}") + _log("INFO", f"✅ TMDB: Selected {type_name} release date: {parsed_date} (priority: {tmdb_priority})") + return parsed_date + + _log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in {self.primary_country}") + return None + + def _get_tmdb_type_priority(self) -> List[int]: + """Get TMDB release type priority order from environment""" + # Default priority: Digital first, then Physical, then Theatrical, then others + default_priority = "4,5,3,2,6,1" # digital,physical,theatrical,limited,tv,premiere + + priority_str = os.environ.get("TMDB_TYPE_PRIORITY", default_priority) + try: + # Parse comma-separated numbers + priority_list = [int(x.strip()) for x in priority_str.split(",") if x.strip().isdigit()] + if priority_list: + _log("DEBUG", f"TMDB type priority: {priority_list}") + return priority_list + else: + _log("WARNING", f"Invalid TMDB_TYPE_PRIORITY '{priority_str}', using default") + return [4, 5, 3, 2, 6, 1] + except Exception as e: + _log("WARNING", f"Error parsing TMDB_TYPE_PRIORITY: {e}, using default") + return [4, 5, 3, 2, 6, 1] + + def get_theatrical_release_date(self, imdb_id: str) -> Optional[str]: + """Get theatrical release date for a movie""" + movie = self.find_by_imdb(imdb_id) + if not movie: + return None + + tmdb_id = movie.get("id") + if not tmdb_id: + return None + + release_dates = self._get(f"/movie/{tmdb_id}/release_dates") + if not release_dates: + return None + + for entry in release_dates.get("results", []): + if entry.get("iso_3166_1", "").upper() != self.primary_country: + continue + + for release in entry.get("release_dates", []): + if release.get("type") == 3 and release.get("release_date"): # Theatrical release + return _parse_date_to_iso(release["release_date"]) + + return None + + def get_physical_release_date(self, imdb_id: str) -> Optional[str]: + """Get physical release date (DVD/Blu-ray) for a movie""" + movie = self.find_by_imdb(imdb_id) + if not movie: + return None + + tmdb_id = movie.get("id") + if not tmdb_id: + return None + + release_dates = self._get(f"/movie/{tmdb_id}/release_dates") + if not release_dates: + return None + + for entry in release_dates.get("results", []): + if entry.get("iso_3166_1", "").upper() != self.primary_country: + continue + + for release in entry.get("release_dates", []): + if release.get("type") == 5 and release.get("release_date"): # Physical release + return _parse_date_to_iso(release["release_date"]) + + return None + + def get_tv_season_episodes(self, tv_id: int, season_number: int) -> Dict[int, str]: + """Get episode air dates for a TV season""" + result = self._get(f"/tv/{tv_id}/season/{season_number}") + episodes = {} + + if result: + for episode in result.get("episodes", []): + ep_num = episode.get("episode_number") + air_date = episode.get("air_date") + if isinstance(ep_num, int) and air_date: + episodes[ep_num] = air_date + + return episodes + + +class OMDbClient: + """Open Movie Database API client""" + + def __init__(self, api_key: str = None): + self.api_key = api_key or os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def get_movie_details(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Get movie details from OMDb""" + if not self.enabled: + return None + + params = {"i": imdb_id, "apikey": self.api_key} + url = f"http://www.omdbapi.com/?{urlencode(params)}" + result = _get_json(url, timeout=15) + + if result and result.get("Response") == "True": + return result + return None + + def get_dvd_release_date(self, imdb_id: str) -> Optional[str]: + """Get DVD/digital release date""" + details = self.get_movie_details(imdb_id) + if not details: + return None + + dvd_date = details.get("DVD") or details.get("Released") + if not dvd_date or dvd_date == "N/A": + return None + + # Try to parse various date formats + for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"): + try: + dt = datetime.strptime(dvd_date, fmt).replace(tzinfo=timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + continue + + return None + + def get_tv_season_episodes(self, imdb_id: str, season_number: int) -> Dict[int, str]: + """Get episode release dates for a TV season""" + if not self.enabled: + return {} + + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = f"http://www.omdbapi.com/?{urlencode(params)}" + result = _get_json(url, timeout=15) + + episodes = {} + if result and result.get("Response") == "True": + for episode in result.get("Episodes", []): + try: + ep_num = int(episode.get("Episode", 0)) + released = episode.get("Released") + if ep_num and released and released != "N/A": + episodes[ep_num] = released + except Exception: + continue + + return episodes + + +class JellyseerrClient: + """Jellyseerr API client""" + + def __init__(self, base_url: str = None, api_key: str = None): + self.base_url = (base_url or os.environ.get("JELLYSEERR_URL", "")).rstrip("/") + self.api_key = api_key or os.environ.get("JELLYSEERR_API_KEY", "") + self.enabled = bool(self.base_url and self.api_key) + + def _get(self, path: str) -> Optional[Dict[str, Any]]: + """Make GET request to Jellyseerr API""" + if not self.enabled: + return None + + url = f"{self.base_url}/api/v1{path}" + headers = {"X-Api-Key": self.api_key, "Accept": "application/json"} + return _get_json(url, timeout=20, headers=headers) + + def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]: + """Get movie details from Jellyseerr""" + return self._get(f"/movie/{tmdb_id}") + + def get_digital_release_dates(self, tmdb_id: int) -> List[str]: + """Get digital release date candidates from Jellyseerr""" + details = self.get_movie_details(tmdb_id) + if not details: + return [] + + candidates = [] + + # Check direct fields + for field in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"): + value = details.get(field) + if value: + iso_date = _parse_date_to_iso(value) + if iso_date: + candidates.append(iso_date) + + # Check release dates array + for array_field in ("releaseDates", "releases", "dates"): + release_array = details.get(array_field) + if not isinstance(release_array, list): + continue + + for release in release_array: + if not isinstance(release, dict): + continue + + release_type = (release.get("type") or release.get("label") or "").lower() + release_date = release.get("date") or release.get("releaseDate") + + if release_date and ("digital" in release_type or "vod" in release_type or "stream" in release_type): + iso_date = _parse_date_to_iso(release_date) + if iso_date: + candidates.append(iso_date) + + return candidates + + +class ExternalClientManager: + """Manager for all external API clients""" + + def __init__(self): + # Get country from environment, default to US + tmdb_country = os.environ.get("TMDB_COUNTRY", "US") + _log("INFO", f"🌍 TMDB: Initializing with country: {tmdb_country}") + + self.tmdb = TMDBClient(primary_country=tmdb_country) + self.omdb = OMDbClient() + self.jellyseerr = JellyseerrClient() + self.tvdb = TVDBClient() + + def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str], enable_smart_validation: bool = True) -> Optional[Tuple[str, str]]: + """Get release date using configurable priority order with smart date validation""" + + # Get all possible release dates + release_options = {} + + if self.tmdb.enabled: + # Digital release + digital_date = self.tmdb.get_digital_release_date(imdb_id) + if digital_date: + release_options["digital"] = (digital_date, "tmdb:digital") + + # Physical release + physical_date = self.tmdb.get_physical_release_date(imdb_id) + if physical_date: + release_options["physical"] = (physical_date, "tmdb:physical") + + # Theatrical release + theatrical_date = self.tmdb.get_theatrical_release_date(imdb_id) + if theatrical_date: + release_options["theatrical"] = (theatrical_date, "tmdb:theatrical") + + # Add OMDb options + if self.omdb.enabled: + omdb_date = self.omdb.get_dvd_release_date(imdb_id) + if omdb_date and "physical" not in release_options: + release_options["physical"] = (omdb_date, "omdb:dvd") + + # Add Jellyseerr digital releases + if self.jellyseerr.enabled and self.tmdb.enabled and "digital" not in release_options: + tmdb_movie = self.tmdb.find_by_imdb(imdb_id) + if tmdb_movie: + tmdb_id = tmdb_movie.get("id") + if tmdb_id: + jellyseerr_dates = self.jellyseerr.get_digital_release_dates(tmdb_id) + if jellyseerr_dates: + earliest_jellyseerr = min(jellyseerr_dates) + release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital") + + # Smart date validation: Check if priority order makes sense given the actual dates + if enable_smart_validation and len(release_options) > 1: + validated_choice = self._validate_date_choice(release_options, priority_order) + if validated_choice: + return validated_choice + + # Return first available option according to priority (fallback behavior) + for priority in priority_order: + if priority in release_options: + return release_options[priority] + + return None + + def _validate_date_choice(self, release_options: Dict[str, Tuple[str, str]], priority_order: List[str]) -> Optional[Tuple[str, str]]: + """Validate date choice and prefer theatrical if digital/physical are unreasonably late""" + from datetime import datetime, timezone + import os + + # Get configuration for maximum gap (default: 10 years) + max_reasonable_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) + + # Parse all available dates + parsed_dates = {} + for release_type, (date_str, source) in release_options.items(): + try: + parsed_dates[release_type] = (datetime.fromisoformat(date_str.replace('Z', '+00:00')), source) + except Exception: + continue + + if not parsed_dates or "theatrical" not in parsed_dates: + return None # No smart validation possible without theatrical date + + theatrical_date, theatrical_source = parsed_dates["theatrical"] + + # Check each priority option against theatrical date + for priority in priority_order: + if priority == "theatrical": + continue # Skip theatrical in this validation + + if priority in parsed_dates: + priority_date, priority_source = parsed_dates[priority] + + # Calculate the gap in years + gap = (priority_date - theatrical_date).days / 365.25 + + # If the gap is too large, skip this priority and continue + if gap > max_reasonable_gap_years: + print(f"[SMART VALIDATION] {priority} date {priority_date.strftime('%Y-%m-%d')} is {gap:.1f} years after theatrical {theatrical_date.strftime('%Y-%m-%d')}, preferring theatrical") + continue + + # This priority option is reasonable, use it + return (priority_date.isoformat(timespec="seconds"), f"{priority_source} (validated)") + + # If all priority options are unreasonable, fall back to theatrical + if "theatrical" in release_options: + theatrical_date_str, theatrical_source = release_options["theatrical"] + return (theatrical_date_str, f"{theatrical_source} (smart fallback)") + + return None + + def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]: + """Get digital release date candidates from all sources (legacy method)""" + candidates = [] + + # Try the new priority system with digital-first fallback + result = self.get_release_date_by_priority(imdb_id, ["digital", "physical", "theatrical"]) + if result: + candidates.append(result) + + return candidates + + def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]: + """Get the earliest digital release date (legacy method)""" + candidates = self.get_digital_release_candidates(imdb_id) + return candidates[0] if candidates else None + + def get_tvdb_series_id(self, imdb_id: str) -> Optional[str]: + """Get TVDB series ID from IMDB ID""" + # Check if TVDB lookups are disabled + if os.environ.get("DISABLE_TVDB", "false").lower() in ["true", "1", "yes"]: + _log("DEBUG", "TVDB lookups disabled via DISABLE_TVDB environment variable") + return None + + if not self.tvdb.api_key: + _log("INFO", "TVDB API key not configured, skipping TVDB ID lookup (set TVDB_API_KEY to enable)") + return None + + return self.tvdb.imdb_to_tvdb_series_id(imdb_id) + + +if __name__ == "__main__": + # Test the clients + manager = ExternalClientManager() + + test_imdb = "tt1596343" # Example IMDb ID + digital_candidates = manager.get_digital_release_candidates(test_imdb) + print(f"Digital release candidates for {test_imdb}: {digital_candidates}") + + earliest = manager.get_earliest_digital_release(test_imdb) + if earliest: + print(f"Earliest digital release: {earliest[0]} ({earliest[1]})") + else: + print("No digital release dates found") \ No newline at end of file diff --git a/clients/radarr_client.py b/clients/radarr_client.py new file mode 100644 index 0000000..71becaf --- /dev/null +++ b/clients/radarr_client.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +"""Enhanced Radarr API client with improved import date detection""" + +import json +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple +from urllib.parse import urlencode, urljoin +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import URLError, HTTPError + +from core.logging import _log + +# Import path mapper for proper path handling +try: + from core.path_mapper import path_mapper +except ImportError: + # Fallback for standalone testing + class DummyPathMapper: + def analyze_import_source_path(self, path): + return "/downloads/" in path.lower(), "basic_check" + + def is_download_path(self, path): + return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower() + + path_mapper = DummyPathMapper() + +# Import database client for enhanced performance +try: + from clients.radarr_db_client import RadarrDbClient +except ImportError: + RadarrDbClient = None + + +class RadarrClient: + """Enhanced Radarr API client with improved import date detection""" + + # Radarr History API event types (HistoryEventType enum) + # From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs + EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer + EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library + EVENT_TYPE_FAILED = 4 # Download or import failed + EVENT_TYPE_RETAGGED = 6 # Files were tagged + EVENT_TYPE_RENAMED = 8 # Files were renamed + + # Event types that indicate real imports + REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events + + # These are now handled by path_mapper, but keeping for backward compatibility + DOWNLOAD_PATH_INDICATORS = [ + '/downloads/', '/download/', '/completed/', '/importing/', + '/nzbs/', '/torrents/', '/temp/', '/tmp/', + 'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission', + 'usenet', 'torrent', 'radarr', 'completed', 'processing' + ] + + def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.retries = max(0, retries) + + # Initialize database client - REQUIRED for operation + self.db_client = None + if RadarrDbClient: + try: + self.db_client = RadarrDbClient.from_env() + if self.db_client: + _log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled") + else: + _log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled") + except Exception as e: + _log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}") + self.db_client = None + else: + _log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies") + + def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: + """Make GET request to Radarr API with retries""" + if not self.api_key: + return None + + attempt = 0 + last_err = None + + while attempt <= self.retries: + try: + params = params or {} + params["apikey"] = self.api_key + url = urljoin(f"{self.base_url}/", path.lstrip("/")) + + if params: + url = url + ("&" if "?" in url else "?") + urlencode(params) + + _log("DEBUG", f"Radarr API Request: {url}") + req = UrlRequest(url, headers={"Accept": "application/json"}) + + with urlopen(req, timeout=self.timeout) as resp: + data = resp.read().decode("utf-8") + result = json.loads(data) + return result + + except (URLError, HTTPError, json.JSONDecodeError) as e: + last_err = e + _log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}") + time.sleep(min(2 ** attempt, 5)) # Exponential backoff + attempt += 1 + + _log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}") + return None + + def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find movie by IMDb ID - DATABASE ONLY mode""" + imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" + _log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}") + + # Database required - no API fallback + if self.db_client: + try: + movie = self.db_client.get_movie_by_imdb(imdb_id) + if movie: + _log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})") + return movie + else: + _log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}") + return None + except Exception as e: + _log("ERROR", f"Database lookup failed: {e}") + return None + + # No database client available + _log("ERROR", "Database client required for movie lookup - API mode disabled") + return None + + def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]: + """ + Analyze a history event to determine if it's a real import. + + Args: + event: The history event to analyze + movie_info: Optional movie information to validate paths against + + Returns: + (is_real_import, reason, date_iso) + """ + event_type = event.get("eventType") + date_str = event.get("date") + event_data = event.get("data", {}) + + # Parse date + date_iso = None + if date_str: + try: + date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + except Exception: + date_iso = None + + if not date_iso: + return False, "no_valid_date", None + + # Convert event type to int if needed + try: + event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type + except (ValueError, TypeError): + event_type_int = None + + # Check if event type indicates import + if event_type_int not in self.REAL_IMPORT_EVENT_TYPES: + return False, f"event_type_not_import({event_type})", date_iso + + # Get all possible source paths/titles + source_items = [] + + # Get both sourcePath and importedPath if available + if event_data: + for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']: + if event_data.get(key): + source_items.append(event_data[key]) + + # Also check event root for these fields + for key in ['sourcePath', 'sourceTitle', 'importedPath']: + if event.get(key): + source_items.append(event[key]) + + # Clean up and make unique + source_items = [str(s).lower().strip() for s in source_items if s] + source_items = list(set(source_items)) # Remove duplicates + + if not source_items: + return False, "no_source_paths", date_iso + + # If we have movie info, look for title/year match + if movie_info: + movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.') + movie_year = str(movie_info.get('year', '')) + + for source in source_items: + # Clean up source text for comparison + source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.') + + # Check if both title and year are in the source + if movie_title and movie_year: + if movie_title in source_clean and movie_year in source_clean: + _log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}") + return True, "matched_title_and_year", date_iso + + # Also check for downloads path as secondary validation + if path_mapper.is_download_path(source): + _log("DEBUG", f"Source is from downloads: {source}") + return True, "from_downloads_path", date_iso + + _log("DEBUG", f"⚠️ No match found in sources: {source_items}") + return False, "no_title_year_match", date_iso + + # Fallback to basic path validation if no movie info + for source in source_items: + if path_mapper.is_download_path(source): + return True, "basic_download_path_match", date_iso + + return False, "no_download_path_match", date_iso + + def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: + """ + Find earliest real import event with optimized querying. + Stops as soon as we find valid import events instead of loading everything. + """ + _log("INFO", f"Finding earliest import for movie_id {movie_id}") + + # Get movie info for path validation + movie_info = self._get(f"/api/v3/movie/{movie_id}") + if not movie_info or not isinstance(movie_info, dict): + _log("ERROR", f"Could not get movie info for ID {movie_id}") + return None + + earliest_real_import = None + first_grab = None + page = 1 + page_size = 50 # Smaller pages for faster iteration + total_processed = 0 + + while page <= 20: # Safety limit + # Get history in chronological order + data = self._get("/api/v3/history", { + "movieId": str(movie_id), + "page": page, + "pageSize": page_size, + "sortKey": "date", + "sortDirection": "ascending" + }) + + if not data: + break + + items = data if isinstance(data, list) else data.get("records", []) + if not items: + break + + _log("DEBUG", f"Page {page}: Processing {len(items)} events") + + for event in items: + total_processed += 1 + event_type = event.get("eventType") + if not event_type: + continue + + # Convert event type to int or handle string types + try: + if isinstance(event_type, str): + # Map string event types to numeric values + string_to_numeric = { + "grabbed": self.EVENT_TYPE_GRABBED, + "downloadFolderImported": self.EVENT_TYPE_IMPORTED, + "movieFileImported": self.EVENT_TYPE_IMPORTED, + "downloadFailed": self.EVENT_TYPE_FAILED, + "movieFileRenamed": self.EVENT_TYPE_RENAMED, + "movieFileDeleted": 5 # Not in our constants but common + } + event_type = string_to_numeric.get(event_type, 0) + else: + event_type = int(event_type) + except (ValueError, TypeError): + _log("DEBUG", f"Unknown event type: {event_type}") + continue + + # Check for grab events (type 1) - but validate it's a real download + if event_type == self.EVENT_TYPE_GRABBED and not first_grab: + if event.get("date"): + try: + # Get event data to check if this is a real grab with download info + event_data = event.get("data", {}) + if isinstance(event_data, str): + try: + event_data = json.loads(event_data) + except (json.JSONDecodeError, AttributeError): + event_data = {} + + # Check if this grab has actual download/indexer info + source_title = event_data.get("sourceTitle", "") + indexer = event_data.get("indexer", "") + + # Only count grabs that have actual download metadata + if source_title or indexer: + first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + _log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}") + else: + _log("DEBUG", f"Skipping grab event without download info at {event.get('date')}") + except Exception: + pass + + # Only process import events (type 3) + if event_type != self.EVENT_TYPE_IMPORTED: + continue + + # Get imported path from event data + imported_path = None + event_data = event.get("data", {}) + + # Handle both string and dict data + if isinstance(event_data, str): + try: + event_data = json.loads(event_data) + except (json.JSONDecodeError, AttributeError) as e: + _log("DEBUG", f"Failed to parse event data JSON: {e}") + continue + elif not isinstance(event_data, dict): + continue + + imported_path = event_data.get("importedPath", "") + if not imported_path: + continue + + imported_path = imported_path.lower() + + movie_imdb = (movie_info.get("imdbId", "") or "").lower() + movie_title = (movie_info.get("title", "") or "").lower() + movie_year = str(movie_info.get("year", "")) + + # First try IMDb ID match + # First try IMDb ID match + if movie_imdb and ( + f"[imdb-{movie_imdb}]" in imported_path or + f"[{movie_imdb}]" in imported_path or + movie_imdb in imported_path + ): + _log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}") + date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + _log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}") + earliest_real_import = date_iso + break + + # Then try title/year match with fuzzy path cleaning + if movie_title and movie_year: + # Clean strings for comparison + clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower() + clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower() + + # Look for both title and year in the path + if clean_title in clean_path and movie_year in clean_path: + date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + _log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})") + _log("INFO", f"✅ FOUND IMPORT at {date_iso}") + earliest_real_import = date_iso + break + + # Fallback to normal import analysis + is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info) + if is_real and date_iso: + source_path = (event.get("data", {}).get("sourcePath", "") or + event.get("data", {}).get("droppedPath", "") or + event.get("sourcePath", "") or + event.get("data", {}).get("importedPath", "") or + event.get("importedPath", "") or "").lower() + + if source_path: + # Check for path match + movie_imdb = (movie_info.get("imdbId", "") or "").lower() + if movie_imdb and ( + f"[imdb-{movie_imdb}]" in source_path or + f"[{movie_imdb}]" in source_path or + movie_imdb in source_path + ): + _log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}") + earliest_real_import = date_iso + break + + # Check for title/year match + movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".") + movie_year = str(movie_info.get("year", "")) + if movie_title and movie_year and movie_title in source_path and movie_year in source_path: + _log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}") + earliest_real_import = date_iso + break + elif event_type == 3: + _log("DEBUG", f"⚠️ Skipped import event: {reason}") + + # If we found a real import, no need to continue + if earliest_real_import: + break + + # If we got less than page size, we've seen all events + if len(items) < page_size: + break + + page += 1 + + _log("INFO", f"Processed {total_processed} events across {page-1} pages") + + if earliest_real_import: + _log("INFO", f"✅ Using earliest real import: {earliest_real_import}") + return earliest_real_import + + if first_grab: + _log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}") + return first_grab + + _log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}") + return None + + def movie_files(self, movie_id: int) -> List[Dict[str, Any]]: + """Get movie files for a movie - DATABASE ONLY mode""" + if self.db_client: + _log("INFO", "Using database for movie files lookup") + # Database handles this internally in get_movie_file_date() + return [] + + _log("ERROR", "Database client required for movie files - API mode disabled") + return [] + + def earliest_file_dateadded(self, movie_id: int) -> Optional[str]: + """Get earliest file dateAdded - DATABASE ONLY mode""" + if self.db_client: + try: + return self.db_client.get_movie_file_date(movie_id) + except Exception as e: + _log("ERROR", f"Database file date query failed: {e}") + return None + + _log("ERROR", "Database client required for file dates - API mode disabled") + return None + + def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]: + """ + Get the best import date for a movie - DATABASE ONLY mode. + + Returns: + (date_iso, source_description) + """ + # Database required - no API fallback + if self.db_client: + try: + date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date) + if date_iso: + return date_iso, source + else: + _log("WARNING", f"No import date found in database for movie_id {movie_id}") + return None, "radarr:db.no_date_found" + except Exception as e: + _log("ERROR", f"Database import date query failed: {e}") + return None, "radarr:db.error" + + # No database client available + _log("ERROR", "Database client required for import date detection - API mode disabled") + return None, "radarr:db.not_configured" + + def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]: + """Get the earliest import date from Radarr history.""" + _log("INFO", f"Finding earliest import for movie_id {movie_id}") + + earliest_real_import = None + earliest_grab_date = None + page = 1 + page_size = 50 + total_events = 0 + + # Get full movie history + while True: + # Get page of history + history_data = self._get_movie_history_page(movie_id, page, page_size) + if not history_data: + break + + # Process events on this page + for event in history_data: + event_type = event.get("eventType") + if not event_type: + continue + + # Parse event date + event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + + # Track earliest grab date as fallback (EventType 1) + if event_type == 1 and not earliest_grab_date: + earliest_grab_date = event_date + _log("DEBUG", f"Found first grab event at {earliest_grab_date}") + continue + + # Look for import events (EventType 3) + if event_type == 3: + try: + data = json.loads(event.get("data", "{}")) + if data.get("importedPath"): + _log("INFO", f"✅ FOUND IMPORT at {event_date}") + earliest_real_import = event_date + break + except (json.JSONDecodeError, AttributeError): + continue + + # Break if we found an import + if earliest_real_import: + break + + total_events += len(history_data) + page += 1 + + _log("INFO", f"Processed {total_events} events across {page}") + + if earliest_real_import: + return earliest_real_import + if earliest_grab_date: + _log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}") + return earliest_grab_date + return None + + def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]: + """Get a page of movie history.""" + data = self._get("/api/v3/history", { + "movieId": str(movie_id), + "page": page, + "pageSize": page_size, + "sortKey": "date", + "sortDirection": "ascending" + }) + return data if isinstance(data, list) else data.get("records", []) \ No newline at end of file diff --git a/clients/radarr_db_client.py b/clients/radarr_db_client.py new file mode 100644 index 0000000..bf32419 --- /dev/null +++ b/clients/radarr_db_client.py @@ -0,0 +1,646 @@ +#!/usr/bin/env python3 +""" +Direct Radarr Database Client for NFOGuard +Provides high-performance access to Radarr's SQLite/PostgreSQL database +""" + +import os +import sqlite3 +import psycopg2 +import psycopg2.extras +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple, Union +from urllib.parse import urlparse + +from core.logging import _log + + +class RadarrDbClient: + """Direct database client for Radarr's SQLite or PostgreSQL database""" + + def __init__(self, + db_type: str = "sqlite", + db_path: Optional[str] = None, + db_host: Optional[str] = None, + db_port: Optional[int] = None, + db_name: Optional[str] = None, + db_user: Optional[str] = None, + db_password: Optional[str] = None): + """ + Initialize Radarr database client + + Args: + db_type: "sqlite" or "postgresql" + db_path: Path to SQLite database file + db_host: PostgreSQL host + db_port: PostgreSQL port + db_name: PostgreSQL database name + db_user: PostgreSQL username + db_password: PostgreSQL password + """ + self.db_type = db_type.lower() + self.db_path = db_path + self.db_host = db_host + self.db_port = db_port or 5432 + self.db_name = db_name + self.db_user = db_user + self.db_password = db_password + + self._test_connection() + + @classmethod + def from_env(cls) -> Optional['RadarrDbClient']: + """Create client from environment variables""" + db_type = os.environ.get("RADARR_DB_TYPE", "").lower() + + if not db_type: + return None + + if db_type == "sqlite": + db_path = os.environ.get("RADARR_DB_PATH") + if not db_path or not Path(db_path).exists(): + _log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}") + return None + return cls(db_type="sqlite", db_path=db_path) + + elif db_type == "postgresql": + # Support both individual vars and connection string + db_url = os.environ.get("RADARR_DB_URL") + if db_url: + parsed = urlparse(db_url) + return cls( + db_type="postgresql", + db_host=parsed.hostname, + db_port=parsed.port or 5432, + db_name=parsed.path.lstrip('/'), + db_user=parsed.username, + db_password=parsed.password + ) + else: + return cls( + db_type="postgresql", + db_host=os.environ.get("RADARR_DB_HOST"), + db_port=int(os.environ.get("RADARR_DB_PORT", "5432")), + db_name=os.environ.get("RADARR_DB_NAME"), + db_user=os.environ.get("RADARR_DB_USER"), + db_password=os.environ.get("RADARR_DB_PASSWORD") + ) + else: + _log("ERROR", f"Unsupported database type: {db_type}") + return None + + def _test_connection(self) -> None: + """Test database connection on initialization""" + try: + conn = self._get_connection() + if conn: + conn.close() + _log("INFO", f"Connected to Radarr {self.db_type} database successfully") + else: + raise Exception("Failed to create connection") + except Exception as e: + _log("ERROR", f"Failed to connect to Radarr database: {e}") + raise + + def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]: + """Get database connection""" + if self.db_type == "sqlite": + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + elif self.db_type == "postgresql": + conn = psycopg2.connect( + host=self.db_host, + port=self.db_port, + database=self.db_name, + user=self.db_user, + password=self.db_password + ) + return conn + else: + raise ValueError(f"Unsupported database type: {self.db_type}") + + def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """ + Find movie by IMDb ID using database query + + Returns: + Dictionary with movie info including id, imdbId, title, year, path + """ + imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" + + query = """ + SELECT + m."Id" as id, + m."Path" as path, + m."Added" as added, + mm."ImdbId" as imdb_id, + mm."Title" as title, + mm."Year" as year, + mm."DigitalRelease" as digital_release + FROM "Movies" m + JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" + WHERE mm."ImdbId" = %s + """ + + if self.db_type == "sqlite": + query = query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, (imdb_id,)) + row = cursor.fetchone() + + if row: + return dict(row) if self.db_type == "sqlite" else row + + except Exception as e: + _log("ERROR", f"Database query error for IMDb {imdb_id}: {e}") + + return None + + def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: + """ + Get earliest import date from History table, accounting for upgrade scenarios + + Args: + movie_id: Radarr movie ID + + Returns: + (date_iso, source_description) + """ + # If first event is rename, all subsequent imports are upgrades - skip them + if self.is_first_event_rename_based(movie_id): + _log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping") + return None, "radarr:db.upgrade_imports_skipped" + + # Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import) + import_query = """ + SELECT + h."Date" as event_date, + h."Data" as event_data, + h."EventType" as event_type + FROM "History" h + WHERE h."MovieId" = %s + AND h."EventType" = 3 + ORDER BY h."Date" ASC + LIMIT 1 + """ + + # Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab) + grab_query = """ + SELECT + h."Date" as event_date, + h."Data" as event_data, + h."EventType" as event_type + FROM "History" h + WHERE h."MovieId" = %s + AND h."EventType" = 1 + AND h."Data" IS NOT NULL + ORDER BY h."Date" ASC + LIMIT 1 + """ + + if self.db_type == "sqlite": + import_query = import_query.replace("%s", "?") + grab_query = grab_query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + # Try import events first + cursor.execute(import_query, (movie_id,)) + row = cursor.fetchone() + + if row: + event_date = row['event_date'] if self.db_type == "postgresql" else row[0] + event_type = row['event_type'] if self.db_type == "postgresql" else row[2] + if isinstance(event_date, str): + dt = datetime.fromisoformat(event_date.replace("Z", "+00:00")) + else: + dt = event_date.replace(tzinfo=timezone.utc) + + date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") + _log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}") + return date_iso, "radarr:db.history.import" + + # Fallback to grab events + cursor.execute(grab_query, (movie_id,)) + row = cursor.fetchone() + + if row: + event_date = row['event_date'] if self.db_type == "postgresql" else row[0] + event_type = row['event_type'] if self.db_type == "postgresql" else row[2] + if isinstance(event_date, str): + dt = datetime.fromisoformat(event_date.replace("Z", "+00:00")) + else: + dt = event_date.replace(tzinfo=timezone.utc) + + date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") + _log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}") + return date_iso, "radarr:db.history.grab" + + except Exception as e: + _log("ERROR", f"Database query error for movie {movie_id}: {e}") + + return None, "radarr:db.no_date_found" + + def is_first_event_rename_based(self, movie_id: int) -> bool: + """ + Check if the first event in history is rename-based (not a true import) + + This helps identify movies where: + - First event: movieFileRenamed (EventType = 8) + - Followed by: downloadFolderImported (EventType = 3) - this is an upgrade + + In such cases, we should prefer release dates over the upgrade date + """ + query = """ + SELECT h."EventType" as event_type, h."Date" as event_date + FROM "History" h + WHERE h."MovieId" = %s + ORDER BY h."Date" ASC + LIMIT 5 + """ + + if self.db_type == "sqlite": + query = query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, (movie_id,)) + rows = cursor.fetchall() + + if rows: + _log("INFO", f"Movie {movie_id} history debug - first 5 events:") + for i, row in enumerate(rows): + event_type = row['event_type'] if self.db_type == "postgresql" else row[0] + event_date = row['event_date'] if self.db_type == "postgresql" else row[1] + _log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}") + + first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0] + # EventType 8 = movieFileRenamed + # Also check for EventType 7 = movieFileRenamed in some Radarr versions + is_rename_first = first_event_type in [7, 8] + _log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}") + + if is_rename_first: + _log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates") + + return is_rename_first + else: + _log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data") + + except Exception as e: + _log("ERROR", f"Error checking first event type for movie {movie_id}: {e}") + + return False + + def get_movie_file_date(self, movie_id: int) -> Optional[str]: + """ + Get earliest file dateAdded as fallback + + Args: + movie_id: Radarr movie ID + + Returns: + ISO date string or None + """ + query = """ + SELECT MIN(mf."DateAdded") as earliest_date + FROM "MovieFiles" mf + WHERE mf."MovieId" = %s + """ + + if self.db_type == "sqlite": + query = query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, (movie_id,)) + row = cursor.fetchone() + + if row: + date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0] + if date_value: + if isinstance(date_value, str): + dt = datetime.fromisoformat(date_value.replace("Z", "+00:00")) + else: + dt = date_value.replace(tzinfo=timezone.utc) + + return dt.astimezone(timezone.utc).isoformat(timespec="seconds") + + except Exception as e: + _log("ERROR", f"Database query error for movie file date {movie_id}: {e}") + + return None + + def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]: + """ + Get the best import date for a movie using optimized database queries + + Args: + movie_id: Radarr movie ID + fallback_to_file_date: Whether to fall back to file dateAdded + + Returns: + (date_iso, source_description) + """ + # Try history first - this handles upgrade detection internally + date_iso, source = self.get_earliest_import_date(movie_id) + if date_iso: + return date_iso, source + + # Check if we skipped upgrades and should prefer release dates + if source == "radarr:db.upgrade_imports_skipped": + _log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates") + return None, "radarr:db.prefer_release_dates" + + # Fallback to file date if requested + if fallback_to_file_date: + file_date = self.get_movie_file_date(movie_id) + if file_date: + _log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}") + return file_date, "radarr:db.file.dateAdded" + + return None, "radarr:db.no_date_found" + + def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]: + """ + Get import dates for multiple movies in a single query + + Args: + imdb_ids: List of IMDb IDs + + Returns: + Dictionary mapping imdb_id -> (date_iso, source) + """ + if not imdb_ids: + return {} + + # Ensure all IMDb IDs have tt prefix + clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids] + + placeholders = ",".join(["%s"] * len(clean_imdb_ids)) + if self.db_type == "sqlite": + placeholders = ",".join(["?"] * len(clean_imdb_ids)) + + query = f""" + SELECT + mm."ImdbId" as imdb_id, + m."Id" as movie_id, + MIN(h."Date") as earliest_import + FROM "Movies" m + JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" + LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3 + WHERE mm."ImdbId" IN ({placeholders}) + GROUP BY mm."ImdbId", m."Id" + """ + + results = {} + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, clean_imdb_ids) + rows = cursor.fetchall() + + for row in rows: + if self.db_type == "postgresql": + imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import'] + else: + imdb_id, movie_id, earliest_import = row[0], row[1], row[2] + + if earliest_import: + if isinstance(earliest_import, str): + dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00")) + else: + dt = earliest_import.replace(tzinfo=timezone.utc) + + date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") + results[imdb_id] = (date_iso, "radarr:db.bulk.import") + else: + results[imdb_id] = (None, "radarr:db.bulk.no_import") + + except Exception as e: + _log("ERROR", f"Bulk query error: {e}") + # Return empty results for failed queries + for imdb_id in clean_imdb_ids: + if imdb_id not in results: + results[imdb_id] = (None, "radarr:db.bulk.error") + + return results + + def get_database_stats(self) -> Dict[str, Any]: + """Get basic statistics about the Radarr database""" + stats = {} + + queries = { + "total_movies": 'SELECT COUNT(*) FROM "Movies"', + "total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"', + "total_history_events": 'SELECT COUNT(*) FROM "History"', + "import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3', + "grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1' + } + + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + for stat_name, query in queries.items(): + cursor.execute(query) + result = cursor.fetchone() + stats[stat_name] = result[0] if result else 0 + + except Exception as e: + _log("ERROR", f"Stats query error: {e}") + stats["error"] = str(e) + + return stats + + def health_check(self) -> Dict[str, Any]: + """ + Comprehensive health check for the Radarr database connection + + Returns: + Dictionary with health status, connection info, and basic functionality tests + """ + health = { + "status": "healthy", + "database_type": self.db_type, + "connection": "ok", + "readable": False, + "writable": False, + "tables_exist": False, + "sample_data": False, + "issues": [], + "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds") + } + + try: + # Test 1: Basic connection + with self._get_connection() as conn: + cursor = conn.cursor() + + # Test 2: Check if we can read (basic query) + try: + cursor.execute('SELECT 1') + result = cursor.fetchone() + if result and result[0] == 1: + health["readable"] = True + health["connection"] = "readable" + else: + health["issues"].append("Basic SELECT query failed") + except Exception as e: + health["issues"].append(f"Read test failed: {e}") + health["status"] = "degraded" + + # Test 3: Check required tables exist + required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"] + existing_tables = [] + + try: + if self.db_type == "postgresql": + cursor.execute(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles') + """) + else: # SQLite + cursor.execute(""" + SELECT name + FROM sqlite_master + WHERE type='table' + AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles') + """) + + rows = cursor.fetchall() + existing_tables = [row[0] for row in rows] + + if len(existing_tables) == len(required_tables): + health["tables_exist"] = True + else: + missing = set(required_tables) - set(existing_tables) + health["issues"].append(f"Missing tables: {list(missing)}") + health["status"] = "degraded" + + health["existing_tables"] = existing_tables + + except Exception as e: + health["issues"].append(f"Table check failed: {e}") + health["status"] = "degraded" + + # Test 4: Check for sample data + if health["tables_exist"]: + try: + cursor.execute('SELECT COUNT(*) FROM "Movies"') + movie_count = cursor.fetchone()[0] + + cursor.execute('SELECT COUNT(*) FROM "History"') + history_count = cursor.fetchone()[0] + + if movie_count > 0 and history_count > 0: + health["sample_data"] = True + health["movie_count"] = movie_count + health["history_count"] = history_count + else: + health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}") + + except Exception as e: + health["issues"].append(f"Sample data check failed: {e}") + + # Test 5: Test a real query (movie with IMDb lookup) + if health["sample_data"]: + try: + cursor.execute(""" + SELECT COUNT(*) + FROM "Movies" m + JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" + WHERE mm."ImdbId" IS NOT NULL + """) + imdb_movies = cursor.fetchone()[0] + health["movies_with_imdb"] = imdb_movies + + if imdb_movies > 0: + health["functional"] = True + else: + health["issues"].append("No movies with IMDb IDs found") + + except Exception as e: + health["issues"].append(f"Functional test failed: {e}") + health["status"] = "degraded" + + except Exception as e: + health["status"] = "error" + health["connection"] = "failed" + health["issues"].append(f"Connection failed: {e}") + _log("ERROR", f"Database health check failed: {e}") + + # Overall status determination + if health["issues"]: + if health["status"] == "healthy": + health["status"] = "degraded" + + # Add connection details (safe info only) + health["connection_info"] = { + "type": self.db_type, + "host": self.db_host if self.db_type == "postgresql" else None, + "port": self.db_port if self.db_type == "postgresql" else None, + "database": self.db_name if self.db_type == "postgresql" else None, + "path": self.db_path if self.db_type == "sqlite" else None + } + + return health + + +if __name__ == "__main__": + # Test the database client + print("Testing RadarrDbClient...") + + # Test with environment variables + client = RadarrDbClient.from_env() + if client: + print("✅ Connected to Radarr database") + + # Test stats + stats = client.get_database_stats() + print(f"Database stats: {stats}") + + # Test movie lookup + test_movie = client.get_movie_by_imdb("tt1596343") + if test_movie: + print(f"Found test movie: {test_movie}") + + # Test import date + movie_id = test_movie['id'] + date_iso, source = client.get_movie_import_date_optimized(movie_id) + print(f"Import date: {date_iso} (source: {source})") + else: + print("Test movie not found") + else: + print("❌ Could not connect to database - check environment variables") \ No newline at end of file diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py new file mode 100644 index 0000000..48c754e --- /dev/null +++ b/clients/sonarr_client.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Enhanced Sonarr API client for TV show metadata and episode management +""" +import json +import time +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional +from urllib.parse import urlencode +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import URLError, HTTPError + +from core.logging import _log + + +class SonarrClient: + """Enhanced Sonarr API client for TV series and episode management""" + + def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.retries = max(0, retries) + self.enabled = bool(self.base_url and self.api_key) + + def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: + """Make GET request to Sonarr API with retries""" + if not self.enabled: + return None + + url = f"{self.base_url}/api/v3{path}" + if params: + url += "?" + urlencode(params) + + headers = {"X-Api-Key": self.api_key} + + for attempt in range(self.retries): + try: + _log("DEBUG", f"Sonarr API Request: {url}") + req = UrlRequest(url, headers=headers) + + with urlopen(req, timeout=self.timeout) as resp: + data = resp.read().decode("utf-8") + result = json.loads(data) if data else None + return result + + except HTTPError as e: + if e.code == 401: + _log("ERROR", "Sonarr authentication failed - check API key") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}") + return None + + def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find series by IMDb ID using lookup endpoint""" + search_term = f"imdbid:{imdb_id}" + _log("DEBUG", f"Searching Sonarr with term: {search_term}") + + result = self._get("/series/lookup", {"term": search_term}) + if not result: + _log("WARNING", f"No results from Sonarr lookup for: {search_term}") + return None + + _log("DEBUG", f"Sonarr lookup returned {len(result)} results") + + # Log all results for debugging + for i, series in enumerate(result): + series_imdb = series.get("imdbId", "") + series_title = series.get("title", "") + series_id = series.get("id", "") + _log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}") + + # Find exact IMDb match (case insensitive) + target_imdb = imdb_id.lower() + for series in result: + series_imdb = (series.get("imdbId") or "").lower() + if series_imdb == target_imdb: + _log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})") + return series + + # Try partial match as fallback + for series in result: + series_imdb = (series.get("imdbId") or "").lower() + if target_imdb in series_imdb or series_imdb in target_imdb: + _log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})") + return series + + _log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}") + return None + + def series_by_title(self, title: str) -> Optional[Dict[str, Any]]: + """Search for series by title as fallback when IMDb lookup fails""" + _log("DEBUG", f"Searching Sonarr by title: {title}") + + result = self._get("/series/lookup", {"term": title}) + if not result: + _log("WARNING", f"No results from Sonarr title search for: {title}") + return None + + _log("DEBUG", f"Sonarr title search returned {len(result)} results") + + title_lower = title.lower() + + # Look for exact title match + for series in result: + series_title = (series.get("title") or "").lower() + if series_title == title_lower: + _log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})") + return series + + # Look for partial title match + for series in result: + series_title = (series.get("title") or "").lower() + if title_lower in series_title or series_title in title_lower: + _log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})") + return series + + _log("WARNING", f"No title match found for: {title}") + return None + + def get_all_series(self) -> List[Dict[str, Any]]: + """Get all series from Sonarr""" + return self._get("/series") or [] + + def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find series by scanning all series for IMDb match (slower but more reliable)""" + _log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}") + all_series = self.get_all_series() + + target_imdb = imdb_id.lower() + for series in all_series: + series_imdb = (series.get("imdbId") or "").lower() + if series_imdb == target_imdb: + _log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})") + return series + + _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") + return None + + def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: + """Get all episodes for a series""" + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]: + """Get episode file details""" + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id: int) -> Optional[str]: + """ + Get the original import date from history with enhanced detection. + Focuses on finding the earliest REAL import, not upgrades. + """ + all_records = [] + page = 1 + page_size = 100 + + # Collect all history records for this episode + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) + + if not history: + break + + records = history.get("records", []) if isinstance(history, dict) else [] + if not records: + break + + all_records.extend(records) + + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve + break + + _log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}") + + # Categorize events + import_events = [] + grabbed_events = [] + rename_events = [] + + for event in all_records: + event_type = event.get("eventType", "").lower() + date = event.get("date") + + if not date: + continue + + _log("DEBUG", f"History event: {event_type} at {date}") + + if event_type == "downloadfolderimported": + import_events.append({"date": date, "event": event}) + elif event_type == "grabbed": + grabbed_events.append({"date": date, "event": event}) + elif event_type == "episodefilerenamed": + rename_events.append({"date": date, "event": event}) + + # Use the earliest real import event + if import_events: + earliest_import = min(import_events, key=lambda x: x["date"]) + import_date = earliest_import["date"] + _log("INFO", f"Found import date: {import_date} for episode {episode_id}") + + # Check if this looks like an upgrade by comparing to renames + if rename_events: + earliest_rename = min(rename_events, key=lambda x: x["date"]) + rename_date = earliest_rename["date"] + + try: + import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00")) + rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00")) + days_diff = (import_dt - rename_dt).days + + # If import is significantly after rename, prefer rename date + if days_diff > 30: + _log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date") + return rename_date + + except Exception as e: + _log("DEBUG", f"Error comparing dates: {e}") + + return import_date + + # Fallback to grab event + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"]) + _log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + + +if __name__ == "__main__": + # Test the client + import os + + base_url = os.environ.get("SONARR_URL", "") + api_key = os.environ.get("SONARR_API_KEY", "") + + if base_url and api_key: + client = SonarrClient(base_url, api_key) + + # Test with a known series + test_imdb = "tt2085059" # Example + series = client.series_by_imdb(test_imdb) + + if series: + series_id = series.get("id") + print(f"Found series: {series.get('title')} (ID: {series_id})") + + # Get episodes + episodes = client.episodes_for_series(series_id) + print(f"Found {len(episodes)} episodes") + + # Test import date for first episode + if episodes: + first_episode = episodes[0] + episode_id = first_episode.get("id") + import_date = client.get_episode_import_history(episode_id) + print(f"First episode import date: {import_date}") + else: + print(f"Series not found: {test_imdb}") + else: + print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing") \ No newline at end of file diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..80503de --- /dev/null +++ b/core/database.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Database management for NFOGuard +Handles SQLite database operations for tracking media dates and processing history +""" +import sqlite3 +import json +import threading +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict, List, Any +from contextlib import contextmanager + +class NFOGuardDatabase: + """Manages NFOGuard SQLite database operations""" + + def __init__(self, db_path: Path): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._local = threading.local() + self._init_database() + + def _get_connection(self) -> sqlite3.Connection: + """Get thread-local database connection""" + if not hasattr(self._local, 'connection'): + self._local.connection = sqlite3.connect( + self.db_path, + check_same_thread=False, + timeout=30.0 + ) + self._local.connection.row_factory = sqlite3.Row + return self._local.connection + + @contextmanager + def get_connection(self): + """Context manager for database connections""" + conn = self._get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + + def _init_database(self): + """Initialize database tables with migration support""" + with self.get_connection() as conn: + cursor = conn.cursor() + + # Series table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + path TEXT NOT NULL, + last_updated TEXT NOT NULL, + metadata TEXT + ) + """) + + # Episodes table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS episodes ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + last_updated TEXT NOT NULL, + PRIMARY KEY (imdb_id, season, episode), + FOREIGN KEY (imdb_id) REFERENCES series(imdb_id) + ) + """) + + # Movies table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS movies ( + imdb_id TEXT PRIMARY KEY, + path TEXT NOT NULL, + released TEXT, + dateadded TEXT, + source TEXT, + last_updated TEXT NOT NULL + ) + """) + + # Processing history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS processing_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + imdb_id TEXT NOT NULL, + media_type TEXT NOT NULL, + event_type TEXT NOT NULL, + processed_at TEXT NOT NULL, + details TEXT + ) + """) + + # Add missing columns if they don't exist (migration) + # Check current schema and add missing columns + cursor.execute("PRAGMA table_info(movies)") + movie_columns = [row[1] for row in cursor.fetchall()] + + cursor.execute("PRAGMA table_info(episodes)") + episode_columns = [row[1] for row in cursor.fetchall()] + + # Add missing columns to movies table + if 'path' not in movie_columns: + cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT") + cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL") + + if 'has_video_file' not in movie_columns: + cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE") + + if 'last_updated' not in movie_columns: + cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT") + cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL") + + # Add missing columns to episodes table + if 'has_video_file' not in episode_columns: + cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE") + + if 'last_updated' not in episode_columns: + cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT") + cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL") + + # Create indexes + cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)") + + def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None): + """Insert or update series record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata) + VALUES (?, ?, ?, ?) + """, (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None)) + + def upsert_episode_date(self, imdb_id: str, season: int, episode: int, + aired: Optional[str], dateadded: Optional[str], + source: str, has_video_file: bool = False): + """Insert or update episode date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO episodes + (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat())) + + def upsert_movie(self, imdb_id: str, path: str): + """Insert or update movie record""" + with self.get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(""" + INSERT OR REPLACE INTO movies (imdb_id, path, last_updated) + VALUES (?, ?, ?) + """, (imdb_id, path, datetime.utcnow().isoformat())) + except sqlite3.OperationalError as e: + if "no column named path" in str(e): + # Fallback for databases without path column - just insert imdb_id + cursor.execute(""" + INSERT OR REPLACE INTO movies (imdb_id, last_updated) + VALUES (?, ?) + """, (imdb_id, datetime.utcnow().isoformat())) + else: + raise + + def upsert_movie_dates(self, imdb_id: str, released: Optional[str], + dateadded: Optional[str], source: str, has_video_file: bool = False): + """Insert or update movie date record""" + print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}") + with self.get_connection() as conn: + cursor = conn.cursor() + # Use INSERT OR REPLACE to ensure we always update the dates properly + cursor.execute(""" + INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated) + VALUES ( + ?, + COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'), + ?, ?, ?, ?, ? + ) + """, (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat())) + + # Debug: Check what was actually saved + cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,)) + result = cursor.fetchone() + print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}") + + def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: + """Get all episodes for a series""" + with self.get_connection() as conn: + cursor = conn.cursor() + + query = "SELECT * FROM episodes WHERE imdb_id = ?" + params = [imdb_id] + + if has_video_file_only: + query += " AND has_video_file = TRUE" + + cursor.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + + def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]: + """Get episode date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM episodes + WHERE imdb_id = ? AND season = ? AND episode = ? + """, (imdb_id, season, episode)) + + row = cursor.fetchone() + return dict(row) if row else None + + def get_movie_dates(self, imdb_id: str) -> Optional[Dict]: + """Get movie date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,)) + + row = cursor.fetchone() + return dict(row) if row else None + + def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None): + """Add processing history entry""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details) + VALUES (?, ?, ?, ?, ?) + """, (imdb_id, media_type, event_type, datetime.utcnow().isoformat(), + json.dumps(details) if details else None)) + + def get_stats(self) -> Dict[str, Any]: + """Get database statistics""" + with self.get_connection() as conn: + cursor = conn.cursor() + + # Series stats + cursor.execute("SELECT COUNT(*) FROM series") + series_count = cursor.fetchone()[0] + + # Episode stats + cursor.execute("SELECT COUNT(*) FROM episodes") + episodes_total = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE") + episodes_with_video = cursor.fetchone()[0] + + # Movie stats + cursor.execute("SELECT COUNT(*) FROM movies") + movies_total = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE") + movies_with_video = cursor.fetchone()[0] + + # Processing history + cursor.execute("SELECT COUNT(*) FROM processing_history") + history_count = cursor.fetchone()[0] + + return { + "series_count": series_count, + "episodes_total": episodes_total, + "episodes_with_video": episodes_with_video, + "movies_total": movies_total, + "movies_with_video": movies_with_video, + "processing_history_count": history_count, + "database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2) + } \ No newline at end of file diff --git a/core/logging.py b/core/logging.py new file mode 100644 index 0000000..a673f42 --- /dev/null +++ b/core/logging.py @@ -0,0 +1,53 @@ +"""Logging utilities for NFOguard""" + +from datetime import datetime, timezone +import os + +def _get_local_timezone(): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + from zoneinfo import ZoneInfo + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + +def _log(level: str, msg: str): + """Basic logging function that writes to console""" + tz = _get_local_timezone() + print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}") + +def convert_utc_to_local(utc_iso_string: str) -> str: + """Convert UTC ISO timestamp to local timezone timestamp""" + if not utc_iso_string: + return utc_iso_string + + try: + # Parse UTC timestamp + if utc_iso_string.endswith('Z'): + dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) + elif '+00:00' in utc_iso_string: + dt_utc = datetime.fromisoformat(utc_iso_string) + else: + # Assume UTC if no timezone info + dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) + + # Convert to local timezone + local_tz = _get_local_timezone() + dt_local = dt_utc.astimezone(local_tz) + + return dt_local.isoformat(timespec='seconds') + except Exception: + # If conversion fails, return original + return utc_iso_string \ No newline at end of file diff --git a/core/nfo_manager.py b/core/nfo_manager.py new file mode 100644 index 0000000..5113157 --- /dev/null +++ b/core/nfo_manager.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +""" +NFO Manager for creating and managing metadata files +Handles NFO creation for movies, TV shows, seasons, and episodes +""" +import os +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict, Any +import re + + +class NFOManager: + """Manages NFO file creation and updates""" + + def __init__(self, manager_brand: str = "NFOGuard", 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: + tree = ET.parse(nfo_path) + root = tree.getroot() + + # Check for ttXXXXXX + imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]') + if imdb_uniqueid is not None and imdb_uniqueid.text: + imdb_id = imdb_uniqueid.text.strip() + if imdb_id.startswith('tt'): + return imdb_id + + # Check for legacy ttXXXXXX + imdbid_elem = root.find('.//imdbid') + if imdbid_elem is not None and imdbid_elem.text: + imdb_id = imdbid_elem.text.strip() + if imdb_id.startswith('tt'): + return imdb_id + + # Check for legacy ttXXXXXX + imdb_elem = root.find('.//imdb') + if imdb_elem is not None and imdb_elem.text: + imdb_id = imdb_elem.text.strip() + if imdb_id.startswith('tt'): + return imdb_id + + except (ET.ParseError, Exception): + # Skip corrupted or non-XML files + pass + + return None + + def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]: + """Find IMDb ID from directory name, filenames, or NFO file""" + # First try directory name + imdb_id = self.parse_imdb_from_path(movie_dir) + if imdb_id: + return imdb_id + + # Try all files in the directory for IMDb ID patterns + for file_path in movie_dir.iterdir(): + if file_path.is_file(): + imdb_id = self.parse_imdb_from_path(file_path) + if imdb_id: + return imdb_id + + # Finally, try NFO file content + nfo_path = movie_dir / "movie.nfo" + imdb_id = self.parse_imdb_from_nfo(nfo_path) + if imdb_id: + print(f"🔍 Found IMDb ID in NFO file: {imdb_id} from {nfo_path}") + return imdb_id + + return None + + def _parse_nfo_with_tolerance(self, nfo_path: Path): + """Parse NFO file with tolerance for URLs appended after XML""" + try: + # First try normal parsing + tree = ET.parse(nfo_path) + return tree.getroot() + except ET.ParseError as e: + # If parsing fails, try to extract just the XML part + try: + with open(nfo_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find the last tag and truncate after it + last_movie_end = content.rfind('') + if last_movie_end != -1: + xml_content = content[:last_movie_end + 8] # +8 for + + # Try parsing the truncated content + root = ET.fromstring(xml_content) + print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}") + return root + else: + # Re-raise original error if we can't find + raise e + except Exception: + # Re-raise original error if our fix attempt fails + raise e + + def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str, + released: Optional[str] = None, source: str = "unknown", + lock_metadata: bool = True) -> None: + """Create or update movie.nfo file preserving existing content""" + nfo_path = movie_dir / "movie.nfo" + + try: + # Try to load existing NFO file + if nfo_path.exists(): + try: + # Try to parse the XML, handling URLs appended after + movie = self._parse_nfo_with_tolerance(nfo_path) + + # Ensure root element is + if movie.tag != "movie": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements to avoid duplicates + # These will be re-added at the very bottom + nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"] + for tag in nfoguard_fields: + existing = movie.find(tag) + if existing is not None: + # Store the value before removing (for premiered/year) + if tag == "premiered" and not released: + released = existing.text # Preserve existing premiered date + movie.remove(existing) + + # Remove ALL existing uniqueid with type="imdb" regardless of attributes + # We'll add a clean one at the bottom + for uniqueid in movie.findall("uniqueid[@type='imdb']"): + movie.remove(uniqueid) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean NFO file to replace corrupted one") + movie = ET.Element("movie") + else: + # Create new NFO structure + movie = ET.Element("movie") + + # Now append ALL NFOGuard and date fields at the VERY END of the file + # This ensures they appear after all existing content including actors + + # Add IMDb uniqueid at the end (after all existing content) + uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true") + uniqueid.text = imdb_id + + # Add premiered date at the bottom if we have it + if released: + premiered_elem = ET.SubElement(movie, "premiered") + premiered_elem.text = released[:10] if len(released) >= 10 else released + + # Extract year from premiered date for consistency + try: + year_value = released[:4] if len(released) >= 4 else None + if year_value and year_value.isdigit(): + year_elem = ET.SubElement(movie, "year") + year_elem.text = year_value + except: + pass # Skip year if we can't extract it + + # Add dateadded at the end + if dateadded: + dateadded_elem = ET.SubElement(movie, "dateadded") + dateadded_elem.text = dateadded + + # Add lockdata at the very end + if lock_metadata: + lockdata = ET.SubElement(movie, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} - Source: {source} " + + # Write file with proper formatting + tree = ET.ElementTree(movie) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + import xml.etree.ElementTree as ET_temp + xml_str = ET.tostring(movie, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated movie NFO: {nfo_path}") + print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}") + + except Exception as e: + print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}") + + def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None: + """Create or update tvshow.nfo file preserving existing content""" + nfo_path = series_dir / "tvshow.nfo" + + try: + # Try to load existing NFO file + if nfo_path.exists(): + try: + tree = ET.parse(nfo_path) + tvshow = tree.getroot() + + # Ensure root element is + if tvshow.tag != "tvshow": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements to avoid duplicates + # These will be re-added at the bottom + for tag in ["lockdata"]: + existing = tvshow.find(tag) + if existing is not None: + tvshow.remove(existing) + + # Remove ALL existing uniqueid with type="imdb" regardless of attributes + for uniqueid in tvshow.findall("uniqueid[@type='imdb']"): + tvshow.remove(uniqueid) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean tvshow.nfo file to replace corrupted one") + tvshow = ET.Element("tvshow") + else: + # Create new NFO structure + tvshow = ET.Element("tvshow") + + # Add NFOGuard fields at the bottom + + # Add IMDb uniqueid at the end + imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true") + imdb_uniqueid.text = imdb_id + + # Add TVDB ID if available (preserve existing or add new) + if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"): + tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb") + tvdb_uniqueid.text = tvdb_id + + # Add lockdata at the very end + lockdata = ET.SubElement(tvshow, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} " + + # Write file with proper formatting + tree = ET.ElementTree(tvshow) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + xml_str = ET.tostring(tvshow, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated TV show NFO: {nfo_path}") + print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else "")) + + except Exception as e: + print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}") + + def create_season_nfo(self, season_dir: Path, season_number: int) -> None: + """Create or update season.nfo file preserving existing content""" + nfo_path = season_dir / "season.nfo" + + try: + season_dir.mkdir(exist_ok=True) + + # Try to load existing NFO file + if nfo_path.exists(): + try: + tree = ET.parse(nfo_path) + season = tree.getroot() + + # Ensure root element is + if season.tag != "season": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements + for tag in ["seasonnumber", "lockdata"]: + existing = season.find(tag) + if existing is not None: + season.remove(existing) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean season.nfo file to replace corrupted one") + season = ET.Element("season") + else: + # Create new NFO structure + season = ET.Element("season") + + # Add NFOGuard fields at the bottom + seasonnumber = ET.SubElement(season, "seasonnumber") + seasonnumber.text = str(season_number) + + # Add lockdata at the end + lockdata = ET.SubElement(season, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} " + + # Write file with proper formatting + tree = ET.ElementTree(season) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + xml_str = ET.tostring(season, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated season NFO: {nfo_path}") + print(f" Season: {season_number}") + + except Exception as e: + print(f"❌ Error creating/updating season NFO {nfo_path}: {e}") + + def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]: + """Find existing episode NFO file that matches season/episode but isn't standardized name""" + if not season_dir.exists(): + return None + + # Standard filename pattern we're looking to create + standard_pattern = f"S{season_num:02d}E{episode_num:02d}.nfo" + + # Look for NFO files in the season directory + for nfo_file in season_dir.glob("*.nfo"): + # Skip if it's already the standard format + if nfo_file.name == standard_pattern: + continue + + # Check if this NFO contains the right season/episode + try: + tree = ET.parse(nfo_file) + root = tree.getroot() + + if root.tag == "episodedetails": + # Check for season/episode elements + season_elem = root.find("season") + episode_elem = root.find("episode") + + if (season_elem is not None and episode_elem is not None and + season_elem.text and episode_elem.text): + try: + file_season = int(season_elem.text) + file_episode = int(episode_elem.text) + + if file_season == season_num and file_episode == episode_num: + print(f"🔍 Found existing episode NFO: {nfo_file.name} -> will migrate to {standard_pattern}") + return nfo_file + except ValueError: + continue + + except (ET.ParseError, Exception): + # Skip corrupted or non-XML files + continue + + return None + + def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int, + aired: Optional[str], dateadded: Optional[str], source: str, + lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None: + """Create or update episode NFO file preserving existing content""" + # Generate episode filename pattern + episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" + nfo_path = season_dir / episode_filename + + # Track if we need to delete an old long-named NFO file + old_nfo_to_delete = None + + try: + # First, check for existing long-named NFO files that need migration + existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num) + + # Prioritize long-named file for migration, otherwise use standard file + source_nfo_path = existing_long_nfo if existing_long_nfo else 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 + if episode.tag != "episodedetails": + raise ValueError("Root element is not ") + + # If we're migrating from a long-named file, mark it for deletion + if existing_long_nfo and source_nfo_path == existing_long_nfo: + old_nfo_to_delete = existing_long_nfo + print(f"📦 Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}") + + # Show what content fields are being preserved + content_fields = ["title", "plot", "runtime", "premiered"] + preserved_content = [] + for field in content_fields: + elem = episode.find(field) + if elem is not None and elem.text: + preserved_content.append(field) + if preserved_content: + print(f" 📄 Preserving content: {', '.join(preserved_content)}") + + # 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" ⚠️ No content fields found after cleanup!") + + 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") + aired_elem.text = aired[:10] if len(aired) >= 10 else aired + + if dateadded: + dateadded_elem = ET.SubElement(episode, "dateadded") + dateadded_elem.text = dateadded + + # Add lockdata at the very end + if lock_metadata: + lockdata = ET.SubElement(episode, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} - Source: {source} " + + # Write file with proper formatting + tree = ET.ElementTree(episode) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + xml_str = ET.tostring(episode, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated episode NFO: {nfo_path}") + print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") + + # Clean up old long-named NFO file if we migrated from it + if old_nfo_to_delete and old_nfo_to_delete.exists(): + try: + old_nfo_to_delete.unlink() + print(f"🗑️ Cleaned up old NFO file: {old_nfo_to_delete.name}") + except Exception as cleanup_error: + print(f"⚠️ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}") + + except Exception as e: + print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}") + + def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None: + """Set file modification time to match import date""" + try: + # Parse ISO timestamp + if iso_timestamp.endswith('Z'): + dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00')) + elif '+' in iso_timestamp or 'T' in iso_timestamp: + dt = datetime.fromisoformat(iso_timestamp) + else: + # Assume it's already a simple date + dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00') + + # Convert to timestamp + timestamp = dt.timestamp() + + # Set both access and modification times + os.utime(file_path, (timestamp, timestamp)) + print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}") + + except Exception as e: + print(f"❌ Error setting mtime for {file_path}: {e}") + + def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None: + """Update modification times for all video files in movie directory""" + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + updated_files = [] + + for file_path in movie_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in video_exts: + self.set_file_mtime(file_path, iso_timestamp) + updated_files.append(file_path.name) + + if updated_files: + print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}") + else: + print(f"⚠️ No video files found to update in {movie_dir.name}") \ No newline at end of file diff --git a/core/path_mapper.py b/core/path_mapper.py new file mode 100644 index 0000000..59d5805 --- /dev/null +++ b/core/path_mapper.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Path mapping utilities for NFOGuard +Handles conversion between external service paths and container paths +""" +import os +import re +from pathlib import Path + +class PathMapper: + """Handles path mapping between different environments""" + + def __init__(self, config): + """Initialize path mapper with configuration""" + # Use environment variables directly since config attribute names are unclear + import os + + radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '') + sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '') + movie_paths_str = os.getenv('MOVIE_PATHS', '') + tv_paths_str = os.getenv('TV_PATHS', '') + + self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()] + self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()] + self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()] + self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()] + + # Check if path debugging is enabled + self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true' + + if self.path_debug: + print(f"PATH_DEBUG: PathMapper initialized with:") + print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}") + print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}") + print(f"PATH_DEBUG: movie_paths: {self.movie_paths}") + print(f"PATH_DEBUG: tv_paths: {self.tv_paths}") + + def sonarr_path_to_container_path(self, sonarr_path: str) -> str: + """Convert Sonarr path to container path using environment mappings""" + if self.path_debug: + print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}") + print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}") + print(f"PATH_DEBUG: tv_paths: {self.tv_paths}") + + # Sort roots by length (longest first) to avoid substring matching issues + indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)] + indexed_roots.sort(key=lambda x: len(x[1]), reverse=True) + + # Try to match against configured Sonarr root folders (longest first) + for original_index, sonarr_root in indexed_roots: + if self.path_debug: + print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}") + if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root: + if self.path_debug: + print(f"PATH_DEBUG: Match found! Index {original_index}") + # Map to corresponding TV path + if original_index < len(self.tv_paths): + container_root = self.tv_paths[original_index] + relative_path = sonarr_path[len(sonarr_root):].lstrip('/') + result = str(Path(container_root) / relative_path) if relative_path else container_root + if self.path_debug: + print(f"PATH_DEBUG: Mapped to: {result}") + return result + + if self.path_debug: + print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}") + # No fallback - if path mapping fails, return original and let validation catch it + return sonarr_path + + def radarr_path_to_container_path(self, radarr_path: str) -> str: + """Convert Radarr path to container path using environment mappings""" + if self.path_debug: + print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}") + print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}") + print(f"PATH_DEBUG: movie_paths: {self.movie_paths}") + + # Sort roots by length (longest first) to avoid substring matching issues + indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)] + indexed_roots.sort(key=lambda x: len(x[1]), reverse=True) + + # Try to match against configured Radarr root folders (longest first) + for original_index, radarr_root in indexed_roots: + if self.path_debug: + print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}") + if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root: + if self.path_debug: + print(f"PATH_DEBUG: Match found! Index {original_index}") + # Map to corresponding movie path + if original_index < len(self.movie_paths): + container_root = self.movie_paths[original_index] + relative_path = radarr_path[len(radarr_root):].lstrip('/') + result = str(Path(container_root) / relative_path) if relative_path else container_root + if self.path_debug: + print(f"PATH_DEBUG: Mapped to: {result}") + return result + + if self.path_debug: + print(f"PATH_DEBUG: No match found, returning original: {radarr_path}") + # No fallback - if path mapping fails, return original and let validation catch it + return radarr_path + + def container_path_to_host_path(self, container_path: str) -> str: + """Convert container path back to host path if needed""" + # This might be needed for file operations + return container_path \ No newline at end of file diff --git a/docker-compose.example b/docker-compose.example new file mode 100644 index 0000000..5f760b2 --- /dev/null +++ b/docker-compose.example @@ -0,0 +1,165 @@ +version: '3.8' + +services: + nfoguard: + # Use the official image from Docker Hub + image: sbcrumb/nfoguard:latest + + # Alternative: Use specific version + # image: sbcrumb/nfoguard:v1.5.5 + + # Alternative: Use development version + # image: sbcrumb/nfoguard:dev + + container_name: nfoguard + + # Restart policy + restart: unless-stopped + + # Environment files + env_file: + - .env + - .env.secrets + + # Environment variables + environment: + - TZ=America/New_York # Set to your local timezone + # Alternative examples: + # - TZ=Europe/London + # - TZ=America/Los_Angeles + # - TZ=Australia/Sydney + # - TZ=UTC + + # Port mapping (adjust if needed) + ports: + - "8080:8080" + + # Volume mounts - CRITICAL: These must match your media setup + volumes: + # NFOGuard data directory (database, logs, cache) + - ./data:/app/data + + # Bind mount your Emby plugins directory for automatic plugin deployment + - ${EMBY_PLUGINS_PATH}:/emby-plugins + + # Movie libraries - Mount your movie directories here + # Format: host_path:container_path:rw + - /path/to/your/movies:/media/Movies/movies:rw + - /path/to/your/movies2:/media/Movies/movies6:rw + + # TV libraries - Mount your TV show directories here + - /path/to/your/tv:/media/TV/tv:rw + - /path/to/your/tv2:/media/TV/tv6:rw + + # Optional: Mount download directories for detection + # - /path/to/downloads:/downloads:ro + + # Health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Resource limits (optional) + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + + # Network (optional - use if you have a custom network) + # networks: + # - media-network + + # Logging configuration + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +# Optional: Custom network for media services +# networks: +# media-network: +# external: true + +# =========================================== +# SETUP INSTRUCTIONS +# =========================================== +# 1. Copy this file to docker-compose.yml +# 2. Update volume mounts to match your actual media paths +# 3. Copy .env.template to .env and configure +# 4. Copy .env.secrets.template to .env.secrets and add credentials +# 5. Create data directory: mkdir -p ./data +# 6. Run: docker-compose up -d +# 7. Check logs: docker-compose logs -f nfoguard +# 8. Access health check: curl http://localhost:8080/health + +# =========================================== +# VOLUME MAPPING EXAMPLES +# =========================================== +# Common setups: +# +# Unraid: +# - /mnt/user/Media/Movies:/media/Movies/movies:rw +# - /mnt/user/Media/TV:/media/TV/tv:rw +# +# Synology: +# - /volume1/Media/Movies:/media/Movies/movies:rw +# - /volume1/Media/TV:/media/TV/tv:rw +# +# Standard Linux: +# - /home/user/media/movies:/media/Movies/movies:rw +# - /home/user/media/tv:/media/TV/tv:rw +# +# Windows (Docker Desktop): +# - C:\Media\Movies:/media/Movies/movies:rw +# - C:\Media\TV:/media/TV/tv:rw + +# =========================================== +# WEBHOOK CONFIGURATION +# =========================================== +# Configure these webhook URLs in your *arr applications: +# +# Radarr webhook URL: +# http://nfoguard:8080/webhook/radarr +# (or http://localhost:8080/webhook/radarr if not using Docker network) +# +# Sonarr webhook URL: +# http://nfoguard:8080/webhook/sonarr +# (or http://localhost:8080/webhook/sonarr if not using Docker network) +# +# Webhook triggers: +# - On Import +# - On Upgrade +# - On Rename (recommended) + +# =========================================== +# TROUBLESHOOTING +# =========================================== +# Common issues and solutions: +# +# 1. Permission denied errors: +# - Ensure NFOGuard container can write to mounted directories +# - Check file ownership and permissions +# - Consider using PUID/PGID environment variables +# +# 2. Path mapping issues: +# - Verify container paths match .env configuration +# - Ensure *arr applications can see the same files +# - Check RADARR_ROOT_FOLDERS and SONARR_ROOT_FOLDERS in .env +# +# 3. Database connection issues: +# - Verify PostgreSQL credentials in .env.secrets +# - Check network connectivity to database +# - Ensure database exists and user has permissions +# +# 4. Webhook not triggering: +# - Verify webhook URLs in *arr applications +# - Check NFOGuard logs for incoming requests +# - Ensure port 8080 is accessible diff --git a/nfoguard.py b/nfoguard.py new file mode 100644 index 0000000..2a79fbd --- /dev/null +++ b/nfoguard.py @@ -0,0 +1,2241 @@ +#!/usr/bin/env python3 +""" +NFOGuard - Automated NFO file management for Radarr and Sonarr +Modular architecture with webhook processing and intelligent date handling +""" +import os +import json +import asyncio +import glob +import re +import logging +import logging.handlers +from pathlib import Path +from datetime import datetime, timezone, timedelta +from zoneinfo import ZoneInfo +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request +from pydantic import BaseModel +from typing import Optional, Dict, Any, List, Set, Tuple +import threading +from concurrent.futures import ThreadPoolExecutor +import uvicorn +import signal +import sys + +# Import our new modular components +from core.database import NFOGuardDatabase +from core.nfo_manager import NFOManager +from core.path_mapper import PathMapper +from clients.radarr_client import RadarrClient +from clients.sonarr_client import SonarrClient +from clients.external_clients import ExternalClientManager + +# --------------------------- +# Configuration & Logging +# --------------------------- + +class TimezoneAwareFormatter(logging.Formatter): + """Formatter that respects the container timezone""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.timezone = self._get_local_timezone() + + def _get_local_timezone(self): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + + def formatTime(self, record, datefmt=None): + dt = datetime.fromtimestamp(record.created, tz=self.timezone) + if datefmt: + return dt.strftime(datefmt) + return dt.isoformat(timespec='seconds') + +def _setup_file_logging(): + """Setup file logging for NFOGuard""" + log_dir = Path(os.environ.get("LOG_DIR", "/app/data/logs")) + log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("NFOGuard") + logger.setLevel(logging.DEBUG) + + file_handler = logging.handlers.RotatingFileHandler( + log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3 + ) + + formatter = TimezoneAwareFormatter( + '[%(asctime)s] %(levelname)s: %(message)s' + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + return logger + +def _mask_sensitive_data(msg: str) -> str: + """Mask API keys and other sensitive data in log messages""" + import re + + # List of patterns to mask + sensitive_patterns = [ + (r'api_key=([a-zA-Z0-9_\-]+)', r'api_key=***masked***'), + (r'password=([^\s&]+)', r'password=***masked***'), + (r'token=([a-zA-Z0-9_\-]+)', r'token=***masked***'), + (r'key=([a-zA-Z0-9_\-]{8,})', r'key=***masked***'), # Keys longer than 8 chars + (r'([a-zA-Z0-9]{32,})', lambda m: m.group(1)[:8] + '***masked***' if len(m.group(1)) > 16 else m.group(1)) # Long strings likely to be keys + ] + + masked_msg = msg + for pattern, replacement in sensitive_patterns: + if isinstance(replacement, str): + masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE) + else: + masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE) + + return masked_msg + +def _get_local_timezone(): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + +def _log(level: str, msg: str): + """Enhanced logging that writes to both console and file with sensitive data masking""" + masked_msg = _mask_sensitive_data(msg) + tz = _get_local_timezone() + print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {masked_msg}") + + try: + file_logger = _setup_file_logging() + getattr(file_logger, level.lower(), file_logger.info)(masked_msg) + except Exception as e: + print(f"File logging error: {e}") + +def convert_utc_to_local(utc_iso_string: str) -> str: + """Convert UTC ISO timestamp to local timezone timestamp""" + if not utc_iso_string: + return utc_iso_string + + try: + # Parse UTC timestamp + if utc_iso_string.endswith('Z'): + dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) + elif '+00:00' in utc_iso_string: + dt_utc = datetime.fromisoformat(utc_iso_string) + else: + # Assume UTC if no timezone info + dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) + + # Convert to local timezone + local_tz = _get_local_timezone() + dt_local = dt_utc.astimezone(local_tz) + + return dt_local.isoformat(timespec='seconds') + except Exception: + # If conversion fails, return original + return utc_iso_string + +# Initialize logging +_setup_file_logging() + +def _load_environment_files(): + """Load environment variables from .env and optionally .env.secrets""" + from pathlib import Path + + # Try to load from python-dotenv if available + try: + from dotenv import load_dotenv + + # Load main .env file + env_file = Path(".env") + if env_file.exists(): + load_dotenv(env_file) + _log("INFO", f"Loaded environment from {env_file}") + + # Load secrets file if it exists + secrets_file = Path(".env.secrets") + if secrets_file.exists(): + load_dotenv(secrets_file) + _log("INFO", f"Loaded secrets from {secrets_file}") + + except ImportError: + _log("WARNING", "python-dotenv not available - environment files not loaded") + +# Load environment files at startup +_load_environment_files() + +# Add debug logging near where configuration is loaded +print(f"DEBUG: Environment check - SONARR_ROOT_FOLDERS: {os.getenv('SONARR_ROOT_FOLDERS')}") +print(f"DEBUG: Environment check - TV_PATHS: {os.getenv('TV_PATHS')}") + +def _bool_env(name: str, default: bool) -> bool: + v = os.environ.get(name) + if v is None: + return default + return v.lower() in ("1", "true", "yes", "y", "on") + +# --------------------------- +# Configuration +# --------------------------- + +class NFOGuardConfig: + def __init__(self): + # Paths - No hardcoded defaults, must be configured via environment + tv_paths_env = os.environ.get("TV_PATHS", "") + movie_paths_env = os.environ.get("MOVIE_PATHS", "") + + if not tv_paths_env: + raise ValueError("TV_PATHS environment variable is required but not set") + if not movie_paths_env: + raise ValueError("MOVIE_PATHS environment variable is required but not set") + + self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()] + self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()] + + # Core settings + self.manage_nfo = _bool_env("MANAGE_NFO", True) + self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True) + self.lock_metadata = _bool_env("LOCK_METADATA", True) + self.debug = _bool_env("DEBUG", False) + self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard") + + # Batching + self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0")) + self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3")) + + # Database + self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db")) + + # Movie processing + self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower() + self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True) + self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False) + self.release_date_priority = [p.strip() for p in os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical").split(",")] + self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True) + self.max_release_date_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) + self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower() + self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower() + + # TV processing + self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}") + self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower() + self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower() + +config = NFOGuardConfig() + +# --------------------------- +# Models +# --------------------------- + +class SonarrWebhook(BaseModel): + eventType: str + series: Optional[Dict[str, Any]] = None + episodes: Optional[list] = [] + episodeFile: Optional[Dict[str, Any]] = None + isUpgrade: Optional[bool] = False + + class Config: + extra = "allow" + +class RadarrWebhook(BaseModel): + eventType: str + movie: Optional[Dict[str, Any]] = None + movieFile: Optional[Dict[str, Any]] = None + isUpgrade: Optional[bool] = False + deletedFiles: Optional[list] = [] + remoteMovie: Optional[Dict[str, Any]] = None + renamedMovieFiles: Optional[List[Dict[str, Any]]] = None + + class Config: + extra = "allow" + +class HealthResponse(BaseModel): + status: str + version: str + uptime: str + database_status: str + radarr_database: Optional[Dict[str, Any]] = None + +class TVSeasonRequest(BaseModel): + series_path: str + season_name: str + +class TVEpisodeRequest(BaseModel): + series_path: str + season_name: str + episode_name: str + +# --------------------------- +# Core Processing +# --------------------------- + +class TVProcessor: + """Handles TV series processing""" + + def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): + self.db = db + self.nfo_manager = nfo_manager + self.path_mapper = path_mapper + self.sonarr = SonarrClient( + os.environ.get("SONARR_URL", ""), + os.environ.get("SONARR_API_KEY", "") + ) + self.external_clients = ExternalClientManager() + + def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]: + """Find series directory path""" + # Try webhook path first + if sonarr_path: + container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path) + path_obj = Path(container_path) + if path_obj.exists(): + return path_obj + + # Search by IMDb ID or title + for media_path in config.tv_paths: + if not media_path.exists(): + continue + + # Search by IMDb ID + if imdb_id: + pattern = str(media_path / f"*[imdb-{imdb_id}]*") + matches = glob.glob(pattern) + if matches: + return Path(matches[0]) + + # Search by title + if series_title: + title_clean = series_title.lower().replace(" ", "").replace("-", "") + for item in media_path.iterdir(): + if item.is_dir() and "[imdb-" in item.name.lower(): + item_clean = item.name.lower().replace(" ", "").replace("-", "") + if title_clean in item_clean: + return item + + return None + + def process_series(self, series_path: Path) -> None: + """Process a TV series directory""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + _log("INFO", f"Processing TV series: {series_path.name}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Find video files + disk_episodes = self._find_disk_episodes(series_path) + _log("INFO", f"Found {len(disk_episodes)} episodes on disk") + + # Get episode dates + episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes) + + # Process episodes + for (season, episode), (aired, dateadded, source) in episode_dates.items(): + if (season, episode) in disk_episodes: + # Create NFO + if config.manage_nfo: + season_dir = series_path / config.tv_season_dir_format.format(season=season) + self.nfo_manager.create_episode_nfo( + season_dir, + season, episode, aired, dateadded, source, config.lock_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + video_files = disk_episodes[(season, episode)] + for video_file in video_files: + self.nfo_manager.set_file_mtime(video_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + + # Create season/tvshow NFOs + if config.manage_nfo: + seasons_processed = set() + for (season, episode) in disk_episodes.keys(): + if season not in seasons_processed: + season_dir = series_path / config.tv_season_dir_format.format(season=season) + self.nfo_manager.create_season_nfo(season_dir, season) + seasons_processed.add(season) + + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed processing TV series: {series_path.name}") + + def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]: + """Find all episode video files on disk""" + disk_episodes = {} + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + + for season_dir in series_path.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)): + continue + + try: + # Extract season number from directory name + # Handle formats like "Season 01", "S01", "Season01", etc. + dir_name = season_dir.name.lower() + if config.tv_season_dir_pattern in dir_name: + # Extract everything after the pattern + season_part = dir_name[len(config.tv_season_dir_pattern):].strip() + else: + continue + season_num = int(season_part) + except (ValueError, IndexError): + continue + + for video_file in season_dir.iterdir(): + if video_file.is_file() and video_file.suffix.lower() in video_exts: + match = re.search(r"S(\d{2})E(\d{2})", video_file.name, re.IGNORECASE) + if match: + file_season, file_episode = int(match.group(1)), int(match.group(2)) + key = (season_num, file_episode) # Use directory season number + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(video_file) + + return disk_episodes + + def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict) -> Dict: + """Gather episode dates from various sources""" + episode_dates = {} + + # Check cache first + cached_episodes = self.db.get_series_episodes(imdb_id, has_video_file_only=True) + for ep in cached_episodes: + key = (ep["season"], ep["episode"]) + if key in disk_episodes: # Only use cached data for episodes we have + episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"]) + + # Find missing episodes + cached_keys = set(episode_dates.keys()) + missing_keys = set(disk_episodes.keys()) - cached_keys + + if not missing_keys: + _log("INFO", "All episodes found in cache") + return episode_dates + + _log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes") + + # Query Sonarr for missing episodes + if self.sonarr.enabled: + series = self.sonarr.series_by_imdb(imdb_id) + if series: + series_id = series.get("id") + if series_id: + episodes = self.sonarr.episodes_for_series(series_id) + for ep in episodes: + season_num = ep.get("seasonNumber") + episode_num = ep.get("episodeNumber") + + if not isinstance(season_num, int) or not isinstance(episode_num, int): + continue + + key = (season_num, episode_num) + if key not in missing_keys: + continue + + # Get dates + aired = self._parse_date_to_iso(ep.get("airDateUtc")) + dateadded = None + source = "sonarr:episode.airDateUtc" + + # Try to get import history + episode_id = ep.get("id") + if episode_id: + import_date = self.sonarr.get_episode_import_history(episode_id) + if import_date: + dateadded = self._parse_date_to_iso(import_date) + source = "sonarr:history.import" + + if not dateadded: + dateadded = aired + + if aired or dateadded: + episode_dates[key] = (aired, dateadded, source) + + # Fill remaining gaps with external APIs + remaining_keys = missing_keys - set(episode_dates.keys()) + if remaining_keys and self.external_clients.tmdb.enabled: + tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id) + if tmdb_movie: + tv_id = tmdb_movie.get("id") + if tv_id: + seasons_needed = set(season for season, episode in remaining_keys) + for season_num in seasons_needed: + tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num) + for ep_num, air_date in tmdb_episodes.items(): + key = (season_num, ep_num) + if key in remaining_keys: + aired = self._parse_date_to_iso(air_date) + episode_dates[key] = (aired, aired, "tmdb:air_date") + + return episode_dates + + def _parse_date_to_iso(self, date_str: str) -> Optional[str]: + """Parse date string to ISO format""" + if not date_str: + return None + try: + if len(date_str) == 10 and date_str[4] == "-": + dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + + def process_season(self, series_path: Path, season_path: Path) -> None: + """Process a specific TV season directory""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + # Extract season number from path + season_match = re.search(r'season\s*(\d+)', season_path.name, re.IGNORECASE) + if not season_match: + _log("ERROR", f"Could not extract season number from: {season_path.name}") + return + + season_num = int(season_match.group(1)) + _log("INFO", f"Processing TV season {season_num}: {season_path}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Find video files in this season only + disk_episodes = self._find_season_episodes(season_path, season_num) + _log("INFO", f"Found {len(disk_episodes)} episodes in season {season_num}") + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + + # Get episode dates for this season + episode_dates = self._gather_season_episode_dates(series_path, imdb_id, season_num, disk_episodes, series_metadata) + + # Process episodes + for (season, episode), (aired, dateadded, source) in episode_dates.items(): + if (season, episode) in disk_episodes: + # Get enhanced episode metadata + enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_episode_nfo( + season_path, + season, episode, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + video_files = disk_episodes[(season, episode)] + for video_file in video_files: + self.nfo_manager.set_file_mtime(video_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + + # Create season NFO + if config.manage_nfo: + self.nfo_manager.create_season_nfo(season_path, season_num) + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed processing season {season_num}") + + def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None: + """Process a single TV episode file""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + # Parse episode info from filename + episode_info = self._parse_episode_from_filename(episode_file.name) + if not episode_info: + _log("ERROR", f"Could not parse episode info from: {episode_file.name}") + return + + season_num, episode_num = episode_info + _log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None + + # Get episode date + aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) + + # Create NFO + if config.manage_nfo and dateadded: + self.nfo_manager.create_episode_nfo( + season_path, + season_num, episode_num, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtime + if config.fix_dir_mtimes and dateadded: + self.nfo_manager.set_file_mtime(episode_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) + + _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}") + + def _find_season_episodes(self, season_path: Path, season_num: int) -> Dict[Tuple[int, int], List[Path]]: + """Find all episode video files in a single season""" + disk_episodes = {} + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".m2ts") + + for file_path in season_path.iterdir(): + if not file_path.is_file() or file_path.suffix.lower() not in video_exts: + continue + + episode_info = self._parse_episode_from_filename(file_path.name) + if episode_info and episode_info[0] == season_num: + season, episode = episode_info + key = (season, episode) + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(file_path) + + return disk_episodes + + def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]: + """Parse season and episode numbers from filename""" + # Try SxxExx format + match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE) + if match: + return int(match.group(1)), int(match.group(2)) + + # Try season.episode format + match = re.search(r'(\d{1,2})\.(\d{1,2})', filename) + if match: + return int(match.group(1)), int(match.group(2)) + + return None + + def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Get enhanced series metadata from Sonarr API""" + try: + if not self.sonarr.enabled: + return None + + series = self.sonarr.series_by_imdb(imdb_id) + if not series: + _log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}") + return None + + # Get episodes for the series + series_id = series.get("id") + if series_id: + episodes = self.sonarr.episodes_for_series(series_id) + series["episodes"] = episodes + _log("DEBUG", f"Got {len(episodes)} episodes from Sonarr for series {series.get('title')}") + + return series + + except Exception as e: + _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}") + return None + + def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]: + """Extract specific episode metadata from series data""" + if not series_metadata or "episodes" not in series_metadata: + return None + + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + return { + "title": episode.get("title"), + "overview": episode.get("overview"), + "runtime": episode.get("runtime"), + "ratings": episode.get("ratings", {}) + } + + return None + + def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: + """Get episode dates for a specific season""" + episode_dates = {} + + for (season, episode) in disk_episodes.keys(): + if season != season_num: + continue + + aired, dateadded, source = self._get_single_episode_date(imdb_id, season, episode, series_metadata) + episode_dates[(season, episode)] = (aired, dateadded, source) + + return episode_dates + + def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + """ + Get date info for a single episode during backfill scans. + Priority: 1) Database 2) Sonarr import history 3) Air dates + """ + # Step 1: Try database first + existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + if existing and existing.get("dateadded"): + _log("DEBUG", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}") + return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") + + # Step 2: Try Sonarr import history (the real import date) + aired = None + import_date = None + + if self.sonarr.enabled: + try: + series = self.sonarr.series_by_imdb(imdb_id) + if series: + episodes = self.sonarr.episodes_for_series(series["id"]) + for ep in episodes: + if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: + aired = ep.get("airDateUtc") + import_date = self.sonarr.get_episode_import_history(ep["id"]) + if import_date: + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {local_import_date}") + return aired, local_import_date, "sonarr:history.import" + break + except Exception as e: + _log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}") + + # Step 3: Try Sonarr metadata for air date + if not aired and series_metadata and "episodes" in series_metadata: + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + aired = episode.get("airDateUtc") + break + + # Step 4: Only if we have an air date but no import history, use air date as fallback + if aired: + _log("WARNING", f"No import history found for S{season_num:02d}E{episode_num:02d}, using air date as fallback") + return aired, aired, "sonarr:episode.airDateUtc" + + # Step 5: Last resort - current time in local timezone (shouldn't happen in normal operation) + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") + _log("WARNING", f"No data found for S{season_num:02d}E{episode_num:02d}, using current time") + return None, current_time, "fallback:current_time" + + def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None: + """Process only the specific episodes mentioned in a webhook (targeted mode)""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + if not webhook_episodes: + _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}") + self.process_series(series_path) + return + + _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + + episodes_processed = 0 + for webhook_episode in webhook_episodes: + season_num = webhook_episode.get("seasonNumber") + episode_num = webhook_episode.get("episodeNumber") + + if not season_num or not episode_num: + _log("WARNING", f"Invalid episode data in webhook: {webhook_episode}") + continue + + # Check if episode file exists on disk + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + if not season_dir.exists(): + _log("WARNING", f"Season directory not found: {season_dir}") + continue + + # Find matching episode files + episode_files = [] + for file_path in season_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'): + parsed = self._parse_episode_from_filename(file_path.name) + if parsed and parsed == (season_num, episode_num): + episode_files.append(file_path) + + if not episode_files: + _log("WARNING", f"No video files found for S{season_num:02d}E{episode_num:02d}") + continue + + # Get episode date information - webhook processing prioritizes existing DB entries + _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata) + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_episode_nfo( + season_dir, + season_num, episode_num, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + for episode_file in episode_files: + self.nfo_manager.set_file_mtime(episode_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) + + # Verify database entry was saved (debug) + verification = self.db.get_episode_date(imdb_id, season_num, episode_num) + if verification: + _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") + else: + _log("ERROR", f"Failed to save episode to database: S{season_num:02d}E{episode_num:02d}") + + episodes_processed += 1 + + # Create season/tvshow NFOs if any episodes were processed + if episodes_processed > 0 and config.manage_nfo: + seasons_processed = set() + for webhook_episode in webhook_episodes: + season_num = webhook_episode.get("seasonNumber") + if season_num and season_num not in seasons_processed: + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + if season_dir.exists(): + self.nfo_manager.create_season_nfo(season_dir, season_num) + seasons_processed.add(season_num) + + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") + + def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + """ + Get episode date for webhook processing with correct priority: + 1. Check existing NFOGuard database entry (preserve previous import dates) + 2. If not found, use current time (webhook = new download) + 3. Get aired date from Sonarr/TMDB for reference + """ + # Step 1: Check if we already have this episode in our database + _log("DEBUG", f"Checking database for existing episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + if existing: + _log("DEBUG", f"Found database entry: {existing}") + if existing.get("dateadded"): + _log("INFO", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']} (source: {existing.get('source', 'unknown')})") + return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") + else: + _log("DEBUG", f"Database entry found but no dateadded value: {existing}") + else: + _log("DEBUG", f"No database entry found for IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + + # Step 2: Webhook = Source of Truth - use current timestamp in local timezone + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") + _log("INFO", f"Webhook episode processing - using current timestamp as source of truth: {current_time}") + + # Step 3: Get aired date for reference (but don't use for dateadded) + aired = None + + # Try Sonarr metadata first for air date + if series_metadata and "episodes" in series_metadata: + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + aired = episode.get("airDateUtc") + if aired: + break + + # Fallback to Sonarr API for air date if not in metadata + if not aired and self.sonarr.enabled: + try: + series = self.sonarr.series_by_imdb(imdb_id) + if series: + episodes = self.sonarr.episodes_for_series(series["id"]) + for ep in episodes: + if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: + aired = ep.get("airDateUtc") + break + except Exception as e: + _log("DEBUG", f"Error getting air date from Sonarr: {e}") + + return aired, current_time, "webhook:first_seen" + + +class MovieProcessor: + """Handles movie processing""" + + def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): + self.db = db + self.nfo_manager = nfo_manager + self.path_mapper = path_mapper + self.radarr = RadarrClient( + os.environ.get("RADARR_URL", ""), + os.environ.get("RADARR_API_KEY", "") + ) + self.external_clients = ExternalClientManager() + + def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]: + """Find movie directory path""" + # Try webhook path first + if radarr_path: + container_path = self.path_mapper.radarr_path_to_container_path(radarr_path) + path_obj = Path(container_path) + if path_obj.exists(): + return path_obj + + # Search by IMDb ID or title + for media_path in config.movie_paths: + if not media_path.exists(): + continue + + # Search by IMDb ID + if imdb_id: + pattern = str(media_path / f"*[imdb-{imdb_id}]*") + matches = glob.glob(pattern) + if matches: + return Path(matches[0]) + + # Search by title + if movie_title: + title_clean = movie_title.lower().replace(" ", "").replace("-", "") + for item in media_path.iterdir(): + if item.is_dir() and "[imdb-" in item.name.lower(): + item_clean = item.name.lower().replace(" ", "").replace("-", "") + if title_clean in item_clean: + return item + + return None + + def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None: + """Process a movie directory""" + imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}") + return + + _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})") + + # Update database + self.db.upsert_movie(imdb_id, str(movie_path)) + + # Check for video files + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir()) + + if not has_video: + _log("WARNING", f"No video files found in: {movie_path}") + self.db.upsert_movie_dates(imdb_id, None, None, None, False) + return + + # Get existing dates + existing = self.db.get_movie_dates(imdb_id) + _log("DEBUG", f"Database lookup for {imdb_id}: {existing}") + + # Handle webhook mode - prioritize database, then use proper date logic + if webhook_mode: + _log("DEBUG", f"Webhook mode: existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else 'N/A'}") + if existing and existing.get("dateadded"): + _log("INFO", f"Webhook processing - using existing database entry: {existing['dateadded']} (source: {existing.get('source', 'unknown')})") + dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") + else: + if existing: + _log("INFO", f"Webhook processing - database entry exists but no dateadded field: {existing}") + else: + _log("INFO", f"Webhook processing - no database entry found for {imdb_id}") + _log("INFO", f"Using full date decision logic") + # Use same logic as manual scan to check Radarr import dates, release dates, etc. + should_query = True # Always query for webhooks when no database entry exists + dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) + + # Only if ALL date sources fail, fall back to current timestamp + if dateadded is None: + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") + _log("INFO", f"Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}") + dateadded, source = current_time, "webhook:fallback_timestamp" + else: + # Manual scan mode - determine if we should query APIs + should_query = ( + config.movie_poll_mode == "always" or + (config.movie_poll_mode == "if_missing" and not existing) or + (config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime") + ) + + # Use existing movie date decision logic + dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) + + # Create NFO regardless of date availability (preserves existing metadata) + if config.manage_nfo: + self.nfo_manager.create_movie_nfo( + movie_path, imdb_id, dateadded, released, source, config.lock_metadata + ) + + # Skip remaining processing if no valid date found and file dates disabled + if dateadded is None: + _log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed") + self.db.upsert_movie_dates(imdb_id, released, None, source, True) + return + + _log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}") + + # Update file mtimes (only if we have a valid date) + if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED": + self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) + + _log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}") + + # Save to database + _log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}") + try: + self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True) + _log("DEBUG", f"Database save completed for {imdb_id}") + except Exception as e: + _log("ERROR", f"Database save failed for {imdb_id}: {e}") + raise + + _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") + + def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]: + """Decide movie dates based on configuration and available data""" + if not should_query and existing: + return existing["dateadded"], existing["source"], existing.get("released") + + # Query Radarr for movie info + radarr_movie = None + if should_query and self.radarr.api_key: + radarr_movie = self.radarr.movie_by_imdb(imdb_id) + + released = None + if radarr_movie: + released = self._parse_date_to_iso(radarr_movie.get("inCinemas")) + + # Try import history first if configured + if config.movie_priority == "import_then_digital": + import_date, import_source = None, None + if radarr_movie: + movie_id = radarr_movie.get("id") + if movie_id: + import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) + _log("INFO", f"Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}") + + # Check for special case: rename-first scenario (should prefer release dates) + if import_source == "radarr:db.prefer_release_dates": + _log("INFO", f"🎯 Movie {imdb_id} has rename-first history - skipping import, preferring release dates") + # Fall through to release date logic below + # Check if we got a real import date or just file date fallback + elif import_date and import_source != "radarr:db.file.dateAdded": + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + return local_import_date, import_source, released + + # Get digital release date for comparison/fallback + _log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date fallback...") + digital_date, digital_source = self._get_digital_release_date(imdb_id) + _log("INFO", f"Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}") + + # If we only have file date and release date exists, prefer it if reasonable and enabled + if import_date and import_source == "radarr:db.file.dateAdded" and digital_date and config.prefer_release_dates_over_file_dates: + # Compare dates - prefer release date if it's reasonable + if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id): + _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") + return digital_date, digital_source, released + else: + # Convert file date to local timezone for NFO files + local_file_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable") + return local_file_date, import_source, released + + # Use whichever we have + if import_date: + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + return local_import_date, import_source, released + elif digital_date: + _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") + return digital_date, digital_source, released + else: + _log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks") + + # Try Radarr's own NFO premiered date as fallback + radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path) + if radarr_premiered: + _log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}") + return radarr_premiered, "radarr:nfo.premiered", released + + else: # digital_then_import + # Try digital release first + digital_date, digital_source = self._get_digital_release_date(imdb_id) + if digital_date: + return digital_date, digital_source, released + + # Fall back to import history + if radarr_movie: + movie_id = radarr_movie.get("id") + if movie_id: + import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) + if import_date: + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + return local_import_date, import_source, released + + # Last resort: file mtime (if allowed) + if config.allow_file_date_fallback: + return self._get_file_mtime_date(movie_path) + else: + _log("INFO", f"No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation") + return None, "no_valid_date_source", None + + def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]: + """Get release date from external sources using configured priority""" + _log("INFO", f"🔍 Calling external clients for {imdb_id}") + _log("INFO", f"Release date priority: {config.release_date_priority}") + _log("INFO", f"Smart validation enabled: {config.enable_smart_date_validation}") + + try: + release_result = self.external_clients.get_release_date_by_priority( + imdb_id, + config.release_date_priority, + enable_smart_validation=config.enable_smart_date_validation + ) + _log("INFO", f"External clients result for {imdb_id}: {release_result}") + + if release_result: + _log("INFO", f"✅ Got release date: {release_result[0]} from {release_result[1]}") + return release_result[0], release_result[1] + else: + _log("WARNING", f"❌ No release date found from external clients for {imdb_id}") + return None, "release:none" + except Exception as e: + _log("ERROR", f"External clients error for {imdb_id}: {e}") + return None, f"release:error:{str(e)}" + + def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]: + """Extract premiered date from Radarr's existing movie.nfo file""" + try: + nfo_path = movie_path / "movie.nfo" + if not nfo_path.exists(): + _log("DEBUG", f"No existing NFO file found at {nfo_path}") + return None + + nfo_content = nfo_path.read_text(encoding='utf-8') + + # Look for YYYY-MM-DD + import re + match = re.search(r'(\d{4}-\d{2}-\d{2})', nfo_content) + if match: + premiered_date = match.group(1) + # Convert to ISO format with timezone + iso_date = f"{premiered_date}T00:00:00+00:00" + _log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}") + return iso_date + else: + _log("DEBUG", f"No tag found in existing NFO") + return None + + except Exception as e: + _log("ERROR", f"Error reading Radarr NFO file: {e}") + return None + + def _get_file_mtime_date(self, movie_path: Path) -> Tuple[str, str, Optional[str]]: + """Get date from file modification time as last resort""" + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + newest_mtime = None + + for file_path in movie_path.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in video_exts: + try: + mtime = file_path.stat().st_mtime + if newest_mtime is None or mtime > newest_mtime: + newest_mtime = mtime + except Exception: + continue + + if newest_mtime: + try: + # Use local timezone for file modification times + local_tz = _get_local_timezone() + iso_date = datetime.fromtimestamp(newest_mtime, tz=local_tz).isoformat(timespec="seconds") + return iso_date, "file:mtime", None + except Exception: + pass + + return "MANUAL_REVIEW_NEEDED", "manual_review_required", None + + def _should_prefer_release_over_file_date(self, release_date: str, release_source: str, theatrical_release: Optional[str], imdb_id: str) -> bool: + """ + Decide if release date should be preferred over file date + + Logic: + - For theatrical dates: Always prefer over file dates (they're authoritative) + - For physical dates: Usually prefer over file dates + - For digital dates: Prefer if reasonable (not decades before theatrical) + """ + try: + release_dt = datetime.fromisoformat(release_date.replace("Z", "+00:00")) + + # Always prefer theatrical and physical releases over file dates + if any(release_type in release_source for release_type in ["theatrical", "physical"]): + _log("INFO", f"Release date {release_date} ({release_source}) for {imdb_id}, preferring over file date") + return True + + # If we have theatrical release date, compare digital against it + if theatrical_release: + theatrical_dt = datetime.fromisoformat(theatrical_release.replace("Z", "+00:00")) + year_diff = release_dt.year - theatrical_dt.year + + # If digital is more than 10 years before theatrical, it's probably wrong + if year_diff < -10: + _log("INFO", f"Release date {release_date} is {abs(year_diff)} years before theatrical {theatrical_release} for {imdb_id}, using file date instead") + return False + + # If digital is within reasonable range (theatrical to +20 years), use it + if -2 <= year_diff <= 20: + _log("INFO", f"Release date {release_date} is reasonable for {imdb_id} (theatrical: {theatrical_release}), preferring over file date") + return True + + # If no theatrical date, use digital if it's not absurdly old + if release_dt.year >= 1990: # Reasonable minimum for digital releases + _log("INFO", f"Release date {release_date} seems reasonable for {imdb_id}, preferring over file date") + return True + + _log("INFO", f"Release date {release_date} seems too old for {imdb_id}, using file date instead") + return False + + except Exception as e: + _log("WARNING", f"Error comparing dates for {imdb_id}: {e}") + return False + + def _parse_date_to_iso(self, date_str: str) -> Optional[str]: + """Parse date string to ISO format""" + if not date_str: + return None + try: + if len(date_str) == 10 and date_str[4] == "-": + dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + + +# --------------------------- +# Batching System +# --------------------------- + +class WebhookBatcher: + """Batches webhook events to avoid processing storms""" + + def __init__(self): + self.pending: Dict[str, Dict] = {} + self.timers: Dict[str, threading.Timer] = {} + self.processing: Set[str] = set() + self.lock = threading.Lock() + self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent) + + def add_webhook(self, key: str, webhook_data: Dict, media_type: str): + """Add webhook to batch queue""" + with self.lock: + if key in self.timers: + self.timers[key].cancel() + + webhook_data['media_type'] = media_type + self.pending[key] = webhook_data + _log("INFO", f"Batched {media_type} webhook for {key}") + _log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s") + + timer = threading.Timer(config.batch_delay, self._process_item, args=[key]) + self.timers[key] = timer + timer.start() + + def _process_item(self, key: str): + """Process a batched item""" + with self.lock: + if key in self.processing or key not in self.pending: + return + self.processing.add(key) + webhook_data = self.pending.pop(key) + self.timers.pop(key, None) + + try: + self.executor.submit(self._process_sync, key, webhook_data) + except Exception as e: + _log("ERROR", f"Error submitting processing for {key}: {e}") + with self.lock: + self.processing.discard(key) + + def _process_sync(self, key: str, webhook_data: Dict): + """Synchronous processing of webhook data with validation""" + try: + media_type = webhook_data.get('media_type') + path_str = webhook_data.get('path') + + _log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}") + + if not path_str: + _log("ERROR", f"No path found for {media_type} {key}") + return + + path_obj = Path(path_str) + if not path_obj.exists(): + _log("ERROR", f"BATCH PROCESSING FAILED: Path does not exist: {path_obj}") + _log("ERROR", f"This indicates a path mapping issue - webhook rejected to prevent wrong processing") + return + + # CRITICAL: Validate that the path contains the expected IMDb ID for movies + if media_type == 'movie': + expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key + if expected_imdb not in path_str.lower(): + _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str}") + _log("ERROR", f"This prevents processing wrong movies due to batch corruption") + return + _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path") + + # Process based on media type + if media_type == 'tv': + # Check processing mode for TV webhooks + processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode) + episodes_data = webhook_data.get('episodes', []) + + if processing_mode == 'targeted' and episodes_data: + _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") + tv_processor.process_webhook_episodes(path_obj, episodes_data) + else: + _log("INFO", f"Using series processing mode (fallback or configured)") + tv_processor.process_series(path_obj) + elif media_type == 'movie': + movie_processor.process_movie(path_obj, webhook_mode=True) + else: + _log("ERROR", f"Unknown media type: {media_type}") + + except Exception as e: + _log("ERROR", f"Error processing {media_type} {key}: {e}") + finally: + with self.lock: + self.processing.discard(key) + + def get_status(self) -> Dict: + """Get batch queue status""" + with self.lock: + return { + "pending_items": list(self.pending.keys()), + "processing_items": list(self.processing), + "pending_count": len(self.pending), + "processing_count": len(self.processing) + } + + +# --------------------------- +# FastAPI Application +# --------------------------- + +# Get version +try: + version = (Path(__file__).parent / "VERSION").read_text().strip() +except: + version = "0.1.0" + +# Check if running from dev branch (detect at runtime) +try: + # Try to read git branch from .git/HEAD + git_head_path = Path(__file__).parent / ".git" / "HEAD" + if git_head_path.exists(): + head_content = git_head_path.read_text().strip() + if "ref: refs/heads/dev" in head_content: + version = f"{version}-dev" + elif head_content.startswith("ref: refs/heads/"): + # Extract branch name for other branches + branch = head_content.split("refs/heads/")[-1] + if branch != "main": + version = f"{version}-{branch}" +except Exception: + # If git detection fails, that's fine - use base version + pass + +# 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" + +app = FastAPI( + title="NFOGuard", + description="Webhook server for preserving media import dates", + version=version +) + +start_time = datetime.now(timezone.utc) + +# Initialize components +db = NFOGuardDatabase(config.db_path) +nfo_manager = NFOManager(config.manager_brand, config.debug) +path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper +tv_processor = TVProcessor(db, nfo_manager, path_mapper) +movie_processor = MovieProcessor(db, nfo_manager, path_mapper) +batcher = WebhookBatcher() + +# --------------------------- +# Webhook Handlers +# --------------------------- + +async def _read_payload(request: Request) -> dict: + """Read webhook payload from request""" + content_type = (request.headers.get("content-type") or "").lower() + try: + if "application/json" in content_type: + return await request.json() + form = await request.form() + if "payload" in form: + return json.loads(form["payload"]) + return dict(form) + except Exception as e: + _log("ERROR", f"Failed to read webhook payload: {e}") + return {} + +@app.post("/webhook/sonarr") +async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks): + """Handle Sonarr webhooks""" + try: + payload = await _read_payload(request) + if not payload: + raise HTTPException(status_code=422, detail="Empty Sonarr payload") + + webhook = SonarrWebhook(**payload) + _log("INFO", f"Received Sonarr webhook: {webhook.eventType}") + + if webhook.eventType not in ["Download", "Upgrade", "Rename"]: + return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"} + + if not webhook.series: + return {"status": "ignored", "reason": "No series data"} + + series_info = webhook.series + series_title = series_info.get("title", "") + imdb_id = series_info.get("imdbId", "").replace("tt", "").strip() + if imdb_id: + imdb_id = f"tt{imdb_id}" + sonarr_path = series_info.get("path", "") + + if not imdb_id: + _log("ERROR", f"No IMDb ID for series: {series_title}") + return {"status": "error", "reason": "No IMDb ID"} + + # Find series path + series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path) + if not series_path: + _log("ERROR", f"Could not find series directory: {series_title} ({imdb_id})") + return {"status": "error", "reason": "Series directory not found"} + + # Add to batch queue with TV-prefixed key to avoid movie conflicts + tv_batch_key = f"tv:{imdb_id}" + webhook_dict = { + 'path': str(series_path), + 'series_info': series_info, + 'event_type': webhook.eventType, + 'episodes': webhook.episodes or [], # Include episode data for targeted processing + 'processing_mode': config.tv_webhook_processing_mode + } + batcher.add_webhook(tv_batch_key, webhook_dict, 'tv') + + return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"} + + except Exception as e: + _log("ERROR", f"Sonarr webhook error: {e}") + raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}") + +@app.post("/webhook/radarr") +async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): + """Handle Radarr webhooks""" + try: + payload = await _read_payload(request) + _log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}") + _log("DEBUG", f"Full Radarr webhook payload: {payload}") + + # Filter supported event types (same as Sonarr: Download, Upgrade, Rename) + event_type = payload.get('eventType', '') + if event_type not in ["Download", "Upgrade", "Rename"]: + return {"status": "ignored", "reason": f"Event type {event_type} not processed"} + + # Extract movie info + movie_data = payload.get("movie", {}) + if not movie_data: + _log("WARNING", "No movie data in Radarr webhook") + return {"status": "error", "message": "No movie data"} + + # Get IMDb ID for batching key + imdb_id = movie_data.get("imdbId", "").lower() + if not imdb_id: + _log("WARNING", "No IMDb ID in Radarr webhook movie data") + return {"status": "error", "message": "No IMDb ID"} + + # Get movie path and map it + movie_path = movie_data.get("folderPath") or movie_data.get("path", "") + if not movie_path: + _log("ERROR", "No movie path in Radarr webhook") + return {"status": "error", "message": "No movie path provided"} + + # Map the path to container path + container_path = path_mapper.radarr_path_to_container_path(movie_path) + _log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}") + + # CRITICAL: Verify the mapped path actually exists + from pathlib import Path + if not Path(container_path).exists(): + _log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}") + _log("ERROR", f"This prevents processing wrong movies due to path mapping issues") + return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"} + + # Verify the path contains the expected IMDb ID + if imdb_id not in container_path.lower(): + _log("WARNING", f"IMDb ID {imdb_id} not found in container path {container_path}") + + # Create movie-specific webhook data with proper path validation + movie_webhook_data = { + 'path': container_path, # Use verified container path + 'movie_info': movie_data, + 'event_type': payload.get('eventType'), + 'original_payload': payload + } + + # Add to batch queue with movie-prefixed key to avoid TV conflicts + movie_batch_key = f"movie:{imdb_id}" + _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}") + batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie") + + return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"} + + except Exception as e: + _log("ERROR", f"Radarr webhook error: {e}") + return {"status": "error", "message": str(e)} + +# --------------------------- +# API Endpoints +# --------------------------- + +@app.get("/health") +async def health() -> HealthResponse: + """Health check endpoint with Radarr database status""" + uptime = datetime.now(timezone.utc) - start_time + + # Check NFOGuard database + try: + with db.get_connection() as conn: + conn.execute("SELECT 1").fetchone() + db_status = "healthy" + except Exception as e: + db_status = f"error: {e}" + + # Check Radarr database if available + radarr_db_health = None + overall_status = "healthy" if db_status == "healthy" else "degraded" + + # Get Radarr client with database access from movie processor + try: + if hasattr(movie_processor, 'radarr') and movie_processor.radarr: + radarr_client = movie_processor.radarr + if hasattr(radarr_client, 'db_client') and radarr_client.db_client: + try: + radarr_db_health = radarr_client.db_client.health_check() + if radarr_db_health["status"] != "healthy": + overall_status = "degraded" + except Exception as e: + radarr_db_health = { + "status": "error", + "error": str(e), + "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds") + } + overall_status = "degraded" + except Exception as e: + # If movie processor isn't available, skip database health check + _log("DEBUG", f"Skipping Radarr database health check: {e}") + + return HealthResponse( + status=overall_status, + version=version, + uptime=str(uptime), + database_status=db_status, + radarr_database=radarr_db_health + ) + +@app.get("/stats") +async def get_stats(): + """Get database statistics""" + try: + return db.get_stats() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/batch/status") +async def batch_status(): + """Get batch queue status""" + return batcher.get_status() + +@app.get("/debug/movie/{imdb_id}") +async def debug_movie_import_date(imdb_id: str): + """Debug endpoint to analyze movie import date detection""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + _log("INFO", f"=== DEBUG MOVIE IMPORT DATE: {imdb_id} ===") + + if not (os.environ.get("RADARR_URL") and os.environ.get("RADARR_API_KEY")): + return { + "error": "Radarr not configured", + "imdb_id": imdb_id, + "radarr_configured": False + } + + # Create Radarr client + from clients.radarr_client import RadarrClient + radarr_client = RadarrClient( + os.environ.get("RADARR_URL"), + os.environ.get("RADARR_API_KEY") + ) + + # Look up movie + movie_obj = radarr_client.movie_by_imdb(imdb_id) + if not movie_obj: + return { + "error": f"Movie not found in Radarr for IMDb ID {imdb_id}", + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": False + } + + movie_id = movie_obj.get("id") + movie_title = movie_obj.get("title") + + _log("INFO", f"Found movie: {movie_title} (Radarr ID: {movie_id})") + + # Test the FULL movie processing pipeline (not just database lookup) + _log("INFO", f"=== TESTING FULL MOVIE PROCESSING PIPELINE ===") + + # Create a dummy path for testing the decision logic + from pathlib import Path + dummy_path = Path("/tmp/test") + + try: + # Use the global movie processor instance to test full decision logic + global movie_processor + if movie_processor: + # First check external clients configuration + _log("INFO", f"=== CHECKING EXTERNAL CLIENTS CONFIG ===") + try: + tmdb_key = os.environ.get("TMDB_API_KEY", "") + _log("INFO", f"TMDB API Key configured: {'✅ YES' if tmdb_key else '❌ NO'}") + if tmdb_key: + _log("INFO", f"TMDB API Key length: {len(tmdb_key)} chars") + + # Check if external clients exist + external_clients_available = hasattr(movie_processor, 'external_clients') and movie_processor.external_clients + _log("INFO", f"External clients initialized: {'✅ YES' if external_clients_available else '❌ NO'}") + + except Exception as e: + _log("ERROR", f"Error checking external clients config: {e}") + + # Test the full decision logic (including TMDB fallback) + final_date, final_source, released = movie_processor._decide_movie_dates( + imdb_id, dummy_path, should_query=True, existing=None + ) + + _log("INFO", f"=== FULL PIPELINE RESULT ===") + _log("INFO", f"Final date: {final_date}") + _log("INFO", f"Final source: {final_source}") + _log("INFO", f"Released (theater): {released}") + + return { + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": True, + "movie_title": movie_title, + "movie_id": movie_id, + "full_pipeline_test": { + "final_date": final_date, + "final_source": final_source, + "theater_release": released, + "decision_logic": "✅ TESTED FULL PIPELINE INCLUDING TMDB FALLBACK" + }, + "database_only_test": { + "radarr_db_result": radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True), + "note": "This is just the database part - fallback happens in full pipeline" + }, + "debug_info": { + "radarr_url": os.environ.get("RADARR_URL"), + "movie_digital_release": movie_obj.get("digitalRelease"), + "movie_in_cinemas": movie_obj.get("inCinemas"), + "movie_physical_release": movie_obj.get("physicalRelease"), + "movie_folder_path": movie_obj.get("folderPath") + } + } + else: + _log("ERROR", "Movie processor not available - testing database only") + # Fallback to database-only testing + import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) + return { + "error": "Movie processor not available - only database test performed", + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": True, + "movie_title": movie_title, + "movie_id": movie_id, + "detected_import_date": import_date, + "import_source": source, + "debug_info": { + "note": "FULL PIPELINE TEST FAILED - movie processor not initialized" + } + } + + except Exception as pipeline_error: + _log("ERROR", f"Full pipeline test failed: {pipeline_error}") + # Fallback to database-only testing + import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) + return { + "pipeline_error": str(pipeline_error), + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": True, + "movie_title": movie_title, + "movie_id": movie_id, + "detected_import_date": import_date, + "import_source": source, + "debug_info": { + "note": "FULL PIPELINE TEST FAILED - showing database-only result" + } + } + + except Exception as e: + _log("ERROR", f"Debug endpoint error for {imdb_id}: {e}") + return { + "error": str(e), + "imdb_id": imdb_id, + "success": False + } + +@app.get("/debug/movie/{imdb_id}/history") +async def debug_movie_history(imdb_id: str): + """Detailed history analysis for a movie""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + _log("INFO", f"=== DETAILED HISTORY ANALYSIS: {imdb_id} ===") + + # Use database-only mode for consistency + if not movie_processor.radarr.db_client: + return {"error": "Radarr database not configured"} + + radarr_client = movie_processor.radarr + + # Look up movie + movie_obj = radarr_client.movie_by_imdb(imdb_id) + if not movie_obj: + return {"error": f"Movie not found for {imdb_id}"} + + movie_id = movie_obj.get("id") + movie_title = movie_obj.get("title") + + # Get history from database instead of API + if not radarr_client.db_client: + return {"error": "Database-only mode required"} + + # TODO: Implement database-only history retrieval + return { + "error": "History endpoint temporarily disabled - use /debug/movie/{imdb_id}/priority for date analysis", + "imdb_id": imdb_id, + "movie_id": movie_id, + "movie_title": movie_title, + "note": "This endpoint needs database-only implementation to avoid showing wrong movie events" + } + + # Analyze each event + analyzed_events = [] + for event in all_history: + event_type = event.get("eventType", "") + date_str = event.get("date", "") + event_data = event.get("data", {}) + + # Get source path info + source_path = ( + event_data.get('droppedPath', '') or + event_data.get('sourcePath', '') or + event_data.get('path', '') or + event_data.get('sourceTitle', '') + ) + + # Analyze if this is a real import + is_real, reason, date_iso = radarr_client._analyze_event_for_import(event) + + analyzed_events.append({ + "event_type": event_type, + "date": date_str, + "source_path": source_path, + "is_real_import": is_real, + "analysis_reason": reason, + "parsed_date": date_iso, + "full_data": event_data + }) + + # Find what our algorithm would pick - DATABASE ONLY + picked_date, _ = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) + + return { + "imdb_id": imdb_id, + "movie_title": movie_title, + "movie_id": movie_id, + "total_history_events": len(all_history), + "our_algorithm_picked": picked_date, + "all_events": analyzed_events, + "expected_july_date": "2025-07-07" in (picked_date or "") + } + + except Exception as e: + _log("ERROR", f"History debug error for {imdb_id}: {e}") + return {"error": str(e)} + +@app.post("/manual/scan") +async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"): + """Manual scan endpoint""" + if scan_type not in ["both", "tv", "movies"]: + raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'") + + async def run_scan(): + paths_to_scan = [] + if path: + paths_to_scan = [Path(path)] + else: + if scan_type in ["both", "tv"]: + paths_to_scan.extend(config.tv_paths) + if scan_type in ["both", "movies"]: + paths_to_scan.extend(config.movie_paths) + + for scan_path in paths_to_scan: + if not scan_path.exists(): + continue + + if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path): + # Handle specific season/episode path + if path and scan_path.name.lower().startswith('season'): + # Single season processing + series_path = scan_path.parent + if nfo_manager.parse_imdb_from_path(series_path): + _log("INFO", f"Processing single season: {scan_path}") + try: + tv_processor.process_season(series_path, scan_path) + except Exception as e: + _log("ERROR", f"Failed processing season {scan_path}: {e}") + elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'): + # Single episode processing + season_path = scan_path.parent + series_path = season_path.parent + if nfo_manager.parse_imdb_from_path(series_path): + _log("INFO", f"Processing single episode: {scan_path}") + try: + tv_processor.process_episode_file(series_path, season_path, scan_path) + except Exception as e: + _log("ERROR", f"Failed processing episode {scan_path}: {e}") + else: + # Full series processing + for item in scan_path.iterdir(): + if item.is_dir() and nfo_manager.parse_imdb_from_path(item): + try: + tv_processor.process_series(item) + except Exception as e: + _log("ERROR", f"Failed processing TV series {item}: {e}") + + if scan_type in ["both", "movies"] and scan_path in config.movie_paths: + _log("INFO", f"Scanning movies in: {scan_path}") + movie_count = 0 + for item in scan_path.iterdir(): + if item.is_dir() and nfo_manager.find_movie_imdb_id(item): + movie_count += 1 + _log("INFO", f"Processing movie: {item.name}") + try: + movie_processor.process_movie(item) + except Exception as e: + _log("ERROR", f"Failed processing movie {item}: {e}") + _log("INFO", f"Completed movie scan: {movie_count} movies processed in {scan_path}") + + background_tasks.add_task(run_scan) + return {"status": "started", "message": f"Manual {scan_type} scan started"} + +@app.post("/tv/scan-season") +async def scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest): + """Scan a specific TV season - URL-safe endpoint""" + try: + series_dir = Path(request.series_path) + season_dir = series_dir / request.season_name + + if not series_dir.exists(): + raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}") + if not season_dir.exists(): + raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}") + + imdb_id = nfo_manager.parse_imdb_from_path(series_dir) + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID found in series path") + + async def process_season(): + _log("INFO", f"Processing TV season: {season_dir}") + try: + tv_processor.process_season(series_dir, season_dir) + except Exception as e: + _log("ERROR", f"Failed processing season {season_dir}: {e}") + + background_tasks.add_task(process_season) + return {"status": "started", "message": f"Season scan started for {request.season_name}"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/tv/scan-episode") +async def scan_tv_episode(background_tasks: BackgroundTasks, request: TVEpisodeRequest): + """Scan a specific TV episode - URL-safe endpoint""" + try: + series_dir = Path(request.series_path) + season_dir = series_dir / request.season_name + episode_file = season_dir / request.episode_name + + if not series_dir.exists(): + raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}") + if not episode_file.exists(): + raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}") + + imdb_id = nfo_manager.parse_imdb_from_path(series_dir) + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID found in series path") + + async def process_episode(): + _log("INFO", f"Processing TV episode: {episode_file}") + try: + tv_processor.process_episode_file(series_dir, season_dir, episode_file) + except Exception as e: + _log("ERROR", f"Failed processing episode {episode_file}: {e}") + + background_tasks.add_task(process_episode) + return {"status": "started", "message": f"Episode scan started for {request.episode_name}"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/test/bulk-update") +async def test_bulk_update(): + """Test bulk update functionality without modifying data""" + try: + from clients.radarr_db_client import RadarrDbClient + + # Test Radarr database + radarr_db = RadarrDbClient.from_env() + if not radarr_db: + return {"status": "error", "message": "Radarr database connection failed"} + + # Test query execution + query = 'SELECT COUNT(*) FROM "Movies" m JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" WHERE mm."ImdbId" IS NOT NULL' + with radarr_db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(query) + movie_count = cursor.fetchone()[0] + + return { + "status": "success", + "message": "Bulk update test passed", + "movies_with_imdb": movie_count, + "database_type": radarr_db.db_type + } + except Exception as e: + return {"status": "error", "message": f"Bulk update test failed: {e}"} + +@app.post("/test/movie-scan") +async def test_movie_scan(): + """Test movie directory scanning logic""" + try: + results = [] + for path in config.movie_paths: + path_result = { + "path": str(path), + "exists": path.exists(), + "movies_found": 0 + } + + if path.exists(): + for item in path.iterdir(): + if item.is_dir() and nfo_manager.find_movie_imdb_id(item): + path_result["movies_found"] += 1 + + results.append(path_result) + + total_movies = sum(r["movies_found"] for r in results) + return { + "status": "success", + "message": f"Movie scan test found {total_movies} movies", + "path_results": results + } + except Exception as e: + return {"status": "error", "message": f"Movie scan test failed: {e}"} + +@app.post("/bulk/update") +async def trigger_bulk_update(background_tasks: BackgroundTasks): + """Trigger bulk update of all movies""" + async def run_bulk_update(): + try: + from bulk_update_movies import bulk_update_all_movies + success = bulk_update_all_movies() + _log("INFO", f"Bulk update completed: {'success' if success else 'failed'}") + except Exception as e: + _log("ERROR", f"Bulk update error: {e}") + + background_tasks.add_task(run_bulk_update) + return {"status": "started", "message": "Bulk update started"} + +@app.get("/debug/movie/{imdb_id}/priority") +async def debug_movie_priority_logic(imdb_id: str): + """Debug endpoint showing how MOVIE_PRIORITY affects date selection""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + result = { + "imdb_id": imdb_id, + "movie_priority": config.movie_priority, + "release_date_priority": config.release_date_priority, + "priority_explanation": "", + "date_sources": {}, + "selected_date": None, + "selected_source": None + } + + # Get Radarr import date + if movie_processor.radarr.api_key: + radarr_movie = movie_processor.radarr.movie_by_imdb(imdb_id) + if radarr_movie: + movie_id = radarr_movie.get("id") + if movie_id: + import_date, import_source = movie_processor.radarr.get_movie_import_date(movie_id) + if import_date: + result["date_sources"]["radarr_import"] = { + "date": import_date, + "source": import_source + } + + # Get digital release dates with detailed logging + digital_date, digital_source = movie_processor._get_digital_release_date(imdb_id) + if digital_date: + result["date_sources"]["digital_release"] = { + "date": digital_date, + "source": digital_source + } + else: + # Add debug info about why digital date wasn't found + candidates = movie_processor.external_clients.get_digital_release_candidates(imdb_id) + result["date_sources"]["digital_release_debug"] = { + "candidates_found": len(candidates), + "candidates": candidates[:3] if candidates else [], # Show first 3 + "reason": digital_source if digital_source else "no_digital_dates_found" + } + + # Show priority logic + if config.movie_priority == "import_then_digital": + priority_list = " → ".join(config.release_date_priority) + result["priority_explanation"] = f"1st: Radarr import history, 2nd: Release dates ({priority_list}), 3rd: file mtime. Note: If import is only file date, prefer reasonable release dates." + + radarr_import = result["date_sources"].get("radarr_import") + digital_release = result["date_sources"].get("digital_release") + + # Check for file date fallback logic + if radarr_import and radarr_import["source"] == "radarr:db.file.dateAdded" and digital_release: + # Test the smart logic + would_prefer_digital = movie_processor._should_prefer_release_over_file_date( + digital_release["date"], + digital_release["source"], + None, # We don't have theatrical date in this debug context + imdb_id + ) + result["file_date_detected"] = True + result["would_prefer_digital"] = would_prefer_digital + + if would_prefer_digital: + result["selected_date"] = digital_release["date"] + result["selected_source"] = digital_release["source"] + " (preferred over file date)" + else: + result["selected_date"] = radarr_import["date"] + result["selected_source"] = radarr_import["source"] + " (digital too old)" + elif radarr_import and radarr_import["source"] != "radarr:db.file.dateAdded": + result["selected_date"] = radarr_import["date"] + result["selected_source"] = radarr_import["source"] + elif digital_release: + result["selected_date"] = digital_release["date"] + result["selected_source"] = digital_release["source"] + else: # digital_then_import + result["priority_explanation"] = "1st: TMDB/OMDb digital release, 2nd: Radarr import history, 3rd: file mtime" + if result["date_sources"].get("digital_release"): + result["selected_date"] = result["date_sources"]["digital_release"]["date"] + result["selected_source"] = result["date_sources"]["digital_release"]["source"] + elif result["date_sources"].get("radarr_import"): + result["selected_date"] = result["date_sources"]["radarr_import"]["date"] + result["selected_source"] = result["date_sources"]["radarr_import"]["source"] + + # Show external API status + result["external_apis"] = { + "tmdb_enabled": movie_processor.external_clients.tmdb.enabled, + "omdb_enabled": movie_processor.external_clients.omdb.enabled, + "jellyseerr_enabled": movie_processor.external_clients.jellyseerr.enabled + } + + return result + + except Exception as e: + return {"error": str(e), "imdb_id": imdb_id} + +@app.get("/debug/tmdb/{imdb_id}") +async def debug_tmdb_lookup(imdb_id: str): + """Debug TMDB API lookup for a specific movie""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + result = { + "imdb_id": imdb_id, + "tmdb_api_enabled": movie_processor.external_clients.tmdb.enabled, + "tmdb_api_key_configured": bool(movie_processor.external_clients.tmdb.api_key), + "steps": {} + } + + if not movie_processor.external_clients.tmdb.enabled: + result["error"] = "TMDB API not enabled - check TMDB_API_KEY environment variable" + return result + + # Step 1: Find movie by IMDb ID + _log("INFO", f"TMDB Debug: Looking up {imdb_id}") + tmdb_movie = movie_processor.external_clients.tmdb.find_by_imdb(imdb_id) + result["steps"]["1_find_by_imdb"] = { + "found": bool(tmdb_movie), + "tmdb_movie": tmdb_movie if tmdb_movie else None + } + + if not tmdb_movie: + result["error"] = f"Movie {imdb_id} not found in TMDB" + return result + + tmdb_id = tmdb_movie.get("id") + result["tmdb_id"] = tmdb_id + + # Step 2: Get release dates + if tmdb_id: + _log("INFO", f"TMDB Debug: Getting release dates for TMDB ID {tmdb_id}") + release_dates_result = movie_processor.external_clients.tmdb._get(f"/movie/{tmdb_id}/release_dates") + result["steps"]["2_release_dates"] = { + "raw_response": release_dates_result, + "has_results": bool(release_dates_result and release_dates_result.get("results")) + } + + # Step 3: Look for US digital releases + if release_dates_result and release_dates_result.get("results"): + us_releases = [] + for country_data in release_dates_result["results"]: + if country_data.get("iso_3166_1") == "US": + us_releases = country_data.get("release_dates", []) + break + + result["steps"]["3_us_releases"] = { + "found_us_data": bool(us_releases), + "us_releases": us_releases + } + + # Step 4: Look for digital releases (type 4) + digital_releases = [r for r in us_releases if r.get("type") == 4] + result["steps"]["4_digital_releases"] = { + "digital_count": len(digital_releases), + "digital_releases": digital_releases + } + + # Step 5: Test the full digital release function + digital_date = movie_processor.external_clients.tmdb.get_digital_release_date(imdb_id) + result["steps"]["5_final_result"] = { + "digital_date": digital_date, + "success": bool(digital_date) + } + + return result + + except Exception as e: + return {"error": str(e), "imdb_id": imdb_id, "traceback": str(e)} + +# --------------------------- +# Main +# --------------------------- + +def signal_handler(signum, frame): + """Handle shutdown signals gracefully""" + _log("INFO", f"Received signal {signum}, shutting down gracefully...") + sys.exit(0) + +if __name__ == "__main__": + # Register signal handlers for graceful shutdown + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + _log("INFO", "Starting NFOGuard") + _log("INFO", f"Version: {version}") + _log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}") + _log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}") + _log("INFO", f"Database: {config.db_path}") + _log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}") + _log("INFO", f"Movie priority: {config.movie_priority}") + + try: + uvicorn.run( + app, + host="0.0.0.0", + port=int(os.environ.get("PORT", "8080")), + reload=False + ) + except KeyboardInterrupt: + _log("INFO", "NFOGuard stopped by user") + except Exception as e: + _log("ERROR", f"NFOGuard crashed: {e}") + sys.exit(1) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..043f2eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +psycopg2-binary==2.9.7 +requests==2.31.0 +python-multipart==0.0.6 +aiofiles==23.2.1 +python-dotenv==1.0.0