From ca2e77f4c746a7c672a8d4669ee52d7a19358db7 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Wed, 24 Sep 2025 15:14:40 -0400 Subject: [PATCH 01/16] remove .local --- .local/.env.example | 28 ----- .local/.env.template | 141 ----------------------- .local/SETUP.md | 153 ------------------------- .local/SUMMARY.md | 259 ------------------------------------------- .local/commit.md | 66 ----------- .local/workflow.md | 221 ------------------------------------ 6 files changed, 868 deletions(-) delete mode 100644 .local/.env.example delete mode 100644 .local/.env.template delete mode 100644 .local/SETUP.md delete mode 100644 .local/SUMMARY.md delete mode 100644 .local/commit.md delete mode 100644 .local/workflow.md diff --git a/.local/.env.example b/.local/.env.example deleted file mode 100644 index 78ce602..0000000 --- a/.local/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index 4ee8755..0000000 --- a/.local/.env.template +++ /dev/null @@ -1,141 +0,0 @@ -# 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 deleted file mode 100644 index 0798ec5..0000000 --- a/.local/SETUP.md +++ /dev/null @@ -1,153 +0,0 @@ -# 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 deleted file mode 100644 index cb3e141..0000000 --- a/.local/SUMMARY.md +++ /dev/null @@ -1,259 +0,0 @@ -# NFOGuard Development Summary - September 24, 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 - -## ✅ MAJOR NFO RELEASE DATE BUG FIXES COMPLETED - -### 🎯 Core Issue Resolved -Movies were getting minimal NFO files with "no_valid_date_source" when digital release dates were available from TMDB/OMDB but not being used properly. - -**ROOT CAUSES IDENTIFIED & FIXED:** -- should_query logic wasn't querying APIs when database had empty entries -- Digital release dates weren't being used as dateadded fallback -- NFOGuard fields were scattered in NFO files instead of grouped at bottom -- Emby plugin compatibility issues with dateadded field positioning - -**FIXES IMPLEMENTED:** -- Fixed should_query condition to query APIs when dateadded is None -- Use digital release dates as dateadded when import dates unavailable -- Ensure all NFOGuard fields append at bottom of NFO files -- Add NFOGuard comment marker to clearly delineate our additions - -## 🚀 THREE-TIER PERFORMANCE OPTIMIZATION SYSTEM - -### 🎯 Revolutionary Speed Improvements -Implemented intelligent caching system that eliminates 90%+ of processing time on warm systems: - -**TIER 1 - NFO File Cache (Instant ~99% faster):** -- **MOVIES**: Detects existing NFOGuard data in NFO files (`` + `true`) -- **TV EPISODES**: Detects NFOGuard data in episode NFO files (`` + `true`) -- Uses NFO data directly, skips all database queries AND API calls -- Perfect for: Database rebuilds, migrations, disaster recovery - -**TIER 2 - Database Cache (Very Fast ~90% faster):** -- **MOVIES**: Uses complete database entries when available -- **TV EPISODES**: Uses existing episode database entries with dateadded -- Skips expensive API calls to TMDB/OMDB/Radarr/Sonarr -- Creates NFO from cached data only - -**TIER 3 - Full Processing (Normal Speed):** -- **MOVIES**: Only triggers when neither NFO nor database have data -- **TV EPISODES**: Falls back to Sonarr API queries and airdate extraction -- Performs full API queries, date processing, saves everything - -**TIER 1.5 - TMDB Fallback Processing (Fast):** -- **MOVIES ONLY**: Special handling for TMDB-only movies without IMDb IDs -- Extracts `premiered` dates from existing NFO files created by other tools -- Uses TMDB data as fallback when standard IMDb workflow fails - -### 🔧 Critical Use Cases Enabled - -**DATABASE REBUILD SCENARIO:** -- User loses NFOGuard database but has processed NFO files -- **MOVIES**: System reads existing NFOGuard data from thousands of movie NFO files instantly -- **TV EPISODES**: System reads existing NFOGuard data from episode NFO files instantly -- Repopulates database without ANY API calls or rate limiting -- Complete rebuild in minutes instead of hours - -**MIGRATION SCENARIO:** -- Moving to new NFOGuard installation -- Existing NFO files contain all necessary date information -- **MOVIES**: Zero API dependency for movie data recovery -- **TV EPISODES**: Zero API dependency for episode data recovery - -**PERFORMANCE SCENARIO:** -- Subsequent full scans become lightning fast -- **MOVIES**: Only new movies require API processing -- **TV EPISODES**: Only new episodes require Sonarr API calls -- Existing library processes near-instantly - -**EDGE CASE SCENARIO:** -- **TMDB-ONLY MOVIES**: Movies with TMDB IDs but no IMDb IDs (e.g., "For the One (2024)") -- System extracts `premiered` dates from existing NFO files created by Radarr/other tools -- Provides fallback support for movies that can't be processed via standard IMDb workflow - -## 📝 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 DEVELOPMENT WORKFLOW REMINDER - -**⚠️ CODE REPOSITORY ONLY - NO SYSTEM ACCESS:** -- **THIS IS A CODE REPOSITORY ONLY** - No access to running NFOGuard systems, movie directories, or file operations -- **MAKE CODE CHANGES ONLY** - Edit source files, commit to Gitea, push for local builds -- **NO BASH COMMANDS** - Cannot run python scripts, find commands, or test operations outside of git -- **GITEA LOCAL BUILDS** - All testing happens on local Gitea builds, not in development environment -- **NO FILE SYSTEM ACCESS** - Cannot access /mnt/unionfs/Media/Movies/ or any movie directories -- **CODE CHANGES → COMMIT → PUSH → BUILD** - This is the only workflow available - -### 🚫 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 ❌ -``` - -## 🐳 DOCKER BUILD IMPROVEMENTS COMPLETED - -### 🏷️ Version-Specific Gitea Tags -- **IMPLEMENTED**: Clear Docker image tagging with version-gitea format -- **DEV BUILDS**: `sbcrumb/nfoguard:1.7.0-dev-gitea` for development testing -- **MAIN BUILDS**: `sbcrumb/nfoguard:1.7.0-gitea` for production releases -- **IDENTIFICATION**: Easy to verify you're running Gitea builds vs other sources - -### 🔧 CI/CD Workflow Fixes -- **FIXED**: Repository paths changed from jskala/NFOguard to sbcrumb/nfoguard -- **WORKING**: Both dev and main branch builds functioning properly -- **AUTOMATION**: Proper Docker registry pushing and caching - -## 🔄 Current Repository State - -### 📍 Gitea (Private - Complete & Working Code) -- ✅ All NFO release date fixes implemented and working -- ✅ Complete development history with proper attribution handling -- ✅ .local/ directory with private development documentation -- ✅ Version 1.7.0 with comprehensive NFO improvements -- ✅ Working CI/CD with version-specific Docker tags - -### 📍 GitHub (Public - Clean History, Needs Sync) -- ✅ Professional commit history (sbcrumb only) -- ✅ Working Docker Hub automation -- ✅ Zero Claude/AI references maintained -- ⚠️ **NEEDS SYNC**: GitHub main should get NFO fixes from Gitea dev -- ⚠️ **OUT OF DATE**: Missing latest 1.7.0 improvements - -## 🧪 TESTING STATUS - -### ✅ SUCCESSFUL TEST CASES -- **Flow (2019)**: Fixed IMDb ID issue, now gets proper digital release dates and NFO structure -- **Ambush (2023)**: Working correctly with TMDB digital release date fallback -- **Inside Out 2**: Needs reprocessing with latest code to verify NFO field positioning - -### 🔧 DEBUG IMPROVEMENTS ADDED -- **Enhanced Logging**: Added should_query decision tracking and external API call debugging -- **NFO Creation Logs**: Shows exactly what parameters are passed and what fields are added -- **API Debug Endpoints**: `/debug/movie/{imdb_id}` for troubleshooting specific movies -- **IMDb ID Detection**: Comprehensive logging for directory/filename/NFO parsing -- **Three-Tier Logging**: Shows which optimization tier is used for each movie -- **Failed Movies Log**: Dedicated `logs/failed_movies.log` for movies with no_valid_date_source - -### 🎯 PERFORMANCE TESTING SCENARIOS -- **Cold Database**: Test full processing with empty database (Tier 3 for all content) -- **Warm Database**: Test database optimization (Tier 2 for processed content) -- **NFO File Recovery**: Test database rebuild from existing NFO files (Tier 1 instant recovery) -- **Mixed Scenarios**: New content (Tier 3) + existing content (Tier 1/2) performance -- **TV Episode Testing**: Verify three-tier optimization works for TV shows and episodes -- **TMDB Fallback Testing**: Test movies with TMDB IDs but no IMDb IDs (Tier 1.5) - -## 🎯 SUCCESS METRICS ACHIEVED - -✅ **Privacy**: Zero personal info on public GitHub maintained -✅ **Attribution**: Zero Claude references anywhere public maintained -✅ **Automation**: Working Docker Hub builds and releases with version tags -✅ **Professional**: Clean repository presentation maintained -✅ **Functionality**: NFO release date logic completely fixed and working -✅ **Compatibility**: Emby plugin dateadded field positioning corrected -✅ **User Experience**: Clear Docker tags for easy identification of Gitea builds -✅ **Performance**: Revolutionary three-tier optimization system implemented for movies AND TV episodes -✅ **Reliability**: Database rebuild/disaster recovery scenarios fully supported for all content types -✅ **Scalability**: System now handles large libraries efficiently with intelligent caching -✅ **TV Episode Support**: Three-tier optimization extended to TV shows with Sonarr integration -✅ **TMDB Fallback**: Edge case support for TMDB-only movies without IMDb IDs - -## 📅 FUTURE CONSIDERATIONS - -### 🔄 GitHub Sync Strategy -- **WHEN TO SYNC**: After thorough testing of current NFO fixes -- **HOW TO SYNC**: Merge Gitea dev → Gitea main → GitHub main (preserving clean history) -- **PRIORITY**: Not urgent since Gitea builds are working perfectly - -### 🚀 Version 1.7.0 Release Readiness -- **CURRENT STATUS**: Production-ready with game-changing performance improvements -- **TESTING NEEDED**: Verify three-tier optimization in various scenarios (movies AND TV episodes) -- **FEATURES COMPLETE**: Digital release dates, NFO structure, Emby compatibility, performance optimization, TV episode support, TMDB fallback - -## 📋 IMMEDIATE ACTION ITEMS - -1. **PERFORMANCE TEST**: Test three-tier optimization with various scenarios - - Empty database (cold start) for movies AND TV episodes - - Full database rebuild from existing NFO files (movies AND episodes) - - Mixed library with new + existing content (both movies and TV) - - TMDB-only movies (e.g., "For the One (2024)" with tt33293430) -2. **VALIDATE**: Confirm IMDb ID detection from filenames (Adulthood case) -3. **TV EPISODE VALIDATION**: Verify TV episode three-tier optimization in real scenarios -4. **TMDB FALLBACK TESTING**: Test movies with TMDB IDs but no IMDb IDs -5. **MONITOR**: Database size should remain appropriate with new optimizations -6. **BENCHMARK**: Compare scan times before/after optimization (should see 90%+ improvement) -7. **DEBUG LOG**: Check `logs/failed_movies.log` for movies that couldn't get valid dates - ---- -**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 deleted file mode 100644 index 5f12f68..0000000 --- a/.local/commit.md +++ /dev/null @@ -1,66 +0,0 @@ -# =� 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 deleted file mode 100644 index fc269b3..0000000 --- a/.local/workflow.md +++ /dev/null @@ -1,221 +0,0 @@ -# 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 From 210af8ac4bdc16c52abdafadafed4abc5fb74386 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Wed, 24 Sep 2025 18:53:48 -0400 Subject: [PATCH 02/16] tv-nfo (#4) Reviewed-on: https://gitea.skalas.org/sbcrumb/nfoguard/pulls/4 Co-authored-by: SBCrumb Co-committed-by: SBCrumb --- core/nfo_manager.py | 45 ++++++++++++++++++++++++++++++++++++++++++++- nfoguard.py | 1 + 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index fbc1005..9857a4b 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -683,4 +683,47 @@ class NFOManager: 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 + print(f"⚠️ No video files found to update in {movie_dir.name}") + + 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: + try: + file_season = int(season_elem.text) + file_episode = int(episode_elem.text) + + # If this NFO matches our target season/episode, return it + if file_season == season_num and file_episode == episode_num: + print(f"🔍 Found existing long-named NFO for S{season_num:02d}E{episode_num:02d}: {nfo_file.name}") + return nfo_file + except (ValueError, TypeError): + # Skip NFO files with invalid season/episode numbers + continue + + except ET.ParseError: + # Skip malformed NFO files + continue + + return None \ No newline at end of file diff --git a/nfoguard.py b/nfoguard.py index 223d4d1..67aac0f 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -697,6 +697,7 @@ class TVProcessor: return None + def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: """Get enhanced series metadata from Sonarr API""" try: From 27430a7eadb797de1c8c45baca1aeb75800eed46 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Wed, 24 Sep 2025 19:35:39 -0400 Subject: [PATCH 03/16] fix: nfo --- core/nfo_manager.py | 42 ------------------------------------------ nfoguard.py | 21 +++++++++++++++++++-- 2 files changed, 19 insertions(+), 44 deletions(-) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 9857a4b..09c4e29 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -685,45 +685,3 @@ class NFOManager: else: print(f"⚠️ No video files found to update in {movie_dir.name}") - 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: - try: - file_season = int(season_elem.text) - file_episode = int(episode_elem.text) - - # If this NFO matches our target season/episode, return it - if file_season == season_num and file_episode == episode_num: - print(f"🔍 Found existing long-named NFO for S{season_num:02d}E{episode_num:02d}: {nfo_file.name}") - return nfo_file - except (ValueError, TypeError): - # Skip NFO files with invalid season/episode numbers - continue - - except ET.ParseError: - # Skip malformed NFO files - continue - - return None \ No newline at end of file diff --git a/nfoguard.py b/nfoguard.py index 67aac0f..2584c97 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -440,12 +440,29 @@ class TVProcessor: 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 + # Check for existing NFO files (including long-named ones) for migration + nfo_episodes_found = 0 + for (season_num, episode_num) in disk_episodes.keys(): + if (season_num, episode_num) not in episode_dates: + # Check if this episode has an existing NFO file that needs migration + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + existing_nfo = self.nfo_manager.find_existing_episode_nfo(season_dir, season_num, episode_num) + + if existing_nfo: + # Force processing of this episode for NFO migration + episode_dates[(season_num, episode_num)] = (None, None, "nfo_migration_required") + nfo_episodes_found += 1 + + if nfo_episodes_found > 0: + _log("INFO", f"Found {nfo_episodes_found} episodes with existing NFO files requiring migration") + + # Find missing episodes (not in cache and no existing NFO) 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") + if nfo_episodes_found == 0: + _log("INFO", "All episodes found in cache") return episode_dates _log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes") From 16d9956202dd5897051f632aeb028cfb5b7f7659 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Thu, 25 Sep 2025 08:53:56 -0400 Subject: [PATCH 04/16] fix: Add filename-based title extraction for TV episodes - Add _extract_title_from_filename() method to extract episode titles from video filenames - Enhance _get_episode_metadata() to use filename fallback when Sonarr API doesn't provide titles - Update all _get_episode_metadata() calls to pass season directory for filename extraction - Extract titles from patterns like: Series-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv - Preserves existing Sonarr API behavior while providing fallback for missing titles - Fixes episodes with missing elements in NFO files when Sonarr data is incomplete Resolves issue where episodes processed via sonarr:history.import had empty NFO titles despite clear title information being available in the video filenames. --- nfoguard.py | 86 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/nfoguard.py b/nfoguard.py index 2584c97..6877e8f 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -568,7 +568,7 @@ class TVProcessor: 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 + enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode, season_path) if series_metadata else self._get_episode_metadata(None, season, episode, season_path) # Create NFO if config.manage_nfo: @@ -659,7 +659,7 @@ class TVProcessor: # 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 + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_path) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_path) # Get episode date aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) @@ -739,19 +739,75 @@ class TVProcessor: _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 + def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int, season_dir: Optional[Path] = None) -> Optional[Dict[str, Any]]: + """Extract specific episode metadata from series data with filename fallback for titles""" + metadata = { + "title": None, + "overview": None, + "runtime": None, + "ratings": {} + } - 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", {}) - } + # Try to get metadata from Sonarr first + 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: + metadata.update({ + "title": episode.get("title"), + "overview": episode.get("overview"), + "runtime": episode.get("runtime"), + "ratings": episode.get("ratings", {}) + }) + break + + # If no title from Sonarr, try to extract from filename + if not metadata["title"] and season_dir: + filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) + if filename_title: + metadata["title"] = filename_title + _log("INFO", f"Using filename-extracted title for S{season_num:02d}E{episode_num:02d}: '{filename_title}'") + + # Return metadata if we have at least some information + if any(metadata.values()): + return metadata + + return None + + def _extract_title_from_filename(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[str]: + """Extract episode title from video filename as fallback when Sonarr doesn't provide it""" + try: + import re + # Look for video files matching this episode + season_pattern = f"S{season_num:02d}E{episode_num:02d}" + + for video_file in season_dir.glob("*.mkv"): + filename = video_file.name + if season_pattern in filename: + # Pattern: SeriesName-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv + # Extract the part between season/episode and the first bracket + match = re.search(rf'{season_pattern}-(.*?)\[', filename) + if match: + title = match.group(1).strip() + # Clean up common encoding artifacts and separators + title = title.replace('-', ' ').strip() + if title: + _log("DEBUG", f"Extracted title from filename: '{title}' for {season_pattern}") + return title + + # Also check .mp4 files + for video_file in season_dir.glob("*.mp4"): + filename = video_file.name + if season_pattern in filename: + match = re.search(rf'{season_pattern}-(.*?)\[', filename) + if match: + title = match.group(1).strip() + title = title.replace('-', ' ').strip() + if title: + _log("DEBUG", f"Extracted title from filename: '{title}' for {season_pattern}") + return title + + except Exception as e: + _log("DEBUG", f"Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") return None @@ -869,7 +925,7 @@ class TVProcessor: # 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 + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir) # Create NFO if config.manage_nfo: From a1921936f76102775c246829a5a1b3408ad1569a Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:00:07 -0400 Subject: [PATCH 05/16] fix: Add title enhancement for existing NFO files - Modify extract_nfoguard_dates_from_episode_nfo() to detect missing titles - Add enhance_existing_episode_nfo_with_title() method to update NFOs with titles - Enhance Tier 1 processing to check for missing titles in existing NFO files - Extract titles from filenames and update existing NFOs that lack title elements - Fix NFO filename case to match S01E01.nfo format - Preserves all existing NFO content while adding missing title elements Addresses issue where existing NFO files with NFOGuard metadata were skipping title extraction due to Tier 1 optimization caching. --- core/nfo_manager.py | 53 +++++++++++++++++++++++++++++++++++++++++++-- nfoguard.py | 15 +++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 09c4e29..94ade70 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -163,7 +163,7 @@ class NFOManager: def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]: """Extract NFOGuard-managed dates from existing episode NFO file""" - nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo" + nfo_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" nfo_path = season_path / nfo_filename if not nfo_path.exists(): @@ -190,7 +190,11 @@ class NFOManager: if aired_elem is not None and aired_elem.text: result["aired"] = aired_elem.text.strip() - print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result['dateadded']}, aired={result.get('aired', 'None')}") + # Check if title is missing + title_elem = root.find('.//title') + result["has_title"] = title_elem is not None and title_elem.text and title_elem.text.strip() + + print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result['dateadded']}, aired={result.get('aired', 'None')}, has_title={result['has_title']}") return result except (ET.ParseError, Exception) as e: @@ -199,6 +203,51 @@ class NFOManager: return None + def enhance_existing_episode_nfo_with_title(self, season_path: Path, season_num: int, episode_num: int, title: str) -> bool: + """Add title to existing episode NFO file that's missing one""" + nfo_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" + nfo_path = season_path / nfo_filename + + if not nfo_path.exists(): + return False + + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + + # Check if title already exists + existing_title = root.find('.//title') + if existing_title is not None and existing_title.text and existing_title.text.strip(): + return False # Title already exists + + # Add title element (insert near the top, after any existing plot element) + title_elem = ET.Element("title") + title_elem.text = title + + # Find the best position to insert title (after plot if it exists, otherwise at the beginning) + plot_elem = root.find('.//plot') + if plot_elem is not None: + # Insert after plot + plot_index = list(root).index(plot_elem) + root.insert(plot_index + 1, title_elem) + else: + # Insert at the beginning (after any existing title that might be empty) + if existing_title is not None: + title_index = list(root).index(existing_title) + root.remove(existing_title) + root.insert(title_index, title_elem) + else: + root.insert(0, title_elem) + + # Write the updated NFO + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) + print(f"📝 Enhanced existing NFO with title: S{season_num:02d}E{episode_num:02d} - '{title}'") + return True + + except Exception as e: + print(f"⚠️ Error enhancing NFO with title: {e}") + return False + def _parse_nfo_with_tolerance(self, nfo_path: Path): """Parse NFO file with tolerance for URLs appended after XML""" try: diff --git a/nfoguard.py b/nfoguard.py index 6877e8f..50d9ab4 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -623,6 +623,21 @@ class TVProcessor: source = nfo_data["source"] aired = nfo_data.get("aired") + # Check if NFO is missing title and try to enhance it + if not nfo_data.get("has_title", False): + _log("INFO", f"NFO S{season_num:02d}E{episode_num:02d} missing title, attempting to extract from filename") + filename_title = self._extract_title_from_filename(season_path, season_num, episode_num) + if filename_title: + enhanced = self.nfo_manager.enhance_existing_episode_nfo_with_title( + season_path, season_num, episode_num, filename_title + ) + if enhanced: + _log("INFO", f"✅ Enhanced NFO S{season_num:02d}E{episode_num:02d} with title: '{filename_title}'") + else: + _log("WARNING", f"Failed to enhance NFO S{season_num:02d}E{episode_num:02d} with title") + else: + _log("WARNING", f"Could not extract title from filename for S{season_num:02d}E{episode_num:02d}") + # Update file mtime if enabled (NFO is already correct) if config.fix_dir_mtimes and dateadded: self.nfo_manager.set_file_mtime(episode_file, dateadded) From c33a26460dc50dd53807f2bfe3faa527371eea43 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:07:14 -0400 Subject: [PATCH 06/16] debug: Add comprehensive debugging for title extraction - Add detailed debug logging to _extract_title_from_filename() method - Log file search, regex pattern matching, and title cleanup process - Fix Tier 2 processing to call _get_episode_metadata() for title extraction - Add debug logging to _get_episode_metadata() to trace execution flow - Include both .mkv and .mp4 file checking with detailed logs This should help identify why title extraction isn't working for recreated NFO files and provide visibility into the extraction process. --- nfoguard.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/nfoguard.py b/nfoguard.py index 50d9ab4..6e81903 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -654,12 +654,14 @@ class TVProcessor: source = existing_episode["source"] aired = existing_episode.get("aired") - # Create NFO with existing data (no enhanced metadata needed) + # Create NFO with existing data, but try to add title from filename if missing if config.manage_nfo and dateadded: + # Try to extract title from filename as fallback for Tier 2 processing + enhanced_metadata = self._get_episode_metadata(None, season_num, episode_num, season_path) self.nfo_manager.create_episode_nfo( season_path, season_num, episode_num, aired, dateadded, source, config.lock_metadata, - None # No enhanced metadata for database-cached episodes + enhanced_metadata # Include filename-extracted title if available ) # Update file mtime if enabled @@ -777,10 +779,15 @@ class TVProcessor: # If no title from Sonarr, try to extract from filename if not metadata["title"] and season_dir: + _log("DEBUG", f"📁 No title from Sonarr for S{season_num:02d}E{episode_num:02d}, trying filename extraction") filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) if filename_title: metadata["title"] = filename_title - _log("INFO", f"Using filename-extracted title for S{season_num:02d}E{episode_num:02d}: '{filename_title}'") + _log("INFO", f"✅ Using filename-extracted title for S{season_num:02d}E{episode_num:02d}: '{filename_title}'") + else: + _log("DEBUG", f"⚠️ No filename title extracted for S{season_num:02d}E{episode_num:02d}") + elif metadata["title"]: + _log("DEBUG", f"✅ Using Sonarr title for S{season_num:02d}E{episode_num:02d}: '{metadata['title']}'") # Return metadata if we have at least some information if any(metadata.values()): @@ -794,36 +801,51 @@ class TVProcessor: import re # Look for video files matching this episode season_pattern = f"S{season_num:02d}E{episode_num:02d}" + _log("DEBUG", f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}") for video_file in season_dir.glob("*.mkv"): filename = video_file.name + _log("DEBUG", f"🔍 Checking file: {filename}") if season_pattern in filename: + _log("DEBUG", f"✅ Found matching file: {filename}") # Pattern: SeriesName-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv # Extract the part between season/episode and the first bracket - match = re.search(rf'{season_pattern}-(.*?)\[', filename) + pattern = rf'{season_pattern}-(.*?)\[' + _log("DEBUG", f"🔍 Using regex pattern: {pattern}") + match = re.search(pattern, filename) if match: title = match.group(1).strip() + _log("DEBUG", f"🔍 Raw extracted title: '{title}'") # Clean up common encoding artifacts and separators title = title.replace('-', ' ').strip() if title: - _log("DEBUG", f"Extracted title from filename: '{title}' for {season_pattern}") + _log("INFO", f"✅ Extracted title from filename: '{title}' for {season_pattern}") return title + else: + _log("DEBUG", f"⚠️ Title was empty after cleanup") + else: + _log("DEBUG", f"⚠️ Regex pattern didn't match filename") # Also check .mp4 files for video_file in season_dir.glob("*.mp4"): filename = video_file.name + _log("DEBUG", f"🔍 Checking .mp4 file: {filename}") if season_pattern in filename: - match = re.search(rf'{season_pattern}-(.*?)\[', filename) + _log("DEBUG", f"✅ Found matching .mp4 file: {filename}") + pattern = rf'{season_pattern}-(.*?)\[' + match = re.search(pattern, filename) if match: title = match.group(1).strip() + _log("DEBUG", f"🔍 Raw extracted title from .mp4: '{title}'") title = title.replace('-', ' ').strip() if title: - _log("DEBUG", f"Extracted title from filename: '{title}' for {season_pattern}") + _log("INFO", f"✅ Extracted title from .mp4 filename: '{title}' for {season_pattern}") return title except Exception as e: - _log("DEBUG", f"Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + _log("ERROR", f"Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + _log("DEBUG", f"⚠️ No title found in filenames for {season_pattern}") 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]]: From c9567ce70bfbb0a2e017e0c21bbb87eae7bce68b Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:14:12 -0400 Subject: [PATCH 07/16] update: update version number --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 27f9cd3..b9268da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.0 +1.8.1 \ No newline at end of file From b4fe0cb7fe355602a706eb9166acf6c594959747 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:26:09 -0400 Subject: [PATCH 08/16] bump: Version 1.8.2 with title extraction functionality --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b9268da..0bfbd57 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.1 \ No newline at end of file +1.8.2 \ No newline at end of file From e870474f0775dab0b7225e25ce2d9e1c9e8b4e67 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:32:58 -0400 Subject: [PATCH 09/16] debug: Change DEBUG logs to INFO to trace title extraction --- nfoguard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nfoguard.py b/nfoguard.py index 6e81903..7424060 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -779,7 +779,7 @@ class TVProcessor: # If no title from Sonarr, try to extract from filename if not metadata["title"] and season_dir: - _log("DEBUG", f"📁 No title from Sonarr for S{season_num:02d}E{episode_num:02d}, trying filename extraction") + _log("INFO", f"📁 No title from Sonarr for S{season_num:02d}E{episode_num:02d}, trying filename extraction") filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) if filename_title: metadata["title"] = filename_title @@ -801,7 +801,7 @@ class TVProcessor: import re # Look for video files matching this episode season_pattern = f"S{season_num:02d}E{episode_num:02d}" - _log("DEBUG", f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}") + _log("INFO", f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}") for video_file in season_dir.glob("*.mkv"): filename = video_file.name From 39c1e54922ca8c34f143ac75bbc77576a97dc113 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:36:08 -0400 Subject: [PATCH 10/16] debug: Add INFO log to track if _get_episode_metadata is called --- nfoguard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nfoguard.py b/nfoguard.py index 7424060..215e4f1 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -758,6 +758,7 @@ class TVProcessor: def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int, season_dir: Optional[Path] = None) -> Optional[Dict[str, Any]]: """Extract specific episode metadata from series data with filename fallback for titles""" + _log("INFO", f"🔍 _get_episode_metadata called for S{season_num:02d}E{episode_num:02d}, season_dir: {season_dir}") metadata = { "title": None, "overview": None, From f1420cac5823d53abd1bdb03f92b7e93e620c6e7 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 09:40:26 -0400 Subject: [PATCH 11/16] fix: Add title extraction directly to create_episode_nfo method - Add _extract_title_from_filename method to NFOManager class - Integrate title extraction fallback in create_episode_nfo method - Extract titles when NFO lacks title element or has empty title - Support both .mkv and .mp4 files with comprehensive logging - Fix applied to actual NFO creation method being used by episodes Resolves title extraction by working in the correct code path rather than relying on metadata processing that wasn't being called. --- core/nfo_manager.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 94ade70..d16e213 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -248,6 +248,59 @@ class NFOManager: print(f"⚠️ Error enhancing NFO with title: {e}") return False + def _extract_title_from_filename(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[str]: + """Extract episode title from video filename as fallback when metadata doesn't provide it""" + try: + import re + # Look for video files matching this episode + season_pattern = f"S{season_num:02d}E{episode_num:02d}" + print(f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}") + + for video_file in season_dir.glob("*.mkv"): + filename = video_file.name + print(f"🔍 Checking file: {filename}") + if season_pattern in filename: + print(f"✅ Found matching file: {filename}") + # Pattern: SeriesName-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv + # Extract the part between season/episode and the first bracket + pattern = rf'{season_pattern}-(.*?)\[' + print(f"🔍 Using regex pattern: {pattern}") + match = re.search(pattern, filename) + if match: + title = match.group(1).strip() + print(f"🔍 Raw extracted title: '{title}'") + # Clean up common encoding artifacts and separators + title = title.replace('-', ' ').strip() + if title: + print(f"✅ Extracted title from filename: '{title}' for {season_pattern}") + return title + else: + print(f"⚠️ Title was empty after cleanup") + else: + print(f"⚠️ Regex pattern didn't match filename") + + # Also check .mp4 files + for video_file in season_dir.glob("*.mp4"): + filename = video_file.name + print(f"🔍 Checking .mp4 file: {filename}") + if season_pattern in filename: + print(f"✅ Found matching .mp4 file: {filename}") + pattern = rf'{season_pattern}-(.*?)\[' + match = re.search(pattern, filename) + if match: + title = match.group(1).strip() + print(f"🔍 Raw extracted title from .mp4: '{title}'") + title = title.replace('-', ' ').strip() + if title: + print(f"✅ Extracted title from .mp4 filename: '{title}' for {season_pattern}") + return title + + except Exception as e: + print(f"⚠️ Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + + print(f"⚠️ No title found in filenames for {season_pattern}") + return None + def _parse_nfo_with_tolerance(self, nfo_path: Path): """Parse NFO file with tolerance for URLs appended after XML""" try: @@ -643,6 +696,23 @@ class NFOManager: runtime_elem = ET.SubElement(episode, "runtime") runtime_elem.text = str(enhanced_metadata["runtime"]) + # Fallback: Extract title from filename if no title present + if not episode.find("title") or not episode.find("title").text: + print(f"🔍 No title found in NFO for S{season_num:02d}E{episode_num:02d}, attempting filename extraction") + filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) + if filename_title: + # Remove existing empty title element if present + existing_title = episode.find("title") + if existing_title is not None: + episode.remove(existing_title) + + # Add new title element + title_elem = ET.SubElement(episode, "title") + title_elem.text = filename_title + print(f"✅ Added filename-extracted title to NFO: S{season_num:02d}E{episode_num:02d} - '{filename_title}'") + else: + print(f"⚠️ Could not extract title from filename for S{season_num:02d}E{episode_num:02d}") + # Add NFOGuard fields at the bottom # Basic episode info at the end From 6a20337181a0b73ef1bd60ed3f9b43e656659bd4 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 10:33:03 -0400 Subject: [PATCH 12/16] fix: Improve title detection logic to prevent duplicates - Fix flawed title detection that caused duplicates when migrating NFOs - Add robust title validation (check for non-empty, non-whitespace text) - Improve debugging output to show title detection process - Properly handle existing valid titles vs missing/empty titles - Remove invalid title elements before adding filename-extracted titles Resolves duplicate <title> elements in NFO files during migration. --- core/nfo_manager.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index d16e213..b6f0bd2 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -696,15 +696,19 @@ class NFOManager: runtime_elem = ET.SubElement(episode, "runtime") runtime_elem.text = str(enhanced_metadata["runtime"]) - # Fallback: Extract title from filename if no title present - if not episode.find("title") or not episode.find("title").text: - print(f"🔍 No title found in NFO for S{season_num:02d}E{episode_num:02d}, attempting filename extraction") + # Fallback: Extract title from filename if no valid title present + title_elem = episode.find("title") + has_valid_title = title_elem is not None and title_elem.text and title_elem.text.strip() + + print(f"🔍 Title check for S{season_num:02d}E{episode_num:02d}: has_element={title_elem is not None}, has_text={title_elem.text if title_elem is not None else 'N/A'}, has_valid_title={has_valid_title}") + + if not has_valid_title: + print(f"🔍 No valid title found in NFO for S{season_num:02d}E{episode_num:02d}, attempting filename extraction") filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) if filename_title: - # Remove existing empty title element if present - existing_title = episode.find("title") - if existing_title is not None: - episode.remove(existing_title) + # Remove existing empty/invalid title element if present + if title_elem is not None: + episode.remove(title_elem) # Add new title element title_elem = ET.SubElement(episode, "title") @@ -712,6 +716,8 @@ class NFOManager: print(f"✅ Added filename-extracted title to NFO: S{season_num:02d}E{episode_num:02d} - '{filename_title}'") else: print(f"⚠️ Could not extract title from filename for S{season_num:02d}E{episode_num:02d}") + else: + print(f"✅ Valid title already exists in NFO for S{season_num:02d}E{episode_num:02d}: '{title_elem.text.strip()}')") # Add NFOGuard fields at the bottom From 5e3d4845b0c5691e1943633cee60a3e6a3729070 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 10:38:27 -0400 Subject: [PATCH 13/16] fix: Use comprehensive IMDb detection for movie batch validation - Replace simple string search with find_movie_imdb_id() method - Check directory name, filenames, and NFO file content for IMDb ID - Handle both full IMDb IDs (tt26394837) and number formats - Add detailed logging for detected vs expected IMDb matching - Fix validation failures when IMDb ID is in filename/NFO but not directory name Resolves movie webhook processing failures when directory lacks IMDb ID but files contain proper identification. --- nfoguard.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/nfoguard.py b/nfoguard.py index 215e4f1..16ad8ec 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -1621,11 +1621,23 @@ class WebhookBatcher: # 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(): + # Use comprehensive IMDb detection (directory, filenames, NFO content) + detected_imdb = self.nfo_manager.find_movie_imdb_id(path) + + # Check if detected IMDb matches expected (handle both full IMDb IDs and just numbers) + imdb_match = False + if detected_imdb: + if detected_imdb == expected_imdb: + imdb_match = True + elif detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''): + imdb_match = True + + if not imdb_match: _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str}") + _log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}") _log("ERROR", f"This prevents processing wrong movies due to batch corruption") return - _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path") + _log("DEBUG", f"Batch validation passed: Expected IMDb {expected_imdb} matches detected IMDb {detected_imdb}") # Process based on media type if media_type == 'tv': From a8f8e0a7bd25f52b19b3e1fd7f09e7c2fd0de30c Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 13:11:23 -0400 Subject: [PATCH 14/16] Fix movie webhook processing and organize NFOGuard fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix WebhookBatcher missing nfo_manager attribute that caused movie processing errors - Move NFOGuard comment from top to bottom of episode NFOs with other NFOGuard fields - All NFOGuard additions now grouped together at bottom for better organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --- core/nfo_manager.py | 16 +++++----------- nfoguard.py | 5 +++-- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index b6f0bd2..6e47cf2 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -742,22 +742,16 @@ class NFOManager: lockdata = ET.SubElement(episode, "lockdata") lockdata.text = "true" - # Add NFOGuard comment at the beginning - comment_text = f" Created by {self.manager_brand} - Source: {source} " + # Add NFOGuard comment at the bottom with other NFOGuard fields + nfoguard_comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ") + episode.append(nfoguard_comment) # Write file with proper formatting tree = ET.ElementTree(episode) ET.indent(tree, space=" ", level=0) - # Write to string first to add comment - xml_str = ET.tostring(episode, encoding='unicode') - - # Add XML declaration and comment - full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}' - - # Write to file - with open(nfo_path, 'w', encoding='utf-8') as f: - f.write(full_xml) + # Write to file normally (ET will handle the comment properly) + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") diff --git a/nfoguard.py b/nfoguard.py index 16ad8ec..d255908 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -1562,12 +1562,13 @@ class MovieProcessor: class WebhookBatcher: """Batches webhook events to avoid processing storms""" - def __init__(self): + def __init__(self, nfo_manager: NFOManager): 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) + self.nfo_manager = nfo_manager def add_webhook(self, key: str, webhook_data: Dict, media_type: str): """Add webhook to batch queue""" @@ -1720,7 +1721,7 @@ 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() +batcher = WebhookBatcher(nfo_manager) # --------------------------- # Webhook Handlers From 2b6fd27913685f1602462c94797a5470a09fc7ed Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 13:31:27 -0400 Subject: [PATCH 15/16] Fix movie webhook comprehensive IMDb detection and batch processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix movie webhook to use comprehensive IMDb detection (dir, filename, movie.nfo) instead of simple string search - Fix batch processing NameError: use path_obj instead of undefined path variable - Version bump to 1.8.3 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --- VERSION | 2 +- nfoguard.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 0bfbd57..fe4e75f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.2 \ No newline at end of file +1.8.3 \ No newline at end of file diff --git a/nfoguard.py b/nfoguard.py index d255908..f29275c 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -1623,7 +1623,7 @@ class WebhookBatcher: if media_type == 'movie': expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key # Use comprehensive IMDb detection (directory, filenames, NFO content) - detected_imdb = self.nfo_manager.find_movie_imdb_id(path) + detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj) # Check if detected IMDb matches expected (handle both full IMDb IDs and just numbers) imdb_match = False @@ -1834,9 +1834,18 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): _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}") + # Verify the path contains the expected IMDb ID using comprehensive detection + detected_imdb = nfo_manager.find_movie_imdb_id(Path(container_path)) + imdb_match = False + if detected_imdb: + if detected_imdb == imdb_id or detected_imdb.replace('tt', '') == imdb_id.replace('tt', ''): + imdb_match = True + + if not imdb_match: + _log("WARNING", f"IMDb ID {imdb_id} not found via comprehensive detection in {container_path}") + _log("DEBUG", f"Detected IMDb: {detected_imdb}, Expected: {imdb_id}") + else: + _log("DEBUG", f"IMDb ID validated: {imdb_id} matches detected {detected_imdb}") # Create movie-specific webhook data with proper path validation movie_webhook_data = { From 3e8ea79cbb5bff974fcc6ead51740b204d327a17 Mon Sep 17 00:00:00 2001 From: SBCrumb <sbcrumb@kickthetree.com> Date: Thu, 25 Sep 2025 14:23:28 -0400 Subject: [PATCH 16/16] update emby dll --- Emby-DLL/NFOGuard.Emby.Plugin.dll | Bin 1029632 -> 1033216 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Emby-DLL/NFOGuard.Emby.Plugin.dll b/Emby-DLL/NFOGuard.Emby.Plugin.dll index 009262f4d38df99b14ae39b73f524b02d7e305f3..8369710f27431feb01dcd83570eeac0e69133ca9 100644 GIT binary patch delta 8571 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 delta 6321 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