From cbfd21c4eead1754c1fb6ee0eb1176d2e31e12b3 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Wed, 17 Sep 2025 21:43:45 -0400 Subject: [PATCH] feat: upload intial code Uploading intial Project Code --- Dockerfile | 41 + SETUP.md | 153 +++ VERSION | 1 + clients/external_clients.py | 597 ++++++++++ clients/radarr_client.py | 534 +++++++++ clients/radarr_db_client.py | 646 ++++++++++ clients/sonarr_client.py | 286 +++++ core/database.py | 268 +++++ core/logging.py | 53 + core/nfo_manager.py | 415 +++++++ core/path_mapper.py | 105 ++ nfoguard.py | 2208 +++++++++++++++++++++++++++++++++++ requirements.txt | 8 + 13 files changed, 5315 insertions(+) create mode 100644 Dockerfile create mode 100644 SETUP.md create mode 100644 VERSION create mode 100644 clients/external_clients.py create mode 100644 clients/radarr_client.py create mode 100644 clients/radarr_db_client.py create mode 100644 clients/sonarr_client.py create mode 100644 core/database.py create mode 100644 core/logging.py create mode 100644 core/nfo_manager.py create mode 100644 core/path_mapper.py create mode 100644 nfoguard.py create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5514048 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONIOENCODING=utf-8 + +# Install system dependencies including PostgreSQL client libraries +RUN apt-get update && apt-get install -y \ + curl \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Create app user and directory +RUN useradd --create-home --shell /bin/bash app +WORKDIR /app + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Set ownership +RUN chown -R app:app /app + +# Switch to app user +USER app + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Expose port +EXPOSE 8080 + +# Run the application +CMD ["python", "nfoguard.py"] \ No newline at end of file diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..c634feb --- /dev/null +++ b/SETUP.md @@ -0,0 +1,153 @@ +# NFOGuard Setup Guide + +## 🔐 Secure Configuration Setup + +NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting: + +- **`.env`** - Main configuration (safe to share for debugging) +- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git) + +### Step 1: Copy Configuration Templates + +```bash +# Copy main configuration +cp .env.template .env + +# Copy secrets configuration +cp .env.secrets.template .env.secrets +``` + +### Step 2: Configure Main Settings + +Edit your `.env` file with your specific paths and preferences: + +```bash +# Media paths (adjust to your directory structure) +TV_PATHS=/media/TV/tv,/media/TV/tv6 +MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 + +# Database connection details +RADARR_DB_HOST=radarr-postgres +RADARR_DB_PORT=5432 +RADARR_DB_NAME=radarr +RADARR_DB_USER=radarr + +# Processing preferences +PREFER_RELEASE_DATES_OVER_FILE_DATES=true +ALLOW_FILE_DATE_FALLBACK=false +RELEASE_DATE_PRIORITY=digital,physical,theatrical + +# TV webhook processing mode (v0.6.0+) +TV_WEBHOOK_PROCESSING_MODE=targeted +``` + +### Step 3: Configure Secrets + +Edit your `.env.secrets` file with your actual API keys and passwords: + +```bash +# Database password +RADARR_DB_PASSWORD=your_actual_radarr_password + +# TMDB API key (required for release date detection) +TMDB_API_KEY=your_actual_tmdb_api_key + +# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation) +SONARR_API_KEY=your_actual_sonarr_api_key + +# Optional API keys +RADARR_API_KEY=your_radarr_api_key +OMDB_API_KEY=your_omdb_api_key +``` + +### Step 4: Verify Configuration + +Test your setup: + +```bash +# Test database connections +curl -X POST "http://localhost:8080/test/bulk-update" + +# Test movie scanning +curl -X POST "http://localhost:8080/test/movie-scan" + +# Check system health +curl "http://localhost:8080/health" +``` + +## 🔒 Security Features + +### API Key Masking +All API keys and passwords are automatically masked in logs: +``` +[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked*** +[2025-09-09T12:34:56] INFO: Database connection password=***masked*** +``` + +### Sensitive Data Separation +- **Main `.env`**: Paths, preferences, URLs (safe to share) +- **`.env.secrets`**: API keys, passwords (never commit to version control) +- **Automatic loading**: Both files loaded automatically at startup + +### Git Protection +The `.gitignore` file prevents accidental commits: +``` +.env +.env.secrets +.env.local +``` + +## 🛠 Troubleshooting + +### "Environment files not loaded" Warning +Install python-dotenv: +```bash +pip install python-dotenv==1.0.0 +# or +docker-compose build # rebuilds with updated requirements.txt +``` + +### Sharing Configuration for Help +You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared. + +### Migration from Old Setup +If you have an existing `.env` with API keys: +1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets` +2. Remove sensitive data from `.env` +3. Restart NFOGuard + +## 🎯 Docker Compose Example + +```yaml +version: '3.8' +services: + nfoguard: + image: ghcr.io/your-org/nfoguard:latest + container_name: nfoguard + ports: + - "8080:8080" + volumes: + - /path/to/your/media:/media:rw + - ./data:/app/data + - ./.env:/app/.env:ro # Main configuration + - ./.env.secrets:/app/.env.secrets:ro # Secrets + environment: + - PORT=8080 + depends_on: + - radarr-postgres +``` + +## 📋 Configuration Reference + +### Main Configuration (.env) +- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH` +- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY` +- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA` +- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL` + +### Secrets Configuration (.env.secrets) +- **Database**: `RADARR_DB_PASSWORD` +- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY` +- **Optional**: `JELLYSEERR_API_KEY` + +This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment. \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9f05f9f --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.6.5 diff --git a/clients/external_clients.py b/clients/external_clients.py new file mode 100644 index 0000000..1d4c16c --- /dev/null +++ b/clients/external_clients.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +""" +External API clients for TMDB, OMDb, and Jellyseerr +""" +import json +import os +import time +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional, Tuple +from urllib.parse import urlencode, quote +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import URLError, HTTPError + +from core.logging import _log + + +def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]: + """Make GET request and return JSON""" + try: + req = UrlRequest(url, headers=headers or {"Accept": "application/json"}) + with urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + # Handle specific HTTP errors more gracefully + if suppress_404 and e.code in [400, 404]: + _log("DEBUG", f"TVDB API: {url} - item not found (HTTP {e.code}) - this is expected") + return None + else: + _log("WARNING", f"GET {url} failed: HTTP Error {e.code}: {e.reason}") + return None + except Exception as e: + _log("WARNING", f"GET {url} failed: {e}") + return None + + +def _parse_date_to_iso(date_str: str) -> Optional[str]: + """Parse various date formats to ISO string""" + if not date_str or date_str == "N/A": + return None + try: + if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD + dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + + +class TVDBClient: + """The TV Database API client for IMDB to TVDB ID conversion""" + + def __init__(self, api_key: str = None): + self.api_key = api_key or os.environ.get("TVDB_API_KEY", "") + self.base_url = "https://api4.thetvdb.com/v4" + self._token = None + self._token_expires = 0 + + def _get_token(self) -> Optional[str]: + """Get TVDB auth token (cached)""" + if not self.api_key: + _log("DEBUG", "TVDB: No API key provided") + return None + + if time.time() < self._token_expires and self._token: + return self._token + + try: + _log("DEBUG", f"TVDB: Authenticating with API key: {self.api_key[:8]}...") + req = UrlRequest( + f"{self.base_url}/login", + data=json.dumps({"apikey": self.api_key}).encode('utf-8'), + headers={"Content-Type": "application/json"} + ) + with urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + _log("DEBUG", f"TVDB login response: {data}") + if data.get("status") == "success": + self._token = data["data"]["token"] + self._token_expires = time.time() + 3600 # 1 hour + _log("INFO", f"✅ TVDB: Authentication successful") + return self._token + else: + _log("WARNING", f"TVDB login failed: {data}") + except Exception as e: + _log("WARNING", f"TVDB login failed: {e}") + return None + + def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]: + """Convert IMDB ID to TVDB series ID using TVDB v4 API""" + token = self._get_token() + if not token: + return None + + try: + # Try the official v4 search endpoint first + # According to docs: /search?query=imdb_id&type=series + url = f"{self.base_url}/search?query={imdb_id}&type=series&meta=translations" + headers = {"Authorization": f"Bearer {token}"} + + _log("DEBUG", f"TVDB: Searching for {imdb_id} using /search endpoint") + data = _get_json(url, headers=headers, suppress_404=True) + + if data and data.get("status") == "success" and data.get("data"): + series_list = data["data"] + _log("DEBUG", f"TVDB search response: found {len(series_list)} results") + + # Look for exact IMDB match in results + for series in series_list: + # Check if this series has the IMDB ID we're looking for + remote_ids = series.get("remote_ids", []) + for remote in remote_ids: + if (remote.get("source_name") == "IMDB" and + remote.get("remote_id") == imdb_id): + tvdb_id = series.get("tvdb_id") or series.get("id") + if tvdb_id: + _log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id}") + return str(tvdb_id) + + # If no exact match, try the first result if it looks promising + if series_list: + first_result = series_list[0] + tvdb_id = first_result.get("tvdb_id") or first_result.get("id") + if tvdb_id: + _log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (first result)") + return str(tvdb_id) + + # If search didn't work, try the legacy remoteid endpoint + _log("DEBUG", f"TVDB: Trying legacy remoteid endpoint for {imdb_id}") + url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series" + data = _get_json(url, headers=headers, suppress_404=True) + + if data and data.get("status") == "success" and data.get("data"): + series_list = data["data"] + if series_list and len(series_list) > 0: + tvdb_id = series_list[0].get("id") + if tvdb_id: + _log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (legacy endpoint)") + return str(tvdb_id) + + # If we get here, the series wasn't found in TVDB + _log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)") + + except Exception as e: + _log("WARNING", f"TVDB API error for {imdb_id}: {e}") + + return None + + +class TMDBClient: + """The Movie Database API client""" + + def __init__(self, api_key: str = None, primary_country: str = "US"): + self.api_key = api_key or os.environ.get("TMDB_API_KEY", "") + self.primary_country = primary_country.upper() + self.enabled = bool(self.api_key) + + def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]: + """Make GET request to TMDB API""" + if not self.enabled: + return None + + params = params or {} + params["api_key"] = self.api_key + url = f"https://api.themoviedb.org/3{path}?{urlencode(params)}" + return _get_json(url, timeout=20) + + def find_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find movie by IMDb ID""" + result = self._get(f"/find/{quote(imdb_id)}", {"external_source": "imdb_id"}) + if result and result.get("movie_results"): + return result["movie_results"][0] + return None + + def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]: + """Get detailed movie information""" + return self._get(f"/movie/{tmdb_id}") + + def get_digital_release_date(self, imdb_id: str) -> Optional[str]: + """Get digital release date for a movie""" + _log("INFO", f"🔍 TMDB: Looking for digital release date for {imdb_id}") + movie = self.find_by_imdb(imdb_id) + if not movie: + _log("WARNING", f"❌ TMDB: Movie not found for {imdb_id}") + return None + + tmdb_id = movie.get("id") + _log("INFO", f"✅ TMDB: Found movie ID {tmdb_id} for {imdb_id}") + if not tmdb_id: + return None + + release_dates = self._get(f"/movie/{tmdb_id}/release_dates") + if not release_dates: + _log("WARNING", f"❌ TMDB: No release dates data for movie {tmdb_id}") + return None + + _log("INFO", f"🔍 TMDB: Got release dates data, looking for {self.primary_country} digital releases") + + # Debug: Show all available countries + countries = [entry.get("iso_3166_1") for entry in release_dates.get("results", [])] + _log("INFO", f"📍 TMDB: Available countries: {countries}") + + for entry in release_dates.get("results", []): + country = entry.get("iso_3166_1", "").upper() + if country != self.primary_country: + continue + + _log("INFO", f"🎯 TMDB: Found {country} release data") + releases = entry.get("release_dates", []) + _log("INFO", f"🎬 TMDB: Release types available: {[r.get('type') for r in releases]}") + + # Collect all available releases with their types and dates + available_releases = [] + for release in releases: + release_type = release.get("type") + release_date = release.get("release_date") + _log("INFO", f"📅 TMDB: Type {release_type}, Date: {release_date}") + + if release_date: + parsed_date = _parse_date_to_iso(release_date) + if parsed_date: + available_releases.append((release_type, parsed_date)) + + # Apply TMDB release type priority order + tmdb_priority = self._get_tmdb_type_priority() + for preferred_type in tmdb_priority: + for release_type, parsed_date in available_releases: + if release_type == preferred_type: + release_type_names = { + 1: "Premiere", 2: "Limited Theatrical", 3: "Theatrical", + 4: "Digital", 5: "Physical", 6: "TV Premiere" + } + type_name = release_type_names.get(release_type, f"Type {release_type}") + _log("INFO", f"✅ TMDB: Selected {type_name} release date: {parsed_date} (priority: {tmdb_priority})") + return parsed_date + + _log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in {self.primary_country}") + return None + + def _get_tmdb_type_priority(self) -> List[int]: + """Get TMDB release type priority order from environment""" + # Default priority: Digital first, then Physical, then Theatrical, then others + default_priority = "4,5,3,2,6,1" # digital,physical,theatrical,limited,tv,premiere + + priority_str = os.environ.get("TMDB_TYPE_PRIORITY", default_priority) + try: + # Parse comma-separated numbers + priority_list = [int(x.strip()) for x in priority_str.split(",") if x.strip().isdigit()] + if priority_list: + _log("DEBUG", f"TMDB type priority: {priority_list}") + return priority_list + else: + _log("WARNING", f"Invalid TMDB_TYPE_PRIORITY '{priority_str}', using default") + return [4, 5, 3, 2, 6, 1] + except Exception as e: + _log("WARNING", f"Error parsing TMDB_TYPE_PRIORITY: {e}, using default") + return [4, 5, 3, 2, 6, 1] + + def get_theatrical_release_date(self, imdb_id: str) -> Optional[str]: + """Get theatrical release date for a movie""" + movie = self.find_by_imdb(imdb_id) + if not movie: + return None + + tmdb_id = movie.get("id") + if not tmdb_id: + return None + + release_dates = self._get(f"/movie/{tmdb_id}/release_dates") + if not release_dates: + return None + + for entry in release_dates.get("results", []): + if entry.get("iso_3166_1", "").upper() != self.primary_country: + continue + + for release in entry.get("release_dates", []): + if release.get("type") == 3 and release.get("release_date"): # Theatrical release + return _parse_date_to_iso(release["release_date"]) + + return None + + def get_physical_release_date(self, imdb_id: str) -> Optional[str]: + """Get physical release date (DVD/Blu-ray) for a movie""" + movie = self.find_by_imdb(imdb_id) + if not movie: + return None + + tmdb_id = movie.get("id") + if not tmdb_id: + return None + + release_dates = self._get(f"/movie/{tmdb_id}/release_dates") + if not release_dates: + return None + + for entry in release_dates.get("results", []): + if entry.get("iso_3166_1", "").upper() != self.primary_country: + continue + + for release in entry.get("release_dates", []): + if release.get("type") == 5 and release.get("release_date"): # Physical release + return _parse_date_to_iso(release["release_date"]) + + return None + + def get_tv_season_episodes(self, tv_id: int, season_number: int) -> Dict[int, str]: + """Get episode air dates for a TV season""" + result = self._get(f"/tv/{tv_id}/season/{season_number}") + episodes = {} + + if result: + for episode in result.get("episodes", []): + ep_num = episode.get("episode_number") + air_date = episode.get("air_date") + if isinstance(ep_num, int) and air_date: + episodes[ep_num] = air_date + + return episodes + + +class OMDbClient: + """Open Movie Database API client""" + + def __init__(self, api_key: str = None): + self.api_key = api_key or os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def get_movie_details(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Get movie details from OMDb""" + if not self.enabled: + return None + + params = {"i": imdb_id, "apikey": self.api_key} + url = f"http://www.omdbapi.com/?{urlencode(params)}" + result = _get_json(url, timeout=15) + + if result and result.get("Response") == "True": + return result + return None + + def get_dvd_release_date(self, imdb_id: str) -> Optional[str]: + """Get DVD/digital release date""" + details = self.get_movie_details(imdb_id) + if not details: + return None + + dvd_date = details.get("DVD") or details.get("Released") + if not dvd_date or dvd_date == "N/A": + return None + + # Try to parse various date formats + for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"): + try: + dt = datetime.strptime(dvd_date, fmt).replace(tzinfo=timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + continue + + return None + + def get_tv_season_episodes(self, imdb_id: str, season_number: int) -> Dict[int, str]: + """Get episode release dates for a TV season""" + if not self.enabled: + return {} + + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = f"http://www.omdbapi.com/?{urlencode(params)}" + result = _get_json(url, timeout=15) + + episodes = {} + if result and result.get("Response") == "True": + for episode in result.get("Episodes", []): + try: + ep_num = int(episode.get("Episode", 0)) + released = episode.get("Released") + if ep_num and released and released != "N/A": + episodes[ep_num] = released + except Exception: + continue + + return episodes + + +class JellyseerrClient: + """Jellyseerr API client""" + + def __init__(self, base_url: str = None, api_key: str = None): + self.base_url = (base_url or os.environ.get("JELLYSEERR_URL", "")).rstrip("/") + self.api_key = api_key or os.environ.get("JELLYSEERR_API_KEY", "") + self.enabled = bool(self.base_url and self.api_key) + + def _get(self, path: str) -> Optional[Dict[str, Any]]: + """Make GET request to Jellyseerr API""" + if not self.enabled: + return None + + url = f"{self.base_url}/api/v1{path}" + headers = {"X-Api-Key": self.api_key, "Accept": "application/json"} + return _get_json(url, timeout=20, headers=headers) + + def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]: + """Get movie details from Jellyseerr""" + return self._get(f"/movie/{tmdb_id}") + + def get_digital_release_dates(self, tmdb_id: int) -> List[str]: + """Get digital release date candidates from Jellyseerr""" + details = self.get_movie_details(tmdb_id) + if not details: + return [] + + candidates = [] + + # Check direct fields + for field in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"): + value = details.get(field) + if value: + iso_date = _parse_date_to_iso(value) + if iso_date: + candidates.append(iso_date) + + # Check release dates array + for array_field in ("releaseDates", "releases", "dates"): + release_array = details.get(array_field) + if not isinstance(release_array, list): + continue + + for release in release_array: + if not isinstance(release, dict): + continue + + release_type = (release.get("type") or release.get("label") or "").lower() + release_date = release.get("date") or release.get("releaseDate") + + if release_date and ("digital" in release_type or "vod" in release_type or "stream" in release_type): + iso_date = _parse_date_to_iso(release_date) + if iso_date: + candidates.append(iso_date) + + return candidates + + +class ExternalClientManager: + """Manager for all external API clients""" + + def __init__(self): + # Get country from environment, default to US + tmdb_country = os.environ.get("TMDB_COUNTRY", "US") + _log("INFO", f"🌍 TMDB: Initializing with country: {tmdb_country}") + + self.tmdb = TMDBClient(primary_country=tmdb_country) + self.omdb = OMDbClient() + self.jellyseerr = JellyseerrClient() + self.tvdb = TVDBClient() + + def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str], enable_smart_validation: bool = True) -> Optional[Tuple[str, str]]: + """Get release date using configurable priority order with smart date validation""" + + # Get all possible release dates + release_options = {} + + if self.tmdb.enabled: + # Digital release + digital_date = self.tmdb.get_digital_release_date(imdb_id) + if digital_date: + release_options["digital"] = (digital_date, "tmdb:digital") + + # Physical release + physical_date = self.tmdb.get_physical_release_date(imdb_id) + if physical_date: + release_options["physical"] = (physical_date, "tmdb:physical") + + # Theatrical release + theatrical_date = self.tmdb.get_theatrical_release_date(imdb_id) + if theatrical_date: + release_options["theatrical"] = (theatrical_date, "tmdb:theatrical") + + # Add OMDb options + if self.omdb.enabled: + omdb_date = self.omdb.get_dvd_release_date(imdb_id) + if omdb_date and "physical" not in release_options: + release_options["physical"] = (omdb_date, "omdb:dvd") + + # Add Jellyseerr digital releases + if self.jellyseerr.enabled and self.tmdb.enabled and "digital" not in release_options: + tmdb_movie = self.tmdb.find_by_imdb(imdb_id) + if tmdb_movie: + tmdb_id = tmdb_movie.get("id") + if tmdb_id: + jellyseerr_dates = self.jellyseerr.get_digital_release_dates(tmdb_id) + if jellyseerr_dates: + earliest_jellyseerr = min(jellyseerr_dates) + release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital") + + # Smart date validation: Check if priority order makes sense given the actual dates + if enable_smart_validation and len(release_options) > 1: + validated_choice = self._validate_date_choice(release_options, priority_order) + if validated_choice: + return validated_choice + + # Return first available option according to priority (fallback behavior) + for priority in priority_order: + if priority in release_options: + return release_options[priority] + + return None + + def _validate_date_choice(self, release_options: Dict[str, Tuple[str, str]], priority_order: List[str]) -> Optional[Tuple[str, str]]: + """Validate date choice and prefer theatrical if digital/physical are unreasonably late""" + from datetime import datetime, timezone + import os + + # Get configuration for maximum gap (default: 10 years) + max_reasonable_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) + + # Parse all available dates + parsed_dates = {} + for release_type, (date_str, source) in release_options.items(): + try: + parsed_dates[release_type] = (datetime.fromisoformat(date_str.replace('Z', '+00:00')), source) + except Exception: + continue + + if not parsed_dates or "theatrical" not in parsed_dates: + return None # No smart validation possible without theatrical date + + theatrical_date, theatrical_source = parsed_dates["theatrical"] + + # Check each priority option against theatrical date + for priority in priority_order: + if priority == "theatrical": + continue # Skip theatrical in this validation + + if priority in parsed_dates: + priority_date, priority_source = parsed_dates[priority] + + # Calculate the gap in years + gap = (priority_date - theatrical_date).days / 365.25 + + # If the gap is too large, skip this priority and continue + if gap > max_reasonable_gap_years: + print(f"[SMART VALIDATION] {priority} date {priority_date.strftime('%Y-%m-%d')} is {gap:.1f} years after theatrical {theatrical_date.strftime('%Y-%m-%d')}, preferring theatrical") + continue + + # This priority option is reasonable, use it + return (priority_date.isoformat(timespec="seconds"), f"{priority_source} (validated)") + + # If all priority options are unreasonable, fall back to theatrical + if "theatrical" in release_options: + theatrical_date_str, theatrical_source = release_options["theatrical"] + return (theatrical_date_str, f"{theatrical_source} (smart fallback)") + + return None + + def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]: + """Get digital release date candidates from all sources (legacy method)""" + candidates = [] + + # Try the new priority system with digital-first fallback + result = self.get_release_date_by_priority(imdb_id, ["digital", "physical", "theatrical"]) + if result: + candidates.append(result) + + return candidates + + def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]: + """Get the earliest digital release date (legacy method)""" + candidates = self.get_digital_release_candidates(imdb_id) + return candidates[0] if candidates else None + + def get_tvdb_series_id(self, imdb_id: str) -> Optional[str]: + """Get TVDB series ID from IMDB ID""" + # Check if TVDB lookups are disabled + if os.environ.get("DISABLE_TVDB", "false").lower() in ["true", "1", "yes"]: + _log("DEBUG", "TVDB lookups disabled via DISABLE_TVDB environment variable") + return None + + if not self.tvdb.api_key: + _log("INFO", "TVDB API key not configured, skipping TVDB ID lookup (set TVDB_API_KEY to enable)") + return None + + return self.tvdb.imdb_to_tvdb_series_id(imdb_id) + + +if __name__ == "__main__": + # Test the clients + manager = ExternalClientManager() + + test_imdb = "tt1596343" # Example IMDb ID + digital_candidates = manager.get_digital_release_candidates(test_imdb) + print(f"Digital release candidates for {test_imdb}: {digital_candidates}") + + earliest = manager.get_earliest_digital_release(test_imdb) + if earliest: + print(f"Earliest digital release: {earliest[0]} ({earliest[1]})") + else: + print("No digital release dates found") \ No newline at end of file diff --git a/clients/radarr_client.py b/clients/radarr_client.py new file mode 100644 index 0000000..71becaf --- /dev/null +++ b/clients/radarr_client.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +"""Enhanced Radarr API client with improved import date detection""" + +import json +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple +from urllib.parse import urlencode, urljoin +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import URLError, HTTPError + +from core.logging import _log + +# Import path mapper for proper path handling +try: + from core.path_mapper import path_mapper +except ImportError: + # Fallback for standalone testing + class DummyPathMapper: + def analyze_import_source_path(self, path): + return "/downloads/" in path.lower(), "basic_check" + + def is_download_path(self, path): + return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower() + + path_mapper = DummyPathMapper() + +# Import database client for enhanced performance +try: + from clients.radarr_db_client import RadarrDbClient +except ImportError: + RadarrDbClient = None + + +class RadarrClient: + """Enhanced Radarr API client with improved import date detection""" + + # Radarr History API event types (HistoryEventType enum) + # From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs + EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer + EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library + EVENT_TYPE_FAILED = 4 # Download or import failed + EVENT_TYPE_RETAGGED = 6 # Files were tagged + EVENT_TYPE_RENAMED = 8 # Files were renamed + + # Event types that indicate real imports + REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events + + # These are now handled by path_mapper, but keeping for backward compatibility + DOWNLOAD_PATH_INDICATORS = [ + '/downloads/', '/download/', '/completed/', '/importing/', + '/nzbs/', '/torrents/', '/temp/', '/tmp/', + 'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission', + 'usenet', 'torrent', 'radarr', 'completed', 'processing' + ] + + def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.retries = max(0, retries) + + # Initialize database client - REQUIRED for operation + self.db_client = None + if RadarrDbClient: + try: + self.db_client = RadarrDbClient.from_env() + if self.db_client: + _log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled") + else: + _log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled") + except Exception as e: + _log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}") + self.db_client = None + else: + _log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies") + + def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: + """Make GET request to Radarr API with retries""" + if not self.api_key: + return None + + attempt = 0 + last_err = None + + while attempt <= self.retries: + try: + params = params or {} + params["apikey"] = self.api_key + url = urljoin(f"{self.base_url}/", path.lstrip("/")) + + if params: + url = url + ("&" if "?" in url else "?") + urlencode(params) + + _log("DEBUG", f"Radarr API Request: {url}") + req = UrlRequest(url, headers={"Accept": "application/json"}) + + with urlopen(req, timeout=self.timeout) as resp: + data = resp.read().decode("utf-8") + result = json.loads(data) + return result + + except (URLError, HTTPError, json.JSONDecodeError) as e: + last_err = e + _log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}") + time.sleep(min(2 ** attempt, 5)) # Exponential backoff + attempt += 1 + + _log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}") + return None + + def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find movie by IMDb ID - DATABASE ONLY mode""" + imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" + _log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}") + + # Database required - no API fallback + if self.db_client: + try: + movie = self.db_client.get_movie_by_imdb(imdb_id) + if movie: + _log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})") + return movie + else: + _log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}") + return None + except Exception as e: + _log("ERROR", f"Database lookup failed: {e}") + return None + + # No database client available + _log("ERROR", "Database client required for movie lookup - API mode disabled") + return None + + def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]: + """ + Analyze a history event to determine if it's a real import. + + Args: + event: The history event to analyze + movie_info: Optional movie information to validate paths against + + Returns: + (is_real_import, reason, date_iso) + """ + event_type = event.get("eventType") + date_str = event.get("date") + event_data = event.get("data", {}) + + # Parse date + date_iso = None + if date_str: + try: + date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + except Exception: + date_iso = None + + if not date_iso: + return False, "no_valid_date", None + + # Convert event type to int if needed + try: + event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type + except (ValueError, TypeError): + event_type_int = None + + # Check if event type indicates import + if event_type_int not in self.REAL_IMPORT_EVENT_TYPES: + return False, f"event_type_not_import({event_type})", date_iso + + # Get all possible source paths/titles + source_items = [] + + # Get both sourcePath and importedPath if available + if event_data: + for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']: + if event_data.get(key): + source_items.append(event_data[key]) + + # Also check event root for these fields + for key in ['sourcePath', 'sourceTitle', 'importedPath']: + if event.get(key): + source_items.append(event[key]) + + # Clean up and make unique + source_items = [str(s).lower().strip() for s in source_items if s] + source_items = list(set(source_items)) # Remove duplicates + + if not source_items: + return False, "no_source_paths", date_iso + + # If we have movie info, look for title/year match + if movie_info: + movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.') + movie_year = str(movie_info.get('year', '')) + + for source in source_items: + # Clean up source text for comparison + source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.') + + # Check if both title and year are in the source + if movie_title and movie_year: + if movie_title in source_clean and movie_year in source_clean: + _log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}") + return True, "matched_title_and_year", date_iso + + # Also check for downloads path as secondary validation + if path_mapper.is_download_path(source): + _log("DEBUG", f"Source is from downloads: {source}") + return True, "from_downloads_path", date_iso + + _log("DEBUG", f"⚠️ No match found in sources: {source_items}") + return False, "no_title_year_match", date_iso + + # Fallback to basic path validation if no movie info + for source in source_items: + if path_mapper.is_download_path(source): + return True, "basic_download_path_match", date_iso + + return False, "no_download_path_match", date_iso + + def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]: + """ + Find earliest real import event with optimized querying. + Stops as soon as we find valid import events instead of loading everything. + """ + _log("INFO", f"Finding earliest import for movie_id {movie_id}") + + # Get movie info for path validation + movie_info = self._get(f"/api/v3/movie/{movie_id}") + if not movie_info or not isinstance(movie_info, dict): + _log("ERROR", f"Could not get movie info for ID {movie_id}") + return None + + earliest_real_import = None + first_grab = None + page = 1 + page_size = 50 # Smaller pages for faster iteration + total_processed = 0 + + while page <= 20: # Safety limit + # Get history in chronological order + data = self._get("/api/v3/history", { + "movieId": str(movie_id), + "page": page, + "pageSize": page_size, + "sortKey": "date", + "sortDirection": "ascending" + }) + + if not data: + break + + items = data if isinstance(data, list) else data.get("records", []) + if not items: + break + + _log("DEBUG", f"Page {page}: Processing {len(items)} events") + + for event in items: + total_processed += 1 + event_type = event.get("eventType") + if not event_type: + continue + + # Convert event type to int or handle string types + try: + if isinstance(event_type, str): + # Map string event types to numeric values + string_to_numeric = { + "grabbed": self.EVENT_TYPE_GRABBED, + "downloadFolderImported": self.EVENT_TYPE_IMPORTED, + "movieFileImported": self.EVENT_TYPE_IMPORTED, + "downloadFailed": self.EVENT_TYPE_FAILED, + "movieFileRenamed": self.EVENT_TYPE_RENAMED, + "movieFileDeleted": 5 # Not in our constants but common + } + event_type = string_to_numeric.get(event_type, 0) + else: + event_type = int(event_type) + except (ValueError, TypeError): + _log("DEBUG", f"Unknown event type: {event_type}") + continue + + # Check for grab events (type 1) - but validate it's a real download + if event_type == self.EVENT_TYPE_GRABBED and not first_grab: + if event.get("date"): + try: + # Get event data to check if this is a real grab with download info + event_data = event.get("data", {}) + if isinstance(event_data, str): + try: + event_data = json.loads(event_data) + except (json.JSONDecodeError, AttributeError): + event_data = {} + + # Check if this grab has actual download/indexer info + source_title = event_data.get("sourceTitle", "") + indexer = event_data.get("indexer", "") + + # Only count grabs that have actual download metadata + if source_title or indexer: + first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + _log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}") + else: + _log("DEBUG", f"Skipping grab event without download info at {event.get('date')}") + except Exception: + pass + + # Only process import events (type 3) + if event_type != self.EVENT_TYPE_IMPORTED: + continue + + # Get imported path from event data + imported_path = None + event_data = event.get("data", {}) + + # Handle both string and dict data + if isinstance(event_data, str): + try: + event_data = json.loads(event_data) + except (json.JSONDecodeError, AttributeError) as e: + _log("DEBUG", f"Failed to parse event data JSON: {e}") + continue + elif not isinstance(event_data, dict): + continue + + imported_path = event_data.get("importedPath", "") + if not imported_path: + continue + + imported_path = imported_path.lower() + + movie_imdb = (movie_info.get("imdbId", "") or "").lower() + movie_title = (movie_info.get("title", "") or "").lower() + movie_year = str(movie_info.get("year", "")) + + # First try IMDb ID match + # First try IMDb ID match + if movie_imdb and ( + f"[imdb-{movie_imdb}]" in imported_path or + f"[{movie_imdb}]" in imported_path or + movie_imdb in imported_path + ): + _log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}") + date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + _log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}") + earliest_real_import = date_iso + break + + # Then try title/year match with fuzzy path cleaning + if movie_title and movie_year: + # Clean strings for comparison + clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower() + clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower() + + # Look for both title and year in the path + if clean_title in clean_path and movie_year in clean_path: + date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + _log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})") + _log("INFO", f"✅ FOUND IMPORT at {date_iso}") + earliest_real_import = date_iso + break + + # Fallback to normal import analysis + is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info) + if is_real and date_iso: + source_path = (event.get("data", {}).get("sourcePath", "") or + event.get("data", {}).get("droppedPath", "") or + event.get("sourcePath", "") or + event.get("data", {}).get("importedPath", "") or + event.get("importedPath", "") or "").lower() + + if source_path: + # Check for path match + movie_imdb = (movie_info.get("imdbId", "") or "").lower() + if movie_imdb and ( + f"[imdb-{movie_imdb}]" in source_path or + f"[{movie_imdb}]" in source_path or + movie_imdb in source_path + ): + _log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}") + earliest_real_import = date_iso + break + + # Check for title/year match + movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".") + movie_year = str(movie_info.get("year", "")) + if movie_title and movie_year and movie_title in source_path and movie_year in source_path: + _log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}") + earliest_real_import = date_iso + break + elif event_type == 3: + _log("DEBUG", f"⚠️ Skipped import event: {reason}") + + # If we found a real import, no need to continue + if earliest_real_import: + break + + # If we got less than page size, we've seen all events + if len(items) < page_size: + break + + page += 1 + + _log("INFO", f"Processed {total_processed} events across {page-1} pages") + + if earliest_real_import: + _log("INFO", f"✅ Using earliest real import: {earliest_real_import}") + return earliest_real_import + + if first_grab: + _log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}") + return first_grab + + _log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}") + return None + + def movie_files(self, movie_id: int) -> List[Dict[str, Any]]: + """Get movie files for a movie - DATABASE ONLY mode""" + if self.db_client: + _log("INFO", "Using database for movie files lookup") + # Database handles this internally in get_movie_file_date() + return [] + + _log("ERROR", "Database client required for movie files - API mode disabled") + return [] + + def earliest_file_dateadded(self, movie_id: int) -> Optional[str]: + """Get earliest file dateAdded - DATABASE ONLY mode""" + if self.db_client: + try: + return self.db_client.get_movie_file_date(movie_id) + except Exception as e: + _log("ERROR", f"Database file date query failed: {e}") + return None + + _log("ERROR", "Database client required for file dates - API mode disabled") + return None + + def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]: + """ + Get the best import date for a movie - DATABASE ONLY mode. + + Returns: + (date_iso, source_description) + """ + # Database required - no API fallback + if self.db_client: + try: + date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date) + if date_iso: + return date_iso, source + else: + _log("WARNING", f"No import date found in database for movie_id {movie_id}") + return None, "radarr:db.no_date_found" + except Exception as e: + _log("ERROR", f"Database import date query failed: {e}") + return None, "radarr:db.error" + + # No database client available + _log("ERROR", "Database client required for import date detection - API mode disabled") + return None, "radarr:db.not_configured" + + def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]: + """Get the earliest import date from Radarr history.""" + _log("INFO", f"Finding earliest import for movie_id {movie_id}") + + earliest_real_import = None + earliest_grab_date = None + page = 1 + page_size = 50 + total_events = 0 + + # Get full movie history + while True: + # Get page of history + history_data = self._get_movie_history_page(movie_id, page, page_size) + if not history_data: + break + + # Process events on this page + for event in history_data: + event_type = event.get("eventType") + if not event_type: + continue + + # Parse event date + event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") + + # Track earliest grab date as fallback (EventType 1) + if event_type == 1 and not earliest_grab_date: + earliest_grab_date = event_date + _log("DEBUG", f"Found first grab event at {earliest_grab_date}") + continue + + # Look for import events (EventType 3) + if event_type == 3: + try: + data = json.loads(event.get("data", "{}")) + if data.get("importedPath"): + _log("INFO", f"✅ FOUND IMPORT at {event_date}") + earliest_real_import = event_date + break + except (json.JSONDecodeError, AttributeError): + continue + + # Break if we found an import + if earliest_real_import: + break + + total_events += len(history_data) + page += 1 + + _log("INFO", f"Processed {total_events} events across {page}") + + if earliest_real_import: + return earliest_real_import + if earliest_grab_date: + _log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}") + return earliest_grab_date + return None + + def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]: + """Get a page of movie history.""" + data = self._get("/api/v3/history", { + "movieId": str(movie_id), + "page": page, + "pageSize": page_size, + "sortKey": "date", + "sortDirection": "ascending" + }) + return data if isinstance(data, list) else data.get("records", []) \ No newline at end of file diff --git a/clients/radarr_db_client.py b/clients/radarr_db_client.py new file mode 100644 index 0000000..bf32419 --- /dev/null +++ b/clients/radarr_db_client.py @@ -0,0 +1,646 @@ +#!/usr/bin/env python3 +""" +Direct Radarr Database Client for NFOGuard +Provides high-performance access to Radarr's SQLite/PostgreSQL database +""" + +import os +import sqlite3 +import psycopg2 +import psycopg2.extras +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple, Union +from urllib.parse import urlparse + +from core.logging import _log + + +class RadarrDbClient: + """Direct database client for Radarr's SQLite or PostgreSQL database""" + + def __init__(self, + db_type: str = "sqlite", + db_path: Optional[str] = None, + db_host: Optional[str] = None, + db_port: Optional[int] = None, + db_name: Optional[str] = None, + db_user: Optional[str] = None, + db_password: Optional[str] = None): + """ + Initialize Radarr database client + + Args: + db_type: "sqlite" or "postgresql" + db_path: Path to SQLite database file + db_host: PostgreSQL host + db_port: PostgreSQL port + db_name: PostgreSQL database name + db_user: PostgreSQL username + db_password: PostgreSQL password + """ + self.db_type = db_type.lower() + self.db_path = db_path + self.db_host = db_host + self.db_port = db_port or 5432 + self.db_name = db_name + self.db_user = db_user + self.db_password = db_password + + self._test_connection() + + @classmethod + def from_env(cls) -> Optional['RadarrDbClient']: + """Create client from environment variables""" + db_type = os.environ.get("RADARR_DB_TYPE", "").lower() + + if not db_type: + return None + + if db_type == "sqlite": + db_path = os.environ.get("RADARR_DB_PATH") + if not db_path or not Path(db_path).exists(): + _log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}") + return None + return cls(db_type="sqlite", db_path=db_path) + + elif db_type == "postgresql": + # Support both individual vars and connection string + db_url = os.environ.get("RADARR_DB_URL") + if db_url: + parsed = urlparse(db_url) + return cls( + db_type="postgresql", + db_host=parsed.hostname, + db_port=parsed.port or 5432, + db_name=parsed.path.lstrip('/'), + db_user=parsed.username, + db_password=parsed.password + ) + else: + return cls( + db_type="postgresql", + db_host=os.environ.get("RADARR_DB_HOST"), + db_port=int(os.environ.get("RADARR_DB_PORT", "5432")), + db_name=os.environ.get("RADARR_DB_NAME"), + db_user=os.environ.get("RADARR_DB_USER"), + db_password=os.environ.get("RADARR_DB_PASSWORD") + ) + else: + _log("ERROR", f"Unsupported database type: {db_type}") + return None + + def _test_connection(self) -> None: + """Test database connection on initialization""" + try: + conn = self._get_connection() + if conn: + conn.close() + _log("INFO", f"Connected to Radarr {self.db_type} database successfully") + else: + raise Exception("Failed to create connection") + except Exception as e: + _log("ERROR", f"Failed to connect to Radarr database: {e}") + raise + + def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]: + """Get database connection""" + if self.db_type == "sqlite": + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + elif self.db_type == "postgresql": + conn = psycopg2.connect( + host=self.db_host, + port=self.db_port, + database=self.db_name, + user=self.db_user, + password=self.db_password + ) + return conn + else: + raise ValueError(f"Unsupported database type: {self.db_type}") + + def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """ + Find movie by IMDb ID using database query + + Returns: + Dictionary with movie info including id, imdbId, title, year, path + """ + imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" + + query = """ + SELECT + m."Id" as id, + m."Path" as path, + m."Added" as added, + mm."ImdbId" as imdb_id, + mm."Title" as title, + mm."Year" as year, + mm."DigitalRelease" as digital_release + FROM "Movies" m + JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" + WHERE mm."ImdbId" = %s + """ + + if self.db_type == "sqlite": + query = query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, (imdb_id,)) + row = cursor.fetchone() + + if row: + return dict(row) if self.db_type == "sqlite" else row + + except Exception as e: + _log("ERROR", f"Database query error for IMDb {imdb_id}: {e}") + + return None + + def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: + """ + Get earliest import date from History table, accounting for upgrade scenarios + + Args: + movie_id: Radarr movie ID + + Returns: + (date_iso, source_description) + """ + # If first event is rename, all subsequent imports are upgrades - skip them + if self.is_first_event_rename_based(movie_id): + _log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping") + return None, "radarr:db.upgrade_imports_skipped" + + # Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import) + import_query = """ + SELECT + h."Date" as event_date, + h."Data" as event_data, + h."EventType" as event_type + FROM "History" h + WHERE h."MovieId" = %s + AND h."EventType" = 3 + ORDER BY h."Date" ASC + LIMIT 1 + """ + + # Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab) + grab_query = """ + SELECT + h."Date" as event_date, + h."Data" as event_data, + h."EventType" as event_type + FROM "History" h + WHERE h."MovieId" = %s + AND h."EventType" = 1 + AND h."Data" IS NOT NULL + ORDER BY h."Date" ASC + LIMIT 1 + """ + + if self.db_type == "sqlite": + import_query = import_query.replace("%s", "?") + grab_query = grab_query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + # Try import events first + cursor.execute(import_query, (movie_id,)) + row = cursor.fetchone() + + if row: + event_date = row['event_date'] if self.db_type == "postgresql" else row[0] + event_type = row['event_type'] if self.db_type == "postgresql" else row[2] + if isinstance(event_date, str): + dt = datetime.fromisoformat(event_date.replace("Z", "+00:00")) + else: + dt = event_date.replace(tzinfo=timezone.utc) + + date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") + _log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}") + return date_iso, "radarr:db.history.import" + + # Fallback to grab events + cursor.execute(grab_query, (movie_id,)) + row = cursor.fetchone() + + if row: + event_date = row['event_date'] if self.db_type == "postgresql" else row[0] + event_type = row['event_type'] if self.db_type == "postgresql" else row[2] + if isinstance(event_date, str): + dt = datetime.fromisoformat(event_date.replace("Z", "+00:00")) + else: + dt = event_date.replace(tzinfo=timezone.utc) + + date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") + _log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}") + return date_iso, "radarr:db.history.grab" + + except Exception as e: + _log("ERROR", f"Database query error for movie {movie_id}: {e}") + + return None, "radarr:db.no_date_found" + + def is_first_event_rename_based(self, movie_id: int) -> bool: + """ + Check if the first event in history is rename-based (not a true import) + + This helps identify movies where: + - First event: movieFileRenamed (EventType = 8) + - Followed by: downloadFolderImported (EventType = 3) - this is an upgrade + + In such cases, we should prefer release dates over the upgrade date + """ + query = """ + SELECT h."EventType" as event_type, h."Date" as event_date + FROM "History" h + WHERE h."MovieId" = %s + ORDER BY h."Date" ASC + LIMIT 5 + """ + + if self.db_type == "sqlite": + query = query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, (movie_id,)) + rows = cursor.fetchall() + + if rows: + _log("INFO", f"Movie {movie_id} history debug - first 5 events:") + for i, row in enumerate(rows): + event_type = row['event_type'] if self.db_type == "postgresql" else row[0] + event_date = row['event_date'] if self.db_type == "postgresql" else row[1] + _log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}") + + first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0] + # EventType 8 = movieFileRenamed + # Also check for EventType 7 = movieFileRenamed in some Radarr versions + is_rename_first = first_event_type in [7, 8] + _log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}") + + if is_rename_first: + _log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates") + + return is_rename_first + else: + _log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data") + + except Exception as e: + _log("ERROR", f"Error checking first event type for movie {movie_id}: {e}") + + return False + + def get_movie_file_date(self, movie_id: int) -> Optional[str]: + """ + Get earliest file dateAdded as fallback + + Args: + movie_id: Radarr movie ID + + Returns: + ISO date string or None + """ + query = """ + SELECT MIN(mf."DateAdded") as earliest_date + FROM "MovieFiles" mf + WHERE mf."MovieId" = %s + """ + + if self.db_type == "sqlite": + query = query.replace("%s", "?") + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, (movie_id,)) + row = cursor.fetchone() + + if row: + date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0] + if date_value: + if isinstance(date_value, str): + dt = datetime.fromisoformat(date_value.replace("Z", "+00:00")) + else: + dt = date_value.replace(tzinfo=timezone.utc) + + return dt.astimezone(timezone.utc).isoformat(timespec="seconds") + + except Exception as e: + _log("ERROR", f"Database query error for movie file date {movie_id}: {e}") + + return None + + def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]: + """ + Get the best import date for a movie using optimized database queries + + Args: + movie_id: Radarr movie ID + fallback_to_file_date: Whether to fall back to file dateAdded + + Returns: + (date_iso, source_description) + """ + # Try history first - this handles upgrade detection internally + date_iso, source = self.get_earliest_import_date(movie_id) + if date_iso: + return date_iso, source + + # Check if we skipped upgrades and should prefer release dates + if source == "radarr:db.upgrade_imports_skipped": + _log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates") + return None, "radarr:db.prefer_release_dates" + + # Fallback to file date if requested + if fallback_to_file_date: + file_date = self.get_movie_file_date(movie_id) + if file_date: + _log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}") + return file_date, "radarr:db.file.dateAdded" + + return None, "radarr:db.no_date_found" + + def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]: + """ + Get import dates for multiple movies in a single query + + Args: + imdb_ids: List of IMDb IDs + + Returns: + Dictionary mapping imdb_id -> (date_iso, source) + """ + if not imdb_ids: + return {} + + # Ensure all IMDb IDs have tt prefix + clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids] + + placeholders = ",".join(["%s"] * len(clean_imdb_ids)) + if self.db_type == "sqlite": + placeholders = ",".join(["?"] * len(clean_imdb_ids)) + + query = f""" + SELECT + mm."ImdbId" as imdb_id, + m."Id" as movie_id, + MIN(h."Date") as earliest_import + FROM "Movies" m + JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" + LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3 + WHERE mm."ImdbId" IN ({placeholders}) + GROUP BY mm."ImdbId", m."Id" + """ + + results = {} + + try: + with self._get_connection() as conn: + if self.db_type == "postgresql": + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + else: + cursor = conn.cursor() + + cursor.execute(query, clean_imdb_ids) + rows = cursor.fetchall() + + for row in rows: + if self.db_type == "postgresql": + imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import'] + else: + imdb_id, movie_id, earliest_import = row[0], row[1], row[2] + + if earliest_import: + if isinstance(earliest_import, str): + dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00")) + else: + dt = earliest_import.replace(tzinfo=timezone.utc) + + date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds") + results[imdb_id] = (date_iso, "radarr:db.bulk.import") + else: + results[imdb_id] = (None, "radarr:db.bulk.no_import") + + except Exception as e: + _log("ERROR", f"Bulk query error: {e}") + # Return empty results for failed queries + for imdb_id in clean_imdb_ids: + if imdb_id not in results: + results[imdb_id] = (None, "radarr:db.bulk.error") + + return results + + def get_database_stats(self) -> Dict[str, Any]: + """Get basic statistics about the Radarr database""" + stats = {} + + queries = { + "total_movies": 'SELECT COUNT(*) FROM "Movies"', + "total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"', + "total_history_events": 'SELECT COUNT(*) FROM "History"', + "import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3', + "grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1' + } + + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + for stat_name, query in queries.items(): + cursor.execute(query) + result = cursor.fetchone() + stats[stat_name] = result[0] if result else 0 + + except Exception as e: + _log("ERROR", f"Stats query error: {e}") + stats["error"] = str(e) + + return stats + + def health_check(self) -> Dict[str, Any]: + """ + Comprehensive health check for the Radarr database connection + + Returns: + Dictionary with health status, connection info, and basic functionality tests + """ + health = { + "status": "healthy", + "database_type": self.db_type, + "connection": "ok", + "readable": False, + "writable": False, + "tables_exist": False, + "sample_data": False, + "issues": [], + "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds") + } + + try: + # Test 1: Basic connection + with self._get_connection() as conn: + cursor = conn.cursor() + + # Test 2: Check if we can read (basic query) + try: + cursor.execute('SELECT 1') + result = cursor.fetchone() + if result and result[0] == 1: + health["readable"] = True + health["connection"] = "readable" + else: + health["issues"].append("Basic SELECT query failed") + except Exception as e: + health["issues"].append(f"Read test failed: {e}") + health["status"] = "degraded" + + # Test 3: Check required tables exist + required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"] + existing_tables = [] + + try: + if self.db_type == "postgresql": + cursor.execute(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles') + """) + else: # SQLite + cursor.execute(""" + SELECT name + FROM sqlite_master + WHERE type='table' + AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles') + """) + + rows = cursor.fetchall() + existing_tables = [row[0] for row in rows] + + if len(existing_tables) == len(required_tables): + health["tables_exist"] = True + else: + missing = set(required_tables) - set(existing_tables) + health["issues"].append(f"Missing tables: {list(missing)}") + health["status"] = "degraded" + + health["existing_tables"] = existing_tables + + except Exception as e: + health["issues"].append(f"Table check failed: {e}") + health["status"] = "degraded" + + # Test 4: Check for sample data + if health["tables_exist"]: + try: + cursor.execute('SELECT COUNT(*) FROM "Movies"') + movie_count = cursor.fetchone()[0] + + cursor.execute('SELECT COUNT(*) FROM "History"') + history_count = cursor.fetchone()[0] + + if movie_count > 0 and history_count > 0: + health["sample_data"] = True + health["movie_count"] = movie_count + health["history_count"] = history_count + else: + health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}") + + except Exception as e: + health["issues"].append(f"Sample data check failed: {e}") + + # Test 5: Test a real query (movie with IMDb lookup) + if health["sample_data"]: + try: + cursor.execute(""" + SELECT COUNT(*) + FROM "Movies" m + JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" + WHERE mm."ImdbId" IS NOT NULL + """) + imdb_movies = cursor.fetchone()[0] + health["movies_with_imdb"] = imdb_movies + + if imdb_movies > 0: + health["functional"] = True + else: + health["issues"].append("No movies with IMDb IDs found") + + except Exception as e: + health["issues"].append(f"Functional test failed: {e}") + health["status"] = "degraded" + + except Exception as e: + health["status"] = "error" + health["connection"] = "failed" + health["issues"].append(f"Connection failed: {e}") + _log("ERROR", f"Database health check failed: {e}") + + # Overall status determination + if health["issues"]: + if health["status"] == "healthy": + health["status"] = "degraded" + + # Add connection details (safe info only) + health["connection_info"] = { + "type": self.db_type, + "host": self.db_host if self.db_type == "postgresql" else None, + "port": self.db_port if self.db_type == "postgresql" else None, + "database": self.db_name if self.db_type == "postgresql" else None, + "path": self.db_path if self.db_type == "sqlite" else None + } + + return health + + +if __name__ == "__main__": + # Test the database client + print("Testing RadarrDbClient...") + + # Test with environment variables + client = RadarrDbClient.from_env() + if client: + print("✅ Connected to Radarr database") + + # Test stats + stats = client.get_database_stats() + print(f"Database stats: {stats}") + + # Test movie lookup + test_movie = client.get_movie_by_imdb("tt1596343") + if test_movie: + print(f"Found test movie: {test_movie}") + + # Test import date + movie_id = test_movie['id'] + date_iso, source = client.get_movie_import_date_optimized(movie_id) + print(f"Import date: {date_iso} (source: {source})") + else: + print("Test movie not found") + else: + print("❌ Could not connect to database - check environment variables") \ No newline at end of file diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py new file mode 100644 index 0000000..06fafc9 --- /dev/null +++ b/clients/sonarr_client.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Enhanced Sonarr API client extracted from media_date_cache.py +""" +import json +import time +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional +from urllib.parse import urlencode +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import URLError, HTTPError + +from core.logging import _log + + +class SonarrClient: + """Enhanced Sonarr API client for TV series and episode management""" + + def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.retries = max(0, retries) + self.enabled = bool(self.base_url and self.api_key) + + def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]: + """Make GET request to Sonarr API with retries""" + if not self.enabled: + return None + + url = f"{self.base_url}/api/v3{path}" + if params: + url += "?" + urlencode(params) + + headers = {"X-Api-Key": self.api_key} + + for attempt in range(self.retries): + try: + _log("DEBUG", f"Sonarr API Request: {url}") + req = UrlRequest(url, headers=headers) + + with urlopen(req, timeout=self.timeout) as resp: + data = resp.read().decode("utf-8") + result = json.loads(data) if data else None + return result + + except HTTPError as e: + if e.code == 401: + _log("ERROR", "Sonarr authentication failed - check API key") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}") + return None + + def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find series by IMDb ID using lookup endpoint""" + search_term = f"imdbid:{imdb_id}" + _log("DEBUG", f"Searching Sonarr with term: {search_term}") + + result = self._get("/series/lookup", {"term": search_term}) + if not result: + _log("WARNING", f"No results from Sonarr lookup for: {search_term}") + return None + + _log("DEBUG", f"Sonarr lookup returned {len(result)} results") + + # Log all results for debugging + for i, series in enumerate(result): + series_imdb = series.get("imdbId", "") + series_title = series.get("title", "") + series_id = series.get("id", "") + _log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}") + + # Find exact IMDb match (case insensitive) + target_imdb = imdb_id.lower() + for series in result: + series_imdb = (series.get("imdbId") or "").lower() + if series_imdb == target_imdb: + _log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})") + return series + + # Try partial match as fallback + for series in result: + series_imdb = (series.get("imdbId") or "").lower() + if target_imdb in series_imdb or series_imdb in target_imdb: + _log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})") + return series + + _log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}") + return None + + def series_by_title(self, title: str) -> Optional[Dict[str, Any]]: + """Search for series by title as fallback when IMDb lookup fails""" + _log("DEBUG", f"Searching Sonarr by title: {title}") + + result = self._get("/series/lookup", {"term": title}) + if not result: + _log("WARNING", f"No results from Sonarr title search for: {title}") + return None + + _log("DEBUG", f"Sonarr title search returned {len(result)} results") + + title_lower = title.lower() + + # Look for exact title match + for series in result: + series_title = (series.get("title") or "").lower() + if series_title == title_lower: + _log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})") + return series + + # Look for partial title match + for series in result: + series_title = (series.get("title") or "").lower() + if title_lower in series_title or series_title in title_lower: + _log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})") + return series + + _log("WARNING", f"No title match found for: {title}") + return None + + def get_all_series(self) -> List[Dict[str, Any]]: + """Get all series from Sonarr""" + return self._get("/series") or [] + + def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Find series by scanning all series for IMDb match (slower but more reliable)""" + _log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}") + all_series = self.get_all_series() + + target_imdb = imdb_id.lower() + for series in all_series: + series_imdb = (series.get("imdbId") or "").lower() + if series_imdb == target_imdb: + _log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})") + return series + + _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") + return None + + def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: + """Get all episodes for a series""" + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]: + """Get episode file details""" + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id: int) -> Optional[str]: + """ + Get the original import date from history with enhanced detection. + Focuses on finding the earliest REAL import, not upgrades. + """ + all_records = [] + page = 1 + page_size = 100 + + # Collect all history records for this episode + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) + + if not history: + break + + records = history.get("records", []) if isinstance(history, dict) else [] + if not records: + break + + all_records.extend(records) + + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve + break + + _log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}") + + # Categorize events + import_events = [] + grabbed_events = [] + rename_events = [] + + for event in all_records: + event_type = event.get("eventType", "").lower() + date = event.get("date") + + if not date: + continue + + _log("DEBUG", f"History event: {event_type} at {date}") + + if event_type == "downloadfolderimported": + import_events.append({"date": date, "event": event}) + elif event_type == "grabbed": + grabbed_events.append({"date": date, "event": event}) + elif event_type == "episodefilerenamed": + rename_events.append({"date": date, "event": event}) + + # Use the earliest real import event + if import_events: + earliest_import = min(import_events, key=lambda x: x["date"]) + import_date = earliest_import["date"] + _log("INFO", f"Found import date: {import_date} for episode {episode_id}") + + # Check if this looks like an upgrade by comparing to renames + if rename_events: + earliest_rename = min(rename_events, key=lambda x: x["date"]) + rename_date = earliest_rename["date"] + + try: + import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00")) + rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00")) + days_diff = (import_dt - rename_dt).days + + # If import is significantly after rename, prefer rename date + if days_diff > 30: + _log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date") + return rename_date + + except Exception as e: + _log("DEBUG", f"Error comparing dates: {e}") + + return import_date + + # Fallback to grab event + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"]) + _log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + + +if __name__ == "__main__": + # Test the client + import os + + base_url = os.environ.get("SONARR_URL", "") + api_key = os.environ.get("SONARR_API_KEY", "") + + if base_url and api_key: + client = SonarrClient(base_url, api_key) + + # Test with a known series + test_imdb = "tt2085059" # Example + series = client.series_by_imdb(test_imdb) + + if series: + series_id = series.get("id") + print(f"Found series: {series.get('title')} (ID: {series_id})") + + # Get episodes + episodes = client.episodes_for_series(series_id) + print(f"Found {len(episodes)} episodes") + + # Test import date for first episode + if episodes: + first_episode = episodes[0] + episode_id = first_episode.get("id") + import_date = client.get_episode_import_history(episode_id) + print(f"First episode import date: {import_date}") + else: + print(f"Series not found: {test_imdb}") + else: + print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing") \ No newline at end of file diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..33f2905 --- /dev/null +++ b/core/database.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Database management for NFOGuard +Handles SQLite database operations for tracking media dates and processing history +""" +import sqlite3 +import json +import threading +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict, List, Any +from contextlib import contextmanager + +class NFOGuardDatabase: + """Manages NFOGuard SQLite database operations""" + + def __init__(self, db_path: Path): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._local = threading.local() + self._init_database() + + def _get_connection(self) -> sqlite3.Connection: + """Get thread-local database connection""" + if not hasattr(self._local, 'connection'): + self._local.connection = sqlite3.connect( + self.db_path, + check_same_thread=False, + timeout=30.0 + ) + self._local.connection.row_factory = sqlite3.Row + return self._local.connection + + @contextmanager + def get_connection(self): + """Context manager for database connections""" + conn = self._get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + + def _init_database(self): + """Initialize database tables with migration support""" + with self.get_connection() as conn: + cursor = conn.cursor() + + # Series table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + path TEXT NOT NULL, + last_updated TEXT NOT NULL, + metadata TEXT + ) + """) + + # Episodes table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS episodes ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + last_updated TEXT NOT NULL, + PRIMARY KEY (imdb_id, season, episode), + FOREIGN KEY (imdb_id) REFERENCES series(imdb_id) + ) + """) + + # Movies table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS movies ( + imdb_id TEXT PRIMARY KEY, + path TEXT NOT NULL, + released TEXT, + dateadded TEXT, + source TEXT, + last_updated TEXT NOT NULL + ) + """) + + # Processing history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS processing_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + imdb_id TEXT NOT NULL, + media_type TEXT NOT NULL, + event_type TEXT NOT NULL, + processed_at TEXT NOT NULL, + details TEXT + ) + """) + + # Add missing columns if they don't exist (migration) + # Check current schema and add missing columns + cursor.execute("PRAGMA table_info(movies)") + movie_columns = [row[1] for row in cursor.fetchall()] + + cursor.execute("PRAGMA table_info(episodes)") + episode_columns = [row[1] for row in cursor.fetchall()] + + # Add missing columns to movies table + if 'path' not in movie_columns: + cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT") + cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL") + + if 'has_video_file' not in movie_columns: + cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE") + + if 'last_updated' not in movie_columns: + cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT") + cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL") + + # Add missing columns to episodes table + if 'has_video_file' not in episode_columns: + cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE") + + if 'last_updated' not in episode_columns: + cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT") + cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL") + + # Create indexes + cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)") + + def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None): + """Insert or update series record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata) + VALUES (?, ?, ?, ?) + """, (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None)) + + def upsert_episode_date(self, imdb_id: str, season: int, episode: int, + aired: Optional[str], dateadded: Optional[str], + source: str, has_video_file: bool = False): + """Insert or update episode date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO episodes + (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat())) + + def upsert_movie(self, imdb_id: str, path: str): + """Insert or update movie record""" + with self.get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(""" + INSERT OR REPLACE INTO movies (imdb_id, path, last_updated) + VALUES (?, ?, ?) + """, (imdb_id, path, datetime.utcnow().isoformat())) + except sqlite3.OperationalError as e: + if "no column named path" in str(e): + # Fallback for databases without path column - just insert imdb_id + cursor.execute(""" + INSERT OR REPLACE INTO movies (imdb_id, last_updated) + VALUES (?, ?) + """, (imdb_id, datetime.utcnow().isoformat())) + else: + raise + + def upsert_movie_dates(self, imdb_id: str, released: Optional[str], + dateadded: Optional[str], source: str, has_video_file: bool = False): + """Insert or update movie date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + # Use INSERT OR REPLACE to ensure we always update the dates properly + cursor.execute(""" + INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated) + VALUES ( + ?, + COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'), + ?, ?, ?, ?, ? + ) + """, (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat())) + + def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: + """Get all episodes for a series""" + with self.get_connection() as conn: + cursor = conn.cursor() + + query = "SELECT * FROM episodes WHERE imdb_id = ?" + params = [imdb_id] + + if has_video_file_only: + query += " AND has_video_file = TRUE" + + cursor.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + + def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]: + """Get episode date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM episodes + WHERE imdb_id = ? AND season = ? AND episode = ? + """, (imdb_id, season, episode)) + + row = cursor.fetchone() + return dict(row) if row else None + + def get_movie_dates(self, imdb_id: str) -> Optional[Dict]: + """Get movie date record""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,)) + + row = cursor.fetchone() + return dict(row) if row else None + + def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None): + """Add processing history entry""" + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details) + VALUES (?, ?, ?, ?, ?) + """, (imdb_id, media_type, event_type, datetime.utcnow().isoformat(), + json.dumps(details) if details else None)) + + def get_stats(self) -> Dict[str, Any]: + """Get database statistics""" + with self.get_connection() as conn: + cursor = conn.cursor() + + # Series stats + cursor.execute("SELECT COUNT(*) FROM series") + series_count = cursor.fetchone()[0] + + # Episode stats + cursor.execute("SELECT COUNT(*) FROM episodes") + episodes_total = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE") + episodes_with_video = cursor.fetchone()[0] + + # Movie stats + cursor.execute("SELECT COUNT(*) FROM movies") + movies_total = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE") + movies_with_video = cursor.fetchone()[0] + + # Processing history + cursor.execute("SELECT COUNT(*) FROM processing_history") + history_count = cursor.fetchone()[0] + + return { + "series_count": series_count, + "episodes_total": episodes_total, + "episodes_with_video": episodes_with_video, + "movies_total": movies_total, + "movies_with_video": movies_with_video, + "processing_history_count": history_count, + "database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2) + } \ No newline at end of file diff --git a/core/logging.py b/core/logging.py new file mode 100644 index 0000000..a673f42 --- /dev/null +++ b/core/logging.py @@ -0,0 +1,53 @@ +"""Logging utilities for NFOguard""" + +from datetime import datetime, timezone +import os + +def _get_local_timezone(): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + from zoneinfo import ZoneInfo + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + +def _log(level: str, msg: str): + """Basic logging function that writes to console""" + tz = _get_local_timezone() + print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}") + +def convert_utc_to_local(utc_iso_string: str) -> str: + """Convert UTC ISO timestamp to local timezone timestamp""" + if not utc_iso_string: + return utc_iso_string + + try: + # Parse UTC timestamp + if utc_iso_string.endswith('Z'): + dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) + elif '+00:00' in utc_iso_string: + dt_utc = datetime.fromisoformat(utc_iso_string) + else: + # Assume UTC if no timezone info + dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) + + # Convert to local timezone + local_tz = _get_local_timezone() + dt_local = dt_utc.astimezone(local_tz) + + return dt_local.isoformat(timespec='seconds') + except Exception: + # If conversion fails, return original + return utc_iso_string \ No newline at end of file diff --git a/core/nfo_manager.py b/core/nfo_manager.py new file mode 100644 index 0000000..71f45c7 --- /dev/null +++ b/core/nfo_manager.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +""" +NFO Manager for creating and managing metadata files +Handles NFO creation for movies, TV shows, seasons, and episodes +""" +import os +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict, Any +import re + + +class NFOManager: + """Manages NFO file creation and updates""" + + def __init__(self, manager_brand: str = "NFOGuard"): + self.manager_brand = manager_brand + + def parse_imdb_from_path(self, path: Path) -> Optional[str]: + """Extract IMDb ID from directory path or filename""" + # Look for various IMDb patterns in both directory and file names + path_str = str(path).lower() + + # Try [imdb-ttXXXXXXX] format first (most explicit) + match = re.search(r'\[imdb-?(tt\d+)\]', path_str) + if match: + return match.group(1) + + # Try standalone [ttXXXXXXX] format in brackets + match = re.search(r'\[(tt\d+)\]', path_str) + if match: + return match.group(1) + + # Try {imdb-ttXXXXXXX} format with curly braces + match = re.search(r'\{imdb-?(tt\d+)\}', path_str) + if match: + return match.group(1) + + # Try (imdb-ttXXXXXXX) format with parentheses + match = re.search(r'\(imdb-?(tt\d+)\)', path_str) + if match: + return match.group(1) + + # Try ttXXXXXXX at end of filename/dirname (common pattern) + match = re.search(r'[-_\s](tt\d+)$', path_str) + if match: + return match.group(1) + + return None + + def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str, + released: Optional[str] = None, source: str = "unknown", + lock_metadata: bool = True) -> None: + """Create or update movie.nfo file preserving existing content""" + nfo_path = movie_dir / "movie.nfo" + + try: + # Try to load existing NFO file + if nfo_path.exists(): + try: + tree = ET.parse(nfo_path) + movie = tree.getroot() + + # Ensure root element is + if movie.tag != "movie": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements to avoid duplicates + # These will be re-added at the very bottom + nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"] + for tag in nfoguard_fields: + existing = movie.find(tag) + if existing is not None: + # Store the value before removing (for premiered/year) + if tag == "premiered" and not released: + released = existing.text # Preserve existing premiered date + movie.remove(existing) + + # Remove ALL existing uniqueid with type="imdb" regardless of attributes + # We'll add a clean one at the bottom + for uniqueid in movie.findall("uniqueid[@type='imdb']"): + movie.remove(uniqueid) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean NFO file to replace corrupted one") + movie = ET.Element("movie") + else: + # Create new NFO structure + movie = ET.Element("movie") + + # Now append ALL NFOGuard and date fields at the VERY END of the file + # This ensures they appear after all existing content including actors + + # Add IMDb uniqueid at the end (after all existing content) + uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true") + uniqueid.text = imdb_id + + # Add premiered date at the bottom if we have it + if released: + premiered_elem = ET.SubElement(movie, "premiered") + premiered_elem.text = released[:10] if len(released) >= 10 else released + + # Extract year from premiered date for consistency + try: + year_value = released[:4] if len(released) >= 4 else None + if year_value and year_value.isdigit(): + year_elem = ET.SubElement(movie, "year") + year_elem.text = year_value + except: + pass # Skip year if we can't extract it + + # Add dateadded at the end + if dateadded: + dateadded_elem = ET.SubElement(movie, "dateadded") + dateadded_elem.text = dateadded + + # Add lockdata at the very end + if lock_metadata: + lockdata = ET.SubElement(movie, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} - Source: {source} " + + # Write file with proper formatting + tree = ET.ElementTree(movie) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + import xml.etree.ElementTree as ET_temp + xml_str = ET.tostring(movie, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated movie NFO: {nfo_path}") + print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}") + + except Exception as e: + print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}") + + def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None: + """Create or update tvshow.nfo file preserving existing content""" + nfo_path = series_dir / "tvshow.nfo" + + try: + # Try to load existing NFO file + if nfo_path.exists(): + try: + tree = ET.parse(nfo_path) + tvshow = tree.getroot() + + # Ensure root element is + if tvshow.tag != "tvshow": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements to avoid duplicates + # These will be re-added at the bottom + for tag in ["lockdata"]: + existing = tvshow.find(tag) + if existing is not None: + tvshow.remove(existing) + + # Remove ALL existing uniqueid with type="imdb" regardless of attributes + for uniqueid in tvshow.findall("uniqueid[@type='imdb']"): + tvshow.remove(uniqueid) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean tvshow.nfo file to replace corrupted one") + tvshow = ET.Element("tvshow") + else: + # Create new NFO structure + tvshow = ET.Element("tvshow") + + # Add NFOGuard fields at the bottom + + # Add IMDb uniqueid at the end + imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true") + imdb_uniqueid.text = imdb_id + + # Add TVDB ID if available (preserve existing or add new) + if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"): + tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb") + tvdb_uniqueid.text = tvdb_id + + # Add lockdata at the very end + lockdata = ET.SubElement(tvshow, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} " + + # Write file with proper formatting + tree = ET.ElementTree(tvshow) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + xml_str = ET.tostring(tvshow, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated TV show NFO: {nfo_path}") + print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else "")) + + except Exception as e: + print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}") + + def create_season_nfo(self, season_dir: Path, season_number: int) -> None: + """Create or update season.nfo file preserving existing content""" + nfo_path = season_dir / "season.nfo" + + try: + season_dir.mkdir(exist_ok=True) + + # Try to load existing NFO file + if nfo_path.exists(): + try: + tree = ET.parse(nfo_path) + season = tree.getroot() + + # Ensure root element is + if season.tag != "season": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements + for tag in ["seasonnumber", "lockdata"]: + existing = season.find(tag) + if existing is not None: + season.remove(existing) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean season.nfo file to replace corrupted one") + season = ET.Element("season") + else: + # Create new NFO structure + season = ET.Element("season") + + # Add NFOGuard fields at the bottom + seasonnumber = ET.SubElement(season, "seasonnumber") + seasonnumber.text = str(season_number) + + # Add lockdata at the end + lockdata = ET.SubElement(season, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} " + + # Write file with proper formatting + tree = ET.ElementTree(season) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + xml_str = ET.tostring(season, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated season NFO: {nfo_path}") + print(f" Season: {season_number}") + + except Exception as e: + print(f"❌ Error creating/updating season NFO {nfo_path}: {e}") + + def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int, + aired: Optional[str], dateadded: Optional[str], source: str, + lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None: + """Create or update episode NFO file preserving existing content""" + # Generate episode filename pattern + episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" + nfo_path = season_dir / episode_filename + + try: + # Try to load existing NFO file + if nfo_path.exists(): + try: + tree = ET.parse(nfo_path) + episode = tree.getroot() + + # Ensure root element is + if episode.tag != "episodedetails": + raise ValueError("Root element is not ") + + # Remove existing NFOGuard-managed elements to avoid duplicates + # These will be re-added at the bottom + nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"] + for tag in nfoguard_fields: + existing = episode.find(tag) + if existing is not None: + # Store the aired value before removing + if tag == "aired" and not aired: + aired = existing.text # Preserve existing aired date + episode.remove(existing) + + except (ET.ParseError, ValueError) as e: + print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...") + print(f" Creating new clean episode NFO file to replace corrupted one") + episode = ET.Element("episodedetails") + else: + # Create new NFO structure + episode = ET.Element("episodedetails") + + # Enhanced metadata should be preserved - only add if not already present + if enhanced_metadata: + if enhanced_metadata.get("title") and not episode.find("title"): + title_elem = ET.SubElement(episode, "title") + title_elem.text = enhanced_metadata["title"] + + if enhanced_metadata.get("overview") and not episode.find("plot"): + plot_elem = ET.SubElement(episode, "plot") + plot_elem.text = enhanced_metadata["overview"] + + if enhanced_metadata.get("runtime") and not episode.find("runtime"): + runtime_elem = ET.SubElement(episode, "runtime") + runtime_elem.text = str(enhanced_metadata["runtime"]) + + # Add NFOGuard fields at the bottom + + # Basic episode info at the end + season_elem = ET.SubElement(episode, "season") + season_elem.text = str(season_num) + + episode_elem = ET.SubElement(episode, "episode") + episode_elem.text = str(episode_num) + + # Dates at the end + if aired: + aired_elem = ET.SubElement(episode, "aired") + aired_elem.text = aired[:10] if len(aired) >= 10 else aired + + if dateadded: + dateadded_elem = ET.SubElement(episode, "dateadded") + dateadded_elem.text = dateadded + + # Add lockdata at the very end + if lock_metadata: + lockdata = ET.SubElement(episode, "lockdata") + lockdata.text = "true" + + # Add NFOGuard comment at the beginning + comment_text = f" Created by {self.manager_brand} - Source: {source} " + + # Write file with proper formatting + tree = ET.ElementTree(episode) + ET.indent(tree, space=" ", level=0) + + # Write to string first to add comment + xml_str = ET.tostring(episode, encoding='unicode') + + # Add XML declaration and comment + full_xml = f'\n\n{xml_str}' + + # Write to file + with open(nfo_path, 'w', encoding='utf-8') as f: + f.write(full_xml) + + print(f"✅ Successfully created/updated episode NFO: {nfo_path}") + print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") + + except Exception as e: + print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}") + + def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None: + """Set file modification time to match import date""" + try: + # Parse ISO timestamp + if iso_timestamp.endswith('Z'): + dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00')) + elif '+' in iso_timestamp or 'T' in iso_timestamp: + dt = datetime.fromisoformat(iso_timestamp) + else: + # Assume it's already a simple date + dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00') + + # Convert to timestamp + timestamp = dt.timestamp() + + # Set both access and modification times + os.utime(file_path, (timestamp, timestamp)) + print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}") + + except Exception as e: + print(f"❌ Error setting mtime for {file_path}: {e}") + + def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None: + """Update modification times for all video files in movie directory""" + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + updated_files = [] + + for file_path in movie_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in video_exts: + self.set_file_mtime(file_path, iso_timestamp) + updated_files.append(file_path.name) + + if updated_files: + print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}") + else: + print(f"⚠️ No video files found to update in {movie_dir.name}") \ No newline at end of file diff --git a/core/path_mapper.py b/core/path_mapper.py new file mode 100644 index 0000000..59d5805 --- /dev/null +++ b/core/path_mapper.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Path mapping utilities for NFOGuard +Handles conversion between external service paths and container paths +""" +import os +import re +from pathlib import Path + +class PathMapper: + """Handles path mapping between different environments""" + + def __init__(self, config): + """Initialize path mapper with configuration""" + # Use environment variables directly since config attribute names are unclear + import os + + radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '') + sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '') + movie_paths_str = os.getenv('MOVIE_PATHS', '') + tv_paths_str = os.getenv('TV_PATHS', '') + + self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()] + self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()] + self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()] + self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()] + + # Check if path debugging is enabled + self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true' + + if self.path_debug: + print(f"PATH_DEBUG: PathMapper initialized with:") + print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}") + print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}") + print(f"PATH_DEBUG: movie_paths: {self.movie_paths}") + print(f"PATH_DEBUG: tv_paths: {self.tv_paths}") + + def sonarr_path_to_container_path(self, sonarr_path: str) -> str: + """Convert Sonarr path to container path using environment mappings""" + if self.path_debug: + print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}") + print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}") + print(f"PATH_DEBUG: tv_paths: {self.tv_paths}") + + # Sort roots by length (longest first) to avoid substring matching issues + indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)] + indexed_roots.sort(key=lambda x: len(x[1]), reverse=True) + + # Try to match against configured Sonarr root folders (longest first) + for original_index, sonarr_root in indexed_roots: + if self.path_debug: + print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}") + if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root: + if self.path_debug: + print(f"PATH_DEBUG: Match found! Index {original_index}") + # Map to corresponding TV path + if original_index < len(self.tv_paths): + container_root = self.tv_paths[original_index] + relative_path = sonarr_path[len(sonarr_root):].lstrip('/') + result = str(Path(container_root) / relative_path) if relative_path else container_root + if self.path_debug: + print(f"PATH_DEBUG: Mapped to: {result}") + return result + + if self.path_debug: + print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}") + # No fallback - if path mapping fails, return original and let validation catch it + return sonarr_path + + def radarr_path_to_container_path(self, radarr_path: str) -> str: + """Convert Radarr path to container path using environment mappings""" + if self.path_debug: + print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}") + print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}") + print(f"PATH_DEBUG: movie_paths: {self.movie_paths}") + + # Sort roots by length (longest first) to avoid substring matching issues + indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)] + indexed_roots.sort(key=lambda x: len(x[1]), reverse=True) + + # Try to match against configured Radarr root folders (longest first) + for original_index, radarr_root in indexed_roots: + if self.path_debug: + print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}") + if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root: + if self.path_debug: + print(f"PATH_DEBUG: Match found! Index {original_index}") + # Map to corresponding movie path + if original_index < len(self.movie_paths): + container_root = self.movie_paths[original_index] + relative_path = radarr_path[len(radarr_root):].lstrip('/') + result = str(Path(container_root) / relative_path) if relative_path else container_root + if self.path_debug: + print(f"PATH_DEBUG: Mapped to: {result}") + return result + + if self.path_debug: + print(f"PATH_DEBUG: No match found, returning original: {radarr_path}") + # No fallback - if path mapping fails, return original and let validation catch it + return radarr_path + + def container_path_to_host_path(self, container_path: str) -> str: + """Convert container path back to host path if needed""" + # This might be needed for file operations + return container_path \ No newline at end of file diff --git a/nfoguard.py b/nfoguard.py new file mode 100644 index 0000000..f6273ec --- /dev/null +++ b/nfoguard.py @@ -0,0 +1,2208 @@ +#!/usr/bin/env python3 +""" +NFOGuard - Consolidated webhook server for TV and Movie import date management +Replaces webhook_server.py and media_date_cache.py with modular architecture +""" +import os +import json +import asyncio +import glob +import re +import logging +import logging.handlers +from pathlib import Path +from datetime import datetime, timezone, timedelta +from zoneinfo import ZoneInfo +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request +from pydantic import BaseModel +from typing import Optional, Dict, Any, List, Set, Tuple +import threading +from concurrent.futures import ThreadPoolExecutor +import uvicorn + +# Import our new modular components +from core.database import NFOGuardDatabase +from core.nfo_manager import NFOManager +from core.path_mapper import PathMapper +from clients.radarr_client import RadarrClient +from clients.sonarr_client import SonarrClient +from clients.external_clients import ExternalClientManager + +# --------------------------- +# Configuration & Logging +# --------------------------- + +class TimezoneAwareFormatter(logging.Formatter): + """Formatter that respects the container timezone""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.timezone = self._get_local_timezone() + + def _get_local_timezone(self): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + + def formatTime(self, record, datefmt=None): + dt = datetime.fromtimestamp(record.created, tz=self.timezone) + if datefmt: + return dt.strftime(datefmt) + return dt.isoformat(timespec='seconds') + +def _setup_file_logging(): + """Setup file logging for NFOGuard""" + log_dir = Path(os.environ.get("LOG_DIR", "/app/data/logs")) + log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("NFOGuard") + logger.setLevel(logging.DEBUG) + + file_handler = logging.handlers.RotatingFileHandler( + log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3 + ) + + formatter = TimezoneAwareFormatter( + '[%(asctime)s] %(levelname)s: %(message)s' + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + return logger + +def _mask_sensitive_data(msg: str) -> str: + """Mask API keys and other sensitive data in log messages""" + import re + + # List of patterns to mask + sensitive_patterns = [ + (r'api_key=([a-zA-Z0-9_\-]+)', r'api_key=***masked***'), + (r'password=([^\s&]+)', r'password=***masked***'), + (r'token=([a-zA-Z0-9_\-]+)', r'token=***masked***'), + (r'key=([a-zA-Z0-9_\-]{8,})', r'key=***masked***'), # Keys longer than 8 chars + (r'([a-zA-Z0-9]{32,})', lambda m: m.group(1)[:8] + '***masked***' if len(m.group(1)) > 16 else m.group(1)) # Long strings likely to be keys + ] + + masked_msg = msg + for pattern, replacement in sensitive_patterns: + if isinstance(replacement, str): + masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE) + else: + masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE) + + return masked_msg + +def _get_local_timezone(): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + +def _log(level: str, msg: str): + """Enhanced logging that writes to both console and file with sensitive data masking""" + masked_msg = _mask_sensitive_data(msg) + tz = _get_local_timezone() + print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {masked_msg}") + + try: + file_logger = _setup_file_logging() + getattr(file_logger, level.lower(), file_logger.info)(masked_msg) + except Exception as e: + print(f"File logging error: {e}") + +def convert_utc_to_local(utc_iso_string: str) -> str: + """Convert UTC ISO timestamp to local timezone timestamp""" + if not utc_iso_string: + return utc_iso_string + + try: + # Parse UTC timestamp + if utc_iso_string.endswith('Z'): + dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) + elif '+00:00' in utc_iso_string: + dt_utc = datetime.fromisoformat(utc_iso_string) + else: + # Assume UTC if no timezone info + dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) + + # Convert to local timezone + local_tz = _get_local_timezone() + dt_local = dt_utc.astimezone(local_tz) + + return dt_local.isoformat(timespec='seconds') + except Exception: + # If conversion fails, return original + return utc_iso_string + +# Initialize logging +_setup_file_logging() + +def _load_environment_files(): + """Load environment variables from .env and optionally .env.secrets""" + from pathlib import Path + + # Try to load from python-dotenv if available + try: + from dotenv import load_dotenv + + # Load main .env file + env_file = Path(".env") + if env_file.exists(): + load_dotenv(env_file) + _log("INFO", f"Loaded environment from {env_file}") + + # Load secrets file if it exists + secrets_file = Path(".env.secrets") + if secrets_file.exists(): + load_dotenv(secrets_file) + _log("INFO", f"Loaded secrets from {secrets_file}") + + except ImportError: + _log("WARNING", "python-dotenv not available - environment files not loaded") + +# Load environment files at startup +_load_environment_files() + +# Add debug logging near where configuration is loaded +print(f"DEBUG: Environment check - SONARR_ROOT_FOLDERS: {os.getenv('SONARR_ROOT_FOLDERS')}") +print(f"DEBUG: Environment check - TV_PATHS: {os.getenv('TV_PATHS')}") + +def _bool_env(name: str, default: bool) -> bool: + v = os.environ.get(name) + if v is None: + return default + return v.lower() in ("1", "true", "yes", "y", "on") + +# --------------------------- +# Configuration +# --------------------------- + +class NFOGuardConfig: + def __init__(self): + # Paths - No hardcoded defaults, must be configured via environment + tv_paths_env = os.environ.get("TV_PATHS", "") + movie_paths_env = os.environ.get("MOVIE_PATHS", "") + + if not tv_paths_env: + raise ValueError("TV_PATHS environment variable is required but not set") + if not movie_paths_env: + raise ValueError("MOVIE_PATHS environment variable is required but not set") + + self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()] + self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()] + + # Core settings + self.manage_nfo = _bool_env("MANAGE_NFO", True) + self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True) + self.lock_metadata = _bool_env("LOCK_METADATA", True) + self.debug = _bool_env("DEBUG", False) + self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard") + + # Batching + self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0")) + self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3")) + + # Database + self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db")) + + # Movie processing + self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower() + self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True) + self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False) + self.release_date_priority = [p.strip() for p in os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical").split(",")] + self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True) + self.max_release_date_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) + self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower() + self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower() + + # TV processing + self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}") + self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower() + self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower() + +config = NFOGuardConfig() + +# --------------------------- +# Models +# --------------------------- + +class SonarrWebhook(BaseModel): + eventType: str + series: Optional[Dict[str, Any]] = None + episodes: Optional[list] = [] + episodeFile: Optional[Dict[str, Any]] = None + isUpgrade: Optional[bool] = False + + class Config: + extra = "allow" + +class RadarrWebhook(BaseModel): + eventType: str + movie: Optional[Dict[str, Any]] = None + movieFile: Optional[Dict[str, Any]] = None + isUpgrade: Optional[bool] = False + deletedFiles: Optional[list] = [] + remoteMovie: Optional[Dict[str, Any]] = None + renamedMovieFiles: Optional[List[Dict[str, Any]]] = None + + class Config: + extra = "allow" + +class HealthResponse(BaseModel): + status: str + version: str + uptime: str + database_status: str + radarr_database: Optional[Dict[str, Any]] = None + +class TVSeasonRequest(BaseModel): + series_path: str + season_name: str + +class TVEpisodeRequest(BaseModel): + series_path: str + season_name: str + episode_name: str + +# --------------------------- +# Core Processing +# --------------------------- + +class TVProcessor: + """Handles TV series processing""" + + def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): + self.db = db + self.nfo_manager = nfo_manager + self.path_mapper = path_mapper + self.sonarr = SonarrClient( + os.environ.get("SONARR_URL", ""), + os.environ.get("SONARR_API_KEY", "") + ) + self.external_clients = ExternalClientManager() + + def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]: + """Find series directory path""" + # Try webhook path first + if sonarr_path: + container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path) + path_obj = Path(container_path) + if path_obj.exists(): + return path_obj + + # Search by IMDb ID or title + for media_path in config.tv_paths: + if not media_path.exists(): + continue + + # Search by IMDb ID + if imdb_id: + pattern = str(media_path / f"*[imdb-{imdb_id}]*") + matches = glob.glob(pattern) + if matches: + return Path(matches[0]) + + # Search by title + if series_title: + title_clean = series_title.lower().replace(" ", "").replace("-", "") + for item in media_path.iterdir(): + if item.is_dir() and "[imdb-" in item.name.lower(): + item_clean = item.name.lower().replace(" ", "").replace("-", "") + if title_clean in item_clean: + return item + + return None + + def process_series(self, series_path: Path) -> None: + """Process a TV series directory""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + _log("INFO", f"Processing TV series: {series_path.name}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Find video files + disk_episodes = self._find_disk_episodes(series_path) + _log("INFO", f"Found {len(disk_episodes)} episodes on disk") + + # Get episode dates + episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes) + + # Process episodes + for (season, episode), (aired, dateadded, source) in episode_dates.items(): + if (season, episode) in disk_episodes: + # Create NFO + if config.manage_nfo: + season_dir = series_path / config.tv_season_dir_format.format(season=season) + self.nfo_manager.create_episode_nfo( + season_dir, + season, episode, aired, dateadded, source, config.lock_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + video_files = disk_episodes[(season, episode)] + for video_file in video_files: + self.nfo_manager.set_file_mtime(video_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + + # Create season/tvshow NFOs + if config.manage_nfo: + seasons_processed = set() + for (season, episode) in disk_episodes.keys(): + if season not in seasons_processed: + season_dir = series_path / config.tv_season_dir_format.format(season=season) + self.nfo_manager.create_season_nfo(season_dir, season) + seasons_processed.add(season) + + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed processing TV series: {series_path.name}") + + def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]: + """Find all episode video files on disk""" + disk_episodes = {} + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + + for season_dir in series_path.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)): + continue + + try: + # Extract season number from directory name + # Handle formats like "Season 01", "S01", "Season01", etc. + dir_name = season_dir.name.lower() + if config.tv_season_dir_pattern in dir_name: + # Extract everything after the pattern + season_part = dir_name[len(config.tv_season_dir_pattern):].strip() + else: + continue + season_num = int(season_part) + except (ValueError, IndexError): + continue + + for video_file in season_dir.iterdir(): + if video_file.is_file() and video_file.suffix.lower() in video_exts: + match = re.search(r"S(\d{2})E(\d{2})", video_file.name, re.IGNORECASE) + if match: + file_season, file_episode = int(match.group(1)), int(match.group(2)) + key = (season_num, file_episode) # Use directory season number + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(video_file) + + return disk_episodes + + def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict) -> Dict: + """Gather episode dates from various sources""" + episode_dates = {} + + # Check cache first + cached_episodes = self.db.get_series_episodes(imdb_id, has_video_file_only=True) + for ep in cached_episodes: + key = (ep["season"], ep["episode"]) + if key in disk_episodes: # Only use cached data for episodes we have + episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"]) + + # Find missing episodes + cached_keys = set(episode_dates.keys()) + missing_keys = set(disk_episodes.keys()) - cached_keys + + if not missing_keys: + _log("INFO", "All episodes found in cache") + return episode_dates + + _log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes") + + # Query Sonarr for missing episodes + if self.sonarr.enabled: + series = self.sonarr.series_by_imdb(imdb_id) + if series: + series_id = series.get("id") + if series_id: + episodes = self.sonarr.episodes_for_series(series_id) + for ep in episodes: + season_num = ep.get("seasonNumber") + episode_num = ep.get("episodeNumber") + + if not isinstance(season_num, int) or not isinstance(episode_num, int): + continue + + key = (season_num, episode_num) + if key not in missing_keys: + continue + + # Get dates + aired = self._parse_date_to_iso(ep.get("airDateUtc")) + dateadded = None + source = "sonarr:episode.airDateUtc" + + # Try to get import history + episode_id = ep.get("id") + if episode_id: + import_date = self.sonarr.get_episode_import_history(episode_id) + if import_date: + dateadded = self._parse_date_to_iso(import_date) + source = "sonarr:history.import" + + if not dateadded: + dateadded = aired + + if aired or dateadded: + episode_dates[key] = (aired, dateadded, source) + + # Fill remaining gaps with external APIs + remaining_keys = missing_keys - set(episode_dates.keys()) + if remaining_keys and self.external_clients.tmdb.enabled: + tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id) + if tmdb_movie: + tv_id = tmdb_movie.get("id") + if tv_id: + seasons_needed = set(season for season, episode in remaining_keys) + for season_num in seasons_needed: + tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num) + for ep_num, air_date in tmdb_episodes.items(): + key = (season_num, ep_num) + if key in remaining_keys: + aired = self._parse_date_to_iso(air_date) + episode_dates[key] = (aired, aired, "tmdb:air_date") + + return episode_dates + + def _parse_date_to_iso(self, date_str: str) -> Optional[str]: + """Parse date string to ISO format""" + if not date_str: + return None + try: + if len(date_str) == 10 and date_str[4] == "-": + dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + + def process_season(self, series_path: Path, season_path: Path) -> None: + """Process a specific TV season directory""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + # Extract season number from path + season_match = re.search(r'season\s*(\d+)', season_path.name, re.IGNORECASE) + if not season_match: + _log("ERROR", f"Could not extract season number from: {season_path.name}") + return + + season_num = int(season_match.group(1)) + _log("INFO", f"Processing TV season {season_num}: {season_path}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Find video files in this season only + disk_episodes = self._find_season_episodes(season_path, season_num) + _log("INFO", f"Found {len(disk_episodes)} episodes in season {season_num}") + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + + # Get episode dates for this season + episode_dates = self._gather_season_episode_dates(series_path, imdb_id, season_num, disk_episodes, series_metadata) + + # Process episodes + for (season, episode), (aired, dateadded, source) in episode_dates.items(): + if (season, episode) in disk_episodes: + # Get enhanced episode metadata + enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_episode_nfo( + season_path, + season, episode, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + video_files = disk_episodes[(season, episode)] + for video_file in video_files: + self.nfo_manager.set_file_mtime(video_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) + + # Create season NFO + if config.manage_nfo: + self.nfo_manager.create_season_nfo(season_path, season_num) + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed processing season {season_num}") + + def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None: + """Process a single TV episode file""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + # Parse episode info from filename + episode_info = self._parse_episode_from_filename(episode_file.name) + if not episode_info: + _log("ERROR", f"Could not parse episode info from: {episode_file.name}") + return + + season_num, episode_num = episode_info + _log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None + + # Get episode date + aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) + + # Create NFO + if config.manage_nfo and dateadded: + self.nfo_manager.create_episode_nfo( + season_path, + season_num, episode_num, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtime + if config.fix_dir_mtimes and dateadded: + self.nfo_manager.set_file_mtime(episode_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) + + _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}") + + def _find_season_episodes(self, season_path: Path, season_num: int) -> Dict[Tuple[int, int], List[Path]]: + """Find all episode video files in a single season""" + disk_episodes = {} + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".m2ts") + + for file_path in season_path.iterdir(): + if not file_path.is_file() or file_path.suffix.lower() not in video_exts: + continue + + episode_info = self._parse_episode_from_filename(file_path.name) + if episode_info and episode_info[0] == season_num: + season, episode = episode_info + key = (season, episode) + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(file_path) + + return disk_episodes + + def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]: + """Parse season and episode numbers from filename""" + # Try SxxExx format + match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE) + if match: + return int(match.group(1)), int(match.group(2)) + + # Try season.episode format + match = re.search(r'(\d{1,2})\.(\d{1,2})', filename) + if match: + return int(match.group(1)), int(match.group(2)) + + return None + + def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Get enhanced series metadata from Sonarr API""" + try: + if not self.sonarr.enabled: + return None + + series = self.sonarr.series_by_imdb(imdb_id) + if not series: + _log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}") + return None + + # Get episodes for the series + series_id = series.get("id") + if series_id: + episodes = self.sonarr.episodes_for_series(series_id) + series["episodes"] = episodes + _log("DEBUG", f"Got {len(episodes)} episodes from Sonarr for series {series.get('title')}") + + return series + + except Exception as e: + _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}") + return None + + def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]: + """Extract specific episode metadata from series data""" + if not series_metadata or "episodes" not in series_metadata: + return None + + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + return { + "title": episode.get("title"), + "overview": episode.get("overview"), + "runtime": episode.get("runtime"), + "ratings": episode.get("ratings", {}) + } + + return None + + def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: + """Get episode dates for a specific season""" + episode_dates = {} + + for (season, episode) in disk_episodes.keys(): + if season != season_num: + continue + + aired, dateadded, source = self._get_single_episode_date(imdb_id, season, episode, series_metadata) + episode_dates[(season, episode)] = (aired, dateadded, source) + + return episode_dates + + def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + """ + Get date info for a single episode during backfill scans. + Priority: 1) Database 2) Sonarr import history 3) Air dates + """ + # Step 1: Try database first + existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + if existing and existing.get("dateadded"): + _log("DEBUG", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}") + return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") + + # Step 2: Try Sonarr import history (the real import date) + aired = None + import_date = None + + if self.sonarr.enabled: + try: + series = self.sonarr.series_by_imdb(imdb_id) + if series: + episodes = self.sonarr.episodes_for_series(series["id"]) + for ep in episodes: + if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: + aired = ep.get("airDateUtc") + import_date = self.sonarr.get_episode_import_history(ep["id"]) + if import_date: + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {local_import_date}") + return aired, local_import_date, "sonarr:history.import" + break + except Exception as e: + _log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}") + + # Step 3: Try Sonarr metadata for air date + if not aired and series_metadata and "episodes" in series_metadata: + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + aired = episode.get("airDateUtc") + break + + # Step 4: Only if we have an air date but no import history, use air date as fallback + if aired: + _log("WARNING", f"No import history found for S{season_num:02d}E{episode_num:02d}, using air date as fallback") + return aired, aired, "sonarr:episode.airDateUtc" + + # Step 5: Last resort - current time in local timezone (shouldn't happen in normal operation) + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") + _log("WARNING", f"No data found for S{season_num:02d}E{episode_num:02d}, using current time") + return None, current_time, "fallback:current_time" + + def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None: + """Process only the specific episodes mentioned in a webhook (targeted mode)""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + if not webhook_episodes: + _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}") + self.process_series(series_path) + return + + _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + + episodes_processed = 0 + for webhook_episode in webhook_episodes: + season_num = webhook_episode.get("seasonNumber") + episode_num = webhook_episode.get("episodeNumber") + + if not season_num or not episode_num: + _log("WARNING", f"Invalid episode data in webhook: {webhook_episode}") + continue + + # Check if episode file exists on disk + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + if not season_dir.exists(): + _log("WARNING", f"Season directory not found: {season_dir}") + continue + + # Find matching episode files + episode_files = [] + for file_path in season_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'): + parsed = self._parse_episode_from_filename(file_path.name) + if parsed and parsed == (season_num, episode_num): + episode_files.append(file_path) + + if not episode_files: + _log("WARNING", f"No video files found for S{season_num:02d}E{episode_num:02d}") + continue + + # Get episode date information - webhook processing prioritizes existing DB entries + _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata) + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_episode_nfo( + season_dir, + season_num, episode_num, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + for episode_file in episode_files: + self.nfo_manager.set_file_mtime(episode_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) + + # Verify database entry was saved (debug) + verification = self.db.get_episode_date(imdb_id, season_num, episode_num) + if verification: + _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") + else: + _log("ERROR", f"Failed to save episode to database: S{season_num:02d}E{episode_num:02d}") + + episodes_processed += 1 + + # Create season/tvshow NFOs if any episodes were processed + if episodes_processed > 0 and config.manage_nfo: + seasons_processed = set() + for webhook_episode in webhook_episodes: + season_num = webhook_episode.get("seasonNumber") + if season_num and season_num not in seasons_processed: + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + if season_dir.exists(): + self.nfo_manager.create_season_nfo(season_dir, season_num) + seasons_processed.add(season_num) + + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") + + def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + """ + Get episode date for webhook processing with correct priority: + 1. Check existing NFOGuard database entry (preserve previous import dates) + 2. If not found, use current time (webhook = new download) + 3. Get aired date from Sonarr/TMDB for reference + """ + # Step 1: Check if we already have this episode in our database + _log("DEBUG", f"Checking database for existing episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + if existing: + _log("DEBUG", f"Found database entry: {existing}") + if existing.get("dateadded"): + _log("INFO", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']} (source: {existing.get('source', 'unknown')})") + return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") + else: + _log("DEBUG", f"Database entry found but no dateadded value: {existing}") + else: + _log("DEBUG", f"No database entry found for IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + + # Step 2: Webhook = Source of Truth - use current timestamp in local timezone + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") + _log("INFO", f"Webhook episode processing - using current timestamp as source of truth: {current_time}") + + # Step 3: Get aired date for reference (but don't use for dateadded) + aired = None + + # Try Sonarr metadata first for air date + if series_metadata and "episodes" in series_metadata: + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + aired = episode.get("airDateUtc") + if aired: + break + + # Fallback to Sonarr API for air date if not in metadata + if not aired and self.sonarr.enabled: + try: + series = self.sonarr.series_by_imdb(imdb_id) + if series: + episodes = self.sonarr.episodes_for_series(series["id"]) + for ep in episodes: + if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: + aired = ep.get("airDateUtc") + break + except Exception as e: + _log("DEBUG", f"Error getting air date from Sonarr: {e}") + + return aired, current_time, "webhook:first_seen" + + +class MovieProcessor: + """Handles movie processing""" + + def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): + self.db = db + self.nfo_manager = nfo_manager + self.path_mapper = path_mapper + self.radarr = RadarrClient( + os.environ.get("RADARR_URL", ""), + os.environ.get("RADARR_API_KEY", "") + ) + self.external_clients = ExternalClientManager() + + def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]: + """Find movie directory path""" + # Try webhook path first + if radarr_path: + container_path = self.path_mapper.radarr_path_to_container_path(radarr_path) + path_obj = Path(container_path) + if path_obj.exists(): + return path_obj + + # Search by IMDb ID or title + for media_path in config.movie_paths: + if not media_path.exists(): + continue + + # Search by IMDb ID + if imdb_id: + pattern = str(media_path / f"*[imdb-{imdb_id}]*") + matches = glob.glob(pattern) + if matches: + return Path(matches[0]) + + # Search by title + if movie_title: + title_clean = movie_title.lower().replace(" ", "").replace("-", "") + for item in media_path.iterdir(): + if item.is_dir() and "[imdb-" in item.name.lower(): + item_clean = item.name.lower().replace(" ", "").replace("-", "") + if title_clean in item_clean: + return item + + return None + + def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None: + """Process a movie directory""" + imdb_id = self.nfo_manager.parse_imdb_from_path(movie_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in movie path: {movie_path}") + return + + _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})") + + # Update database + self.db.upsert_movie(imdb_id, str(movie_path)) + + # Check for video files + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir()) + + if not has_video: + _log("WARNING", f"No video files found in: {movie_path}") + self.db.upsert_movie_dates(imdb_id, None, None, None, False) + return + + # Get existing dates + existing = self.db.get_movie_dates(imdb_id) + _log("DEBUG", f"Database lookup for {imdb_id}: {existing}") + + # Handle webhook mode - prioritize database, then use proper date logic + if webhook_mode: + _log("DEBUG", f"Webhook mode: existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else 'N/A'}") + if existing and existing.get("dateadded"): + _log("INFO", f"Webhook processing - using existing database entry: {existing['dateadded']} (source: {existing.get('source', 'unknown')})") + dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") + else: + if existing: + _log("INFO", f"Webhook processing - database entry exists but no dateadded field: {existing}") + else: + _log("INFO", f"Webhook processing - no database entry found for {imdb_id}") + _log("INFO", f"Using full date decision logic") + # Use same logic as manual scan to check Radarr import dates, release dates, etc. + should_query = True # Always query for webhooks when no database entry exists + dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) + + # Only if ALL date sources fail, fall back to current timestamp + if dateadded is None: + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") + _log("INFO", f"Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}") + dateadded, source = current_time, "webhook:fallback_timestamp" + else: + # Manual scan mode - determine if we should query APIs + should_query = ( + config.movie_poll_mode == "always" or + (config.movie_poll_mode == "if_missing" and not existing) or + (config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime") + ) + + # Use existing movie date decision logic + dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) + + # Skip processing if no valid date found and file dates disabled + if dateadded is None: + _log("WARNING", f"Skipping movie {movie_path.name} - no valid date source available") + self.db.upsert_movie_dates(imdb_id, released, None, source, True) + return + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_movie_nfo( + movie_path, imdb_id, dateadded, released, source, config.lock_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED": + self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) + + # Save to database + self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True) + + _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") + + def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]: + """Decide movie dates based on configuration and available data""" + if not should_query and existing: + return existing["dateadded"], existing["source"], existing.get("released") + + # Query Radarr for movie info + radarr_movie = None + if should_query and self.radarr.api_key: + radarr_movie = self.radarr.movie_by_imdb(imdb_id) + + released = None + if radarr_movie: + released = self._parse_date_to_iso(radarr_movie.get("inCinemas")) + + # Try import history first if configured + if config.movie_priority == "import_then_digital": + import_date, import_source = None, None + if radarr_movie: + movie_id = radarr_movie.get("id") + if movie_id: + import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) + _log("INFO", f"Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}") + + # Check for special case: rename-first scenario (should prefer release dates) + if import_source == "radarr:db.prefer_release_dates": + _log("INFO", f"🎯 Movie {imdb_id} has rename-first history - skipping import, preferring release dates") + # Fall through to release date logic below + # Check if we got a real import date or just file date fallback + elif import_date and import_source != "radarr:db.file.dateAdded": + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + return local_import_date, import_source, released + + # Get digital release date for comparison/fallback + _log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date fallback...") + digital_date, digital_source = self._get_digital_release_date(imdb_id) + _log("INFO", f"Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}") + + # If we only have file date and release date exists, prefer it if reasonable and enabled + if import_date and import_source == "radarr:db.file.dateAdded" and digital_date and config.prefer_release_dates_over_file_dates: + # Compare dates - prefer release date if it's reasonable + if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id): + _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") + return digital_date, digital_source, released + else: + # Convert file date to local timezone for NFO files + local_file_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable") + return local_file_date, import_source, released + + # Use whichever we have + if import_date: + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + return local_import_date, import_source, released + elif digital_date: + _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") + return digital_date, digital_source, released + else: + _log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks") + + # Try Radarr's own NFO premiered date as fallback + radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path) + if radarr_premiered: + _log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}") + return radarr_premiered, "radarr:nfo.premiered", released + + else: # digital_then_import + # Try digital release first + digital_date, digital_source = self._get_digital_release_date(imdb_id) + if digital_date: + return digital_date, digital_source, released + + # Fall back to import history + if radarr_movie: + movie_id = radarr_movie.get("id") + if movie_id: + import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) + if import_date: + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + return local_import_date, import_source, released + + # Last resort: file mtime (if allowed) + if config.allow_file_date_fallback: + return self._get_file_mtime_date(movie_path) + else: + _log("INFO", f"No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation") + return None, "no_valid_date_source", None + + def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]: + """Get release date from external sources using configured priority""" + _log("INFO", f"🔍 Calling external clients for {imdb_id}") + _log("INFO", f"Release date priority: {config.release_date_priority}") + _log("INFO", f"Smart validation enabled: {config.enable_smart_date_validation}") + + try: + release_result = self.external_clients.get_release_date_by_priority( + imdb_id, + config.release_date_priority, + enable_smart_validation=config.enable_smart_date_validation + ) + _log("INFO", f"External clients result for {imdb_id}: {release_result}") + + if release_result: + _log("INFO", f"✅ Got release date: {release_result[0]} from {release_result[1]}") + return release_result[0], release_result[1] + else: + _log("WARNING", f"❌ No release date found from external clients for {imdb_id}") + return None, "release:none" + except Exception as e: + _log("ERROR", f"External clients error for {imdb_id}: {e}") + return None, f"release:error:{str(e)}" + + def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]: + """Extract premiered date from Radarr's existing movie.nfo file""" + try: + nfo_path = movie_path / "movie.nfo" + if not nfo_path.exists(): + _log("DEBUG", f"No existing NFO file found at {nfo_path}") + return None + + nfo_content = nfo_path.read_text(encoding='utf-8') + + # Look for YYYY-MM-DD + import re + match = re.search(r'(\d{4}-\d{2}-\d{2})', nfo_content) + if match: + premiered_date = match.group(1) + # Convert to ISO format with timezone + iso_date = f"{premiered_date}T00:00:00+00:00" + _log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}") + return iso_date + else: + _log("DEBUG", f"No tag found in existing NFO") + return None + + except Exception as e: + _log("ERROR", f"Error reading Radarr NFO file: {e}") + return None + + def _get_file_mtime_date(self, movie_path: Path) -> Tuple[str, str, Optional[str]]: + """Get date from file modification time as last resort""" + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + newest_mtime = None + + for file_path in movie_path.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in video_exts: + try: + mtime = file_path.stat().st_mtime + if newest_mtime is None or mtime > newest_mtime: + newest_mtime = mtime + except Exception: + continue + + if newest_mtime: + try: + # Use local timezone for file modification times + local_tz = _get_local_timezone() + iso_date = datetime.fromtimestamp(newest_mtime, tz=local_tz).isoformat(timespec="seconds") + return iso_date, "file:mtime", None + except Exception: + pass + + return "MANUAL_REVIEW_NEEDED", "manual_review_required", None + + def _should_prefer_release_over_file_date(self, release_date: str, release_source: str, theatrical_release: Optional[str], imdb_id: str) -> bool: + """ + Decide if release date should be preferred over file date + + Logic: + - For theatrical dates: Always prefer over file dates (they're authoritative) + - For physical dates: Usually prefer over file dates + - For digital dates: Prefer if reasonable (not decades before theatrical) + """ + try: + release_dt = datetime.fromisoformat(release_date.replace("Z", "+00:00")) + + # Always prefer theatrical and physical releases over file dates + if any(release_type in release_source for release_type in ["theatrical", "physical"]): + _log("INFO", f"Release date {release_date} ({release_source}) for {imdb_id}, preferring over file date") + return True + + # If we have theatrical release date, compare digital against it + if theatrical_release: + theatrical_dt = datetime.fromisoformat(theatrical_release.replace("Z", "+00:00")) + year_diff = release_dt.year - theatrical_dt.year + + # If digital is more than 10 years before theatrical, it's probably wrong + if year_diff < -10: + _log("INFO", f"Release date {release_date} is {abs(year_diff)} years before theatrical {theatrical_release} for {imdb_id}, using file date instead") + return False + + # If digital is within reasonable range (theatrical to +20 years), use it + if -2 <= year_diff <= 20: + _log("INFO", f"Release date {release_date} is reasonable for {imdb_id} (theatrical: {theatrical_release}), preferring over file date") + return True + + # If no theatrical date, use digital if it's not absurdly old + if release_dt.year >= 1990: # Reasonable minimum for digital releases + _log("INFO", f"Release date {release_date} seems reasonable for {imdb_id}, preferring over file date") + return True + + _log("INFO", f"Release date {release_date} seems too old for {imdb_id}, using file date instead") + return False + + except Exception as e: + _log("WARNING", f"Error comparing dates for {imdb_id}: {e}") + return False + + def _parse_date_to_iso(self, date_str: str) -> Optional[str]: + """Parse date string to ISO format""" + if not date_str: + return None + try: + if len(date_str) == 10 and date_str[4] == "-": + dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + + +# --------------------------- +# Batching System +# --------------------------- + +class WebhookBatcher: + """Batches webhook events to avoid processing storms""" + + def __init__(self): + self.pending: Dict[str, Dict] = {} + self.timers: Dict[str, threading.Timer] = {} + self.processing: Set[str] = set() + self.lock = threading.Lock() + self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent) + + def add_webhook(self, key: str, webhook_data: Dict, media_type: str): + """Add webhook to batch queue""" + with self.lock: + if key in self.timers: + self.timers[key].cancel() + + webhook_data['media_type'] = media_type + self.pending[key] = webhook_data + _log("INFO", f"Batched {media_type} webhook for {key}") + _log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s") + + timer = threading.Timer(config.batch_delay, self._process_item, args=[key]) + self.timers[key] = timer + timer.start() + + def _process_item(self, key: str): + """Process a batched item""" + with self.lock: + if key in self.processing or key not in self.pending: + return + self.processing.add(key) + webhook_data = self.pending.pop(key) + self.timers.pop(key, None) + + try: + self.executor.submit(self._process_sync, key, webhook_data) + except Exception as e: + _log("ERROR", f"Error submitting processing for {key}: {e}") + with self.lock: + self.processing.discard(key) + + def _process_sync(self, key: str, webhook_data: Dict): + """Synchronous processing of webhook data with validation""" + try: + media_type = webhook_data.get('media_type') + path_str = webhook_data.get('path') + + _log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}") + + if not path_str: + _log("ERROR", f"No path found for {media_type} {key}") + return + + path_obj = Path(path_str) + if not path_obj.exists(): + _log("ERROR", f"BATCH PROCESSING FAILED: Path does not exist: {path_obj}") + _log("ERROR", f"This indicates a path mapping issue - webhook rejected to prevent wrong processing") + return + + # CRITICAL: Validate that the path contains the expected IMDb ID for movies + if media_type == 'movie': + expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key + if expected_imdb not in path_str.lower(): + _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str}") + _log("ERROR", f"This prevents processing wrong movies due to batch corruption") + return + _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path") + + # Process based on media type + if media_type == 'tv': + # Check processing mode for TV webhooks + processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode) + episodes_data = webhook_data.get('episodes', []) + + if processing_mode == 'targeted' and episodes_data: + _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") + tv_processor.process_webhook_episodes(path_obj, episodes_data) + else: + _log("INFO", f"Using series processing mode (fallback or configured)") + tv_processor.process_series(path_obj) + elif media_type == 'movie': + movie_processor.process_movie(path_obj, webhook_mode=True) + else: + _log("ERROR", f"Unknown media type: {media_type}") + + except Exception as e: + _log("ERROR", f"Error processing {media_type} {key}: {e}") + finally: + with self.lock: + self.processing.discard(key) + + def get_status(self) -> Dict: + """Get batch queue status""" + with self.lock: + return { + "pending_items": list(self.pending.keys()), + "processing_items": list(self.processing), + "pending_count": len(self.pending), + "processing_count": len(self.processing) + } + + +# --------------------------- +# FastAPI Application +# --------------------------- + +# Get version +try: + version = (Path(__file__).parent / "VERSION").read_text().strip() +except: + version = "0.1.0" + +# Check if running from dev branch (detect at runtime) +try: + # Try to read git branch from .git/HEAD + git_head_path = Path(__file__).parent / ".git" / "HEAD" + if git_head_path.exists(): + head_content = git_head_path.read_text().strip() + if "ref: refs/heads/dev" in head_content: + version = f"{version}-dev" + elif head_content.startswith("ref: refs/heads/"): + # Extract branch name for other branches + branch = head_content.split("refs/heads/")[-1] + if branch != "main": + version = f"{version}-{branch}" +except Exception: + # If git detection fails, that's fine - use base version + pass + +app = FastAPI( + title="NFOGuard", + description="Webhook server for preserving media import dates", + version=version +) + +start_time = datetime.now(timezone.utc) + +# Initialize components +db = NFOGuardDatabase(config.db_path) +nfo_manager = NFOManager(config.manager_brand) +path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper +tv_processor = TVProcessor(db, nfo_manager, path_mapper) +movie_processor = MovieProcessor(db, nfo_manager, path_mapper) +batcher = WebhookBatcher() + +# --------------------------- +# Webhook Handlers +# --------------------------- + +async def _read_payload(request: Request) -> dict: + """Read webhook payload from request""" + content_type = (request.headers.get("content-type") or "").lower() + try: + if "application/json" in content_type: + return await request.json() + form = await request.form() + if "payload" in form: + return json.loads(form["payload"]) + return dict(form) + except Exception as e: + _log("ERROR", f"Failed to read webhook payload: {e}") + return {} + +@app.post("/webhook/sonarr") +async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks): + """Handle Sonarr webhooks""" + try: + payload = await _read_payload(request) + if not payload: + raise HTTPException(status_code=422, detail="Empty Sonarr payload") + + webhook = SonarrWebhook(**payload) + _log("INFO", f"Received Sonarr webhook: {webhook.eventType}") + + if webhook.eventType not in ["Download", "Upgrade", "Rename"]: + return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"} + + if not webhook.series: + return {"status": "ignored", "reason": "No series data"} + + series_info = webhook.series + series_title = series_info.get("title", "") + imdb_id = series_info.get("imdbId", "").replace("tt", "").strip() + if imdb_id: + imdb_id = f"tt{imdb_id}" + sonarr_path = series_info.get("path", "") + + if not imdb_id: + _log("ERROR", f"No IMDb ID for series: {series_title}") + return {"status": "error", "reason": "No IMDb ID"} + + # Find series path + series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path) + if not series_path: + _log("ERROR", f"Could not find series directory: {series_title} ({imdb_id})") + return {"status": "error", "reason": "Series directory not found"} + + # Add to batch queue with TV-prefixed key to avoid movie conflicts + tv_batch_key = f"tv:{imdb_id}" + webhook_dict = { + 'path': str(series_path), + 'series_info': series_info, + 'event_type': webhook.eventType, + 'episodes': webhook.episodes or [], # Include episode data for targeted processing + 'processing_mode': config.tv_webhook_processing_mode + } + batcher.add_webhook(tv_batch_key, webhook_dict, 'tv') + + return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"} + + except Exception as e: + _log("ERROR", f"Sonarr webhook error: {e}") + raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}") + +@app.post("/webhook/radarr") +async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): + """Handle Radarr webhooks""" + try: + payload = await _read_payload(request) + _log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}") + _log("DEBUG", f"Full Radarr webhook payload: {payload}") + + # Filter supported event types (same as Sonarr: Download, Upgrade, Rename) + event_type = payload.get('eventType', '') + if event_type not in ["Download", "Upgrade", "Rename"]: + return {"status": "ignored", "reason": f"Event type {event_type} not processed"} + + # Extract movie info + movie_data = payload.get("movie", {}) + if not movie_data: + _log("WARNING", "No movie data in Radarr webhook") + return {"status": "error", "message": "No movie data"} + + # Get IMDb ID for batching key + imdb_id = movie_data.get("imdbId", "").lower() + if not imdb_id: + _log("WARNING", "No IMDb ID in Radarr webhook movie data") + return {"status": "error", "message": "No IMDb ID"} + + # Get movie path and map it + movie_path = movie_data.get("folderPath") or movie_data.get("path", "") + if not movie_path: + _log("ERROR", "No movie path in Radarr webhook") + return {"status": "error", "message": "No movie path provided"} + + # Map the path to container path + container_path = path_mapper.radarr_path_to_container_path(movie_path) + _log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}") + + # CRITICAL: Verify the mapped path actually exists + from pathlib import Path + if not Path(container_path).exists(): + _log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}") + _log("ERROR", f"This prevents processing wrong movies due to path mapping issues") + return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"} + + # Verify the path contains the expected IMDb ID + if imdb_id not in container_path.lower(): + _log("WARNING", f"IMDb ID {imdb_id} not found in container path {container_path}") + + # Create movie-specific webhook data with proper path validation + movie_webhook_data = { + 'path': container_path, # Use verified container path + 'movie_info': movie_data, + 'event_type': payload.get('eventType'), + 'original_payload': payload + } + + # Add to batch queue with movie-prefixed key to avoid TV conflicts + movie_batch_key = f"movie:{imdb_id}" + _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}") + batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie") + + return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"} + + except Exception as e: + _log("ERROR", f"Radarr webhook error: {e}") + return {"status": "error", "message": str(e)} + +# --------------------------- +# API Endpoints +# --------------------------- + +@app.get("/health") +async def health() -> HealthResponse: + """Health check endpoint with Radarr database status""" + uptime = datetime.now(timezone.utc) - start_time + + # Check NFOGuard database + try: + with db.get_connection() as conn: + conn.execute("SELECT 1").fetchone() + db_status = "healthy" + except Exception as e: + db_status = f"error: {e}" + + # Check Radarr database if available + radarr_db_health = None + overall_status = "healthy" if db_status == "healthy" else "degraded" + + # Get Radarr client with database access from movie processor + try: + if hasattr(movie_processor, 'radarr') and movie_processor.radarr: + radarr_client = movie_processor.radarr + if hasattr(radarr_client, 'db_client') and radarr_client.db_client: + try: + radarr_db_health = radarr_client.db_client.health_check() + if radarr_db_health["status"] != "healthy": + overall_status = "degraded" + except Exception as e: + radarr_db_health = { + "status": "error", + "error": str(e), + "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds") + } + overall_status = "degraded" + except Exception as e: + # If movie processor isn't available, skip database health check + _log("DEBUG", f"Skipping Radarr database health check: {e}") + + return HealthResponse( + status=overall_status, + version=version, + uptime=str(uptime), + database_status=db_status, + radarr_database=radarr_db_health + ) + +@app.get("/stats") +async def get_stats(): + """Get database statistics""" + try: + return db.get_stats() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/batch/status") +async def batch_status(): + """Get batch queue status""" + return batcher.get_status() + +@app.get("/debug/movie/{imdb_id}") +async def debug_movie_import_date(imdb_id: str): + """Debug endpoint to analyze movie import date detection""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + _log("INFO", f"=== DEBUG MOVIE IMPORT DATE: {imdb_id} ===") + + if not (os.environ.get("RADARR_URL") and os.environ.get("RADARR_API_KEY")): + return { + "error": "Radarr not configured", + "imdb_id": imdb_id, + "radarr_configured": False + } + + # Create Radarr client + from clients.radarr_client import RadarrClient + radarr_client = RadarrClient( + os.environ.get("RADARR_URL"), + os.environ.get("RADARR_API_KEY") + ) + + # Look up movie + movie_obj = radarr_client.movie_by_imdb(imdb_id) + if not movie_obj: + return { + "error": f"Movie not found in Radarr for IMDb ID {imdb_id}", + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": False + } + + movie_id = movie_obj.get("id") + movie_title = movie_obj.get("title") + + _log("INFO", f"Found movie: {movie_title} (Radarr ID: {movie_id})") + + # Test the FULL movie processing pipeline (not just database lookup) + _log("INFO", f"=== TESTING FULL MOVIE PROCESSING PIPELINE ===") + + # Create a dummy path for testing the decision logic + from pathlib import Path + dummy_path = Path("/tmp/test") + + try: + # Use the global movie processor instance to test full decision logic + global movie_processor + if movie_processor: + # First check external clients configuration + _log("INFO", f"=== CHECKING EXTERNAL CLIENTS CONFIG ===") + try: + tmdb_key = os.environ.get("TMDB_API_KEY", "") + _log("INFO", f"TMDB API Key configured: {'✅ YES' if tmdb_key else '❌ NO'}") + if tmdb_key: + _log("INFO", f"TMDB API Key length: {len(tmdb_key)} chars") + + # Check if external clients exist + external_clients_available = hasattr(movie_processor, 'external_clients') and movie_processor.external_clients + _log("INFO", f"External clients initialized: {'✅ YES' if external_clients_available else '❌ NO'}") + + except Exception as e: + _log("ERROR", f"Error checking external clients config: {e}") + + # Test the full decision logic (including TMDB fallback) + final_date, final_source, released = movie_processor._decide_movie_dates( + imdb_id, dummy_path, should_query=True, existing=None + ) + + _log("INFO", f"=== FULL PIPELINE RESULT ===") + _log("INFO", f"Final date: {final_date}") + _log("INFO", f"Final source: {final_source}") + _log("INFO", f"Released (theater): {released}") + + return { + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": True, + "movie_title": movie_title, + "movie_id": movie_id, + "full_pipeline_test": { + "final_date": final_date, + "final_source": final_source, + "theater_release": released, + "decision_logic": "✅ TESTED FULL PIPELINE INCLUDING TMDB FALLBACK" + }, + "database_only_test": { + "radarr_db_result": radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True), + "note": "This is just the database part - fallback happens in full pipeline" + }, + "debug_info": { + "radarr_url": os.environ.get("RADARR_URL"), + "movie_digital_release": movie_obj.get("digitalRelease"), + "movie_in_cinemas": movie_obj.get("inCinemas"), + "movie_physical_release": movie_obj.get("physicalRelease"), + "movie_folder_path": movie_obj.get("folderPath") + } + } + else: + _log("ERROR", "Movie processor not available - testing database only") + # Fallback to database-only testing + import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) + return { + "error": "Movie processor not available - only database test performed", + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": True, + "movie_title": movie_title, + "movie_id": movie_id, + "detected_import_date": import_date, + "import_source": source, + "debug_info": { + "note": "FULL PIPELINE TEST FAILED - movie processor not initialized" + } + } + + except Exception as pipeline_error: + _log("ERROR", f"Full pipeline test failed: {pipeline_error}") + # Fallback to database-only testing + import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) + return { + "pipeline_error": str(pipeline_error), + "imdb_id": imdb_id, + "radarr_configured": True, + "movie_found": True, + "movie_title": movie_title, + "movie_id": movie_id, + "detected_import_date": import_date, + "import_source": source, + "debug_info": { + "note": "FULL PIPELINE TEST FAILED - showing database-only result" + } + } + + except Exception as e: + _log("ERROR", f"Debug endpoint error for {imdb_id}: {e}") + return { + "error": str(e), + "imdb_id": imdb_id, + "success": False + } + +@app.get("/debug/movie/{imdb_id}/history") +async def debug_movie_history(imdb_id: str): + """Detailed history analysis for a movie""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + _log("INFO", f"=== DETAILED HISTORY ANALYSIS: {imdb_id} ===") + + # Use database-only mode for consistency + if not movie_processor.radarr.db_client: + return {"error": "Radarr database not configured"} + + radarr_client = movie_processor.radarr + + # Look up movie + movie_obj = radarr_client.movie_by_imdb(imdb_id) + if not movie_obj: + return {"error": f"Movie not found for {imdb_id}"} + + movie_id = movie_obj.get("id") + movie_title = movie_obj.get("title") + + # Get history from database instead of API + if not radarr_client.db_client: + return {"error": "Database-only mode required"} + + # TODO: Implement database-only history retrieval + return { + "error": "History endpoint temporarily disabled - use /debug/movie/{imdb_id}/priority for date analysis", + "imdb_id": imdb_id, + "movie_id": movie_id, + "movie_title": movie_title, + "note": "This endpoint needs database-only implementation to avoid showing wrong movie events" + } + + # Analyze each event + analyzed_events = [] + for event in all_history: + event_type = event.get("eventType", "") + date_str = event.get("date", "") + event_data = event.get("data", {}) + + # Get source path info + source_path = ( + event_data.get('droppedPath', '') or + event_data.get('sourcePath', '') or + event_data.get('path', '') or + event_data.get('sourceTitle', '') + ) + + # Analyze if this is a real import + is_real, reason, date_iso = radarr_client._analyze_event_for_import(event) + + analyzed_events.append({ + "event_type": event_type, + "date": date_str, + "source_path": source_path, + "is_real_import": is_real, + "analysis_reason": reason, + "parsed_date": date_iso, + "full_data": event_data + }) + + # Find what our algorithm would pick - DATABASE ONLY + picked_date, _ = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) + + return { + "imdb_id": imdb_id, + "movie_title": movie_title, + "movie_id": movie_id, + "total_history_events": len(all_history), + "our_algorithm_picked": picked_date, + "all_events": analyzed_events, + "expected_july_date": "2025-07-07" in (picked_date or "") + } + + except Exception as e: + _log("ERROR", f"History debug error for {imdb_id}: {e}") + return {"error": str(e)} + +@app.post("/manual/scan") +async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"): + """Manual scan endpoint""" + if scan_type not in ["both", "tv", "movies"]: + raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'") + + async def run_scan(): + paths_to_scan = [] + if path: + paths_to_scan = [Path(path)] + else: + if scan_type in ["both", "tv"]: + paths_to_scan.extend(config.tv_paths) + if scan_type in ["both", "movies"]: + paths_to_scan.extend(config.movie_paths) + + for scan_path in paths_to_scan: + if not scan_path.exists(): + continue + + if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path): + # Handle specific season/episode path + if path and scan_path.name.lower().startswith('season'): + # Single season processing + series_path = scan_path.parent + if nfo_manager.parse_imdb_from_path(series_path): + _log("INFO", f"Processing single season: {scan_path}") + try: + tv_processor.process_season(series_path, scan_path) + except Exception as e: + _log("ERROR", f"Failed processing season {scan_path}: {e}") + elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'): + # Single episode processing + season_path = scan_path.parent + series_path = season_path.parent + if nfo_manager.parse_imdb_from_path(series_path): + _log("INFO", f"Processing single episode: {scan_path}") + try: + tv_processor.process_episode_file(series_path, season_path, scan_path) + except Exception as e: + _log("ERROR", f"Failed processing episode {scan_path}: {e}") + else: + # Full series processing + for item in scan_path.iterdir(): + if item.is_dir() and nfo_manager.parse_imdb_from_path(item): + try: + tv_processor.process_series(item) + except Exception as e: + _log("ERROR", f"Failed processing TV series {item}: {e}") + + if scan_type in ["both", "movies"] and scan_path in config.movie_paths: + _log("INFO", f"Scanning movies in: {scan_path}") + movie_count = 0 + for item in scan_path.iterdir(): + if item.is_dir() and nfo_manager.parse_imdb_from_path(item): + movie_count += 1 + _log("INFO", f"Processing movie: {item.name}") + try: + movie_processor.process_movie(item) + except Exception as e: + _log("ERROR", f"Failed processing movie {item}: {e}") + _log("INFO", f"Completed movie scan: {movie_count} movies processed in {scan_path}") + + background_tasks.add_task(run_scan) + return {"status": "started", "message": f"Manual {scan_type} scan started"} + +@app.post("/tv/scan-season") +async def scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest): + """Scan a specific TV season - URL-safe endpoint""" + try: + series_dir = Path(request.series_path) + season_dir = series_dir / request.season_name + + if not series_dir.exists(): + raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}") + if not season_dir.exists(): + raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}") + + imdb_id = nfo_manager.parse_imdb_from_path(series_dir) + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID found in series path") + + async def process_season(): + _log("INFO", f"Processing TV season: {season_dir}") + try: + tv_processor.process_season(series_dir, season_dir) + except Exception as e: + _log("ERROR", f"Failed processing season {season_dir}: {e}") + + background_tasks.add_task(process_season) + return {"status": "started", "message": f"Season scan started for {request.season_name}"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/tv/scan-episode") +async def scan_tv_episode(background_tasks: BackgroundTasks, request: TVEpisodeRequest): + """Scan a specific TV episode - URL-safe endpoint""" + try: + series_dir = Path(request.series_path) + season_dir = series_dir / request.season_name + episode_file = season_dir / request.episode_name + + if not series_dir.exists(): + raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}") + if not episode_file.exists(): + raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}") + + imdb_id = nfo_manager.parse_imdb_from_path(series_dir) + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID found in series path") + + async def process_episode(): + _log("INFO", f"Processing TV episode: {episode_file}") + try: + tv_processor.process_episode_file(series_dir, season_dir, episode_file) + except Exception as e: + _log("ERROR", f"Failed processing episode {episode_file}: {e}") + + background_tasks.add_task(process_episode) + return {"status": "started", "message": f"Episode scan started for {request.episode_name}"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/test/bulk-update") +async def test_bulk_update(): + """Test bulk update functionality without modifying data""" + try: + from clients.radarr_db_client import RadarrDbClient + + # Test Radarr database + radarr_db = RadarrDbClient.from_env() + if not radarr_db: + return {"status": "error", "message": "Radarr database connection failed"} + + # Test query execution + query = 'SELECT COUNT(*) FROM "Movies" m JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" WHERE mm."ImdbId" IS NOT NULL' + with radarr_db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(query) + movie_count = cursor.fetchone()[0] + + return { + "status": "success", + "message": "Bulk update test passed", + "movies_with_imdb": movie_count, + "database_type": radarr_db.db_type + } + except Exception as e: + return {"status": "error", "message": f"Bulk update test failed: {e}"} + +@app.post("/test/movie-scan") +async def test_movie_scan(): + """Test movie directory scanning logic""" + try: + results = [] + for path in config.movie_paths: + path_result = { + "path": str(path), + "exists": path.exists(), + "movies_found": 0 + } + + if path.exists(): + for item in path.iterdir(): + if item.is_dir() and nfo_manager.parse_imdb_from_path(item): + path_result["movies_found"] += 1 + + results.append(path_result) + + total_movies = sum(r["movies_found"] for r in results) + return { + "status": "success", + "message": f"Movie scan test found {total_movies} movies", + "path_results": results + } + except Exception as e: + return {"status": "error", "message": f"Movie scan test failed: {e}"} + +@app.post("/bulk/update") +async def trigger_bulk_update(background_tasks: BackgroundTasks): + """Trigger bulk update of all movies""" + async def run_bulk_update(): + try: + from bulk_update_movies import bulk_update_all_movies + success = bulk_update_all_movies() + _log("INFO", f"Bulk update completed: {'success' if success else 'failed'}") + except Exception as e: + _log("ERROR", f"Bulk update error: {e}") + + background_tasks.add_task(run_bulk_update) + return {"status": "started", "message": "Bulk update started"} + +@app.get("/debug/movie/{imdb_id}/priority") +async def debug_movie_priority_logic(imdb_id: str): + """Debug endpoint showing how MOVIE_PRIORITY affects date selection""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + result = { + "imdb_id": imdb_id, + "movie_priority": config.movie_priority, + "release_date_priority": config.release_date_priority, + "priority_explanation": "", + "date_sources": {}, + "selected_date": None, + "selected_source": None + } + + # Get Radarr import date + if movie_processor.radarr.api_key: + radarr_movie = movie_processor.radarr.movie_by_imdb(imdb_id) + if radarr_movie: + movie_id = radarr_movie.get("id") + if movie_id: + import_date, import_source = movie_processor.radarr.get_movie_import_date(movie_id) + if import_date: + result["date_sources"]["radarr_import"] = { + "date": import_date, + "source": import_source + } + + # Get digital release dates with detailed logging + digital_date, digital_source = movie_processor._get_digital_release_date(imdb_id) + if digital_date: + result["date_sources"]["digital_release"] = { + "date": digital_date, + "source": digital_source + } + else: + # Add debug info about why digital date wasn't found + candidates = movie_processor.external_clients.get_digital_release_candidates(imdb_id) + result["date_sources"]["digital_release_debug"] = { + "candidates_found": len(candidates), + "candidates": candidates[:3] if candidates else [], # Show first 3 + "reason": digital_source if digital_source else "no_digital_dates_found" + } + + # Show priority logic + if config.movie_priority == "import_then_digital": + priority_list = " → ".join(config.release_date_priority) + result["priority_explanation"] = f"1st: Radarr import history, 2nd: Release dates ({priority_list}), 3rd: file mtime. Note: If import is only file date, prefer reasonable release dates." + + radarr_import = result["date_sources"].get("radarr_import") + digital_release = result["date_sources"].get("digital_release") + + # Check for file date fallback logic + if radarr_import and radarr_import["source"] == "radarr:db.file.dateAdded" and digital_release: + # Test the smart logic + would_prefer_digital = movie_processor._should_prefer_release_over_file_date( + digital_release["date"], + digital_release["source"], + None, # We don't have theatrical date in this debug context + imdb_id + ) + result["file_date_detected"] = True + result["would_prefer_digital"] = would_prefer_digital + + if would_prefer_digital: + result["selected_date"] = digital_release["date"] + result["selected_source"] = digital_release["source"] + " (preferred over file date)" + else: + result["selected_date"] = radarr_import["date"] + result["selected_source"] = radarr_import["source"] + " (digital too old)" + elif radarr_import and radarr_import["source"] != "radarr:db.file.dateAdded": + result["selected_date"] = radarr_import["date"] + result["selected_source"] = radarr_import["source"] + elif digital_release: + result["selected_date"] = digital_release["date"] + result["selected_source"] = digital_release["source"] + else: # digital_then_import + result["priority_explanation"] = "1st: TMDB/OMDb digital release, 2nd: Radarr import history, 3rd: file mtime" + if result["date_sources"].get("digital_release"): + result["selected_date"] = result["date_sources"]["digital_release"]["date"] + result["selected_source"] = result["date_sources"]["digital_release"]["source"] + elif result["date_sources"].get("radarr_import"): + result["selected_date"] = result["date_sources"]["radarr_import"]["date"] + result["selected_source"] = result["date_sources"]["radarr_import"]["source"] + + # Show external API status + result["external_apis"] = { + "tmdb_enabled": movie_processor.external_clients.tmdb.enabled, + "omdb_enabled": movie_processor.external_clients.omdb.enabled, + "jellyseerr_enabled": movie_processor.external_clients.jellyseerr.enabled + } + + return result + + except Exception as e: + return {"error": str(e), "imdb_id": imdb_id} + +@app.get("/debug/tmdb/{imdb_id}") +async def debug_tmdb_lookup(imdb_id: str): + """Debug TMDB API lookup for a specific movie""" + try: + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + + result = { + "imdb_id": imdb_id, + "tmdb_api_enabled": movie_processor.external_clients.tmdb.enabled, + "tmdb_api_key_configured": bool(movie_processor.external_clients.tmdb.api_key), + "steps": {} + } + + if not movie_processor.external_clients.tmdb.enabled: + result["error"] = "TMDB API not enabled - check TMDB_API_KEY environment variable" + return result + + # Step 1: Find movie by IMDb ID + _log("INFO", f"TMDB Debug: Looking up {imdb_id}") + tmdb_movie = movie_processor.external_clients.tmdb.find_by_imdb(imdb_id) + result["steps"]["1_find_by_imdb"] = { + "found": bool(tmdb_movie), + "tmdb_movie": tmdb_movie if tmdb_movie else None + } + + if not tmdb_movie: + result["error"] = f"Movie {imdb_id} not found in TMDB" + return result + + tmdb_id = tmdb_movie.get("id") + result["tmdb_id"] = tmdb_id + + # Step 2: Get release dates + if tmdb_id: + _log("INFO", f"TMDB Debug: Getting release dates for TMDB ID {tmdb_id}") + release_dates_result = movie_processor.external_clients.tmdb._get(f"/movie/{tmdb_id}/release_dates") + result["steps"]["2_release_dates"] = { + "raw_response": release_dates_result, + "has_results": bool(release_dates_result and release_dates_result.get("results")) + } + + # Step 3: Look for US digital releases + if release_dates_result and release_dates_result.get("results"): + us_releases = [] + for country_data in release_dates_result["results"]: + if country_data.get("iso_3166_1") == "US": + us_releases = country_data.get("release_dates", []) + break + + result["steps"]["3_us_releases"] = { + "found_us_data": bool(us_releases), + "us_releases": us_releases + } + + # Step 4: Look for digital releases (type 4) + digital_releases = [r for r in us_releases if r.get("type") == 4] + result["steps"]["4_digital_releases"] = { + "digital_count": len(digital_releases), + "digital_releases": digital_releases + } + + # Step 5: Test the full digital release function + digital_date = movie_processor.external_clients.tmdb.get_digital_release_date(imdb_id) + result["steps"]["5_final_result"] = { + "digital_date": digital_date, + "success": bool(digital_date) + } + + return result + + except Exception as e: + return {"error": str(e), "imdb_id": imdb_id, "traceback": str(e)} + +# --------------------------- +# Main +# --------------------------- + +if __name__ == "__main__": + _log("INFO", "Starting NFOGuard") + _log("INFO", f"Version: {version}") + _log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}") + _log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}") + _log("INFO", f"Database: {config.db_path}") + _log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}") + _log("INFO", f"Movie priority: {config.movie_priority}") + + uvicorn.run( + app, + host="0.0.0.0", + port=int(os.environ.get("PORT", "8080")), + reload=False + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..043f2eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +psycopg2-binary==2.9.7 +requests==2.31.0 +python-multipart==0.0.6 +aiofiles==23.2.1 +python-dotenv==1.0.0