feat: NFOGuard v1.7.2 - Complete media management system
Local Docker Build (Dev) / build-dev (push) Failing after 0s
Local Docker Build (Main) / build (push) Failing after 0s
Local Docker Build (Main) / deploy (push) Has been skipped

Initial clean repository with full NFOGuard codebase:
- Docker-based media file processing
- Webhook integration for Radarr/Sonarr
- NFO file management and organization
- Emby/Jellyfin plugin integration
- TMDB API integration for metadata
- Smart episode and movie renaming
- Comprehensive dual-repository development workflow

Author: SBCrumb
This commit is contained in:
2025-09-23 16:12:58 -04:00
commit 7ad0dd8ca2
38 changed files with 7915 additions and 0 deletions
+597
View File
@@ -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")
+534
View File
@@ -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", [])
+646
View File
@@ -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")
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""
Enhanced Sonarr API client for TV show metadata and episode management
"""
import json
import time
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional
from urllib.parse import urlencode
from urllib.request import Request as UrlRequest, urlopen
from urllib.error import URLError, HTTPError
from core.logging import _log
class SonarrClient:
"""Enhanced Sonarr API client for TV series and episode management"""
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.retries = max(0, retries)
self.enabled = bool(self.base_url and self.api_key)
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
"""Make GET request to Sonarr API with retries"""
if not self.enabled:
return None
url = f"{self.base_url}/api/v3{path}"
if params:
url += "?" + urlencode(params)
headers = {"X-Api-Key": self.api_key}
for attempt in range(self.retries):
try:
_log("DEBUG", f"Sonarr API Request: {url}")
req = UrlRequest(url, headers=headers)
with urlopen(req, timeout=self.timeout) as resp:
data = resp.read().decode("utf-8")
result = json.loads(data) if data else None
return result
except HTTPError as e:
if e.code == 401:
_log("ERROR", "Sonarr authentication failed - check API key")
return None
elif e.code == 429:
wait_time = (attempt + 1) * 2
_log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})")
time.sleep(wait_time)
else:
_log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}")
except Exception as e:
_log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}")
if attempt < self.retries - 1:
time.sleep(0.5 * (attempt + 1))
_log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
return None
def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""Find series by IMDb ID using lookup endpoint"""
search_term = f"imdbid:{imdb_id}"
_log("DEBUG", f"Searching Sonarr with term: {search_term}")
result = self._get("/series/lookup", {"term": search_term})
if not result:
_log("WARNING", f"No results from Sonarr lookup for: {search_term}")
return None
_log("DEBUG", f"Sonarr lookup returned {len(result)} results")
# Log all results for debugging
for i, series in enumerate(result):
series_imdb = series.get("imdbId", "")
series_title = series.get("title", "")
series_id = series.get("id", "")
_log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
# Find exact IMDb match (case insensitive)
target_imdb = imdb_id.lower()
for series in result:
series_imdb = (series.get("imdbId") or "").lower()
if series_imdb == target_imdb:
_log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
return series
# Try partial match as fallback
for series in result:
series_imdb = (series.get("imdbId") or "").lower()
if target_imdb in series_imdb or series_imdb in target_imdb:
_log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
return series
_log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
return None
def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Search for series by title as fallback when IMDb lookup fails"""
_log("DEBUG", f"Searching Sonarr by title: {title}")
result = self._get("/series/lookup", {"term": title})
if not result:
_log("WARNING", f"No results from Sonarr title search for: {title}")
return None
_log("DEBUG", f"Sonarr title search returned {len(result)} results")
title_lower = title.lower()
# Look for exact title match
for series in result:
series_title = (series.get("title") or "").lower()
if series_title == title_lower:
_log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
return series
# Look for partial title match
for series in result:
series_title = (series.get("title") or "").lower()
if title_lower in series_title or series_title in title_lower:
_log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
return series
_log("WARNING", f"No title match found for: {title}")
return None
def get_all_series(self) -> List[Dict[str, Any]]:
"""Get all series from Sonarr"""
return self._get("/series") or []
def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""Find series by scanning all series for IMDb match (slower but more reliable)"""
_log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}")
all_series = self.get_all_series()
target_imdb = imdb_id.lower()
for series in all_series:
series_imdb = (series.get("imdbId") or "").lower()
if series_imdb == target_imdb:
_log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})")
return series
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
return None
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
"""Get all episodes for a series"""
return self._get("/episode", {"seriesId": series_id}) or []
def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
"""Get episode file details"""
return self._get(f"/episodefile/{episode_file_id}")
def get_episode_import_history(self, episode_id: int) -> Optional[str]:
"""
Get the original import date from history with enhanced detection.
Focuses on finding the earliest REAL import, not upgrades.
"""
all_records = []
page = 1
page_size = 100
# Collect all history records for this episode
while True:
history = self._get("/history", {
"episodeId": episode_id,
"sortKey": "date",
"sortDir": "asc",
"page": page,
"pageSize": page_size
})
if not history:
break
records = history.get("records", []) if isinstance(history, dict) else []
if not records:
break
all_records.extend(records)
if len(records) < page_size:
break
page += 1
if page > 10: # Safety valve
break
_log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
# Categorize events
import_events = []
grabbed_events = []
rename_events = []
for event in all_records:
event_type = event.get("eventType", "").lower()
date = event.get("date")
if not date:
continue
_log("DEBUG", f"History event: {event_type} at {date}")
if event_type == "downloadfolderimported":
import_events.append({"date": date, "event": event})
elif event_type == "grabbed":
grabbed_events.append({"date": date, "event": event})
elif event_type == "episodefilerenamed":
rename_events.append({"date": date, "event": event})
# Use the earliest real import event
if import_events:
earliest_import = min(import_events, key=lambda x: x["date"])
import_date = earliest_import["date"]
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
# Check if this looks like an upgrade by comparing to renames
if rename_events:
earliest_rename = min(rename_events, key=lambda x: x["date"])
rename_date = earliest_rename["date"]
try:
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
days_diff = (import_dt - rename_dt).days
# If import is significantly after rename, prefer rename date
if days_diff > 30:
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
return rename_date
except Exception as e:
_log("DEBUG", f"Error comparing dates: {e}")
return import_date
# Fallback to grab event
if grabbed_events:
earliest_grab = min(grabbed_events, key=lambda x: x["date"])
_log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}")
return earliest_grab["date"]
_log("WARNING", f"No reliable import events found for episode {episode_id}")
return None
if __name__ == "__main__":
# Test the client
import os
base_url = os.environ.get("SONARR_URL", "")
api_key = os.environ.get("SONARR_API_KEY", "")
if base_url and api_key:
client = SonarrClient(base_url, api_key)
# Test with a known series
test_imdb = "tt2085059" # Example
series = client.series_by_imdb(test_imdb)
if series:
series_id = series.get("id")
print(f"Found series: {series.get('title')} (ID: {series_id})")
# Get episodes
episodes = client.episodes_for_series(series_id)
print(f"Found {len(episodes)} episodes")
# Test import date for first episode
if episodes:
first_episode = episodes[0]
episode_id = first_episode.get("id")
import_date = client.get_episode_import_history(episode_id)
print(f"First episode import date: {import_date}")
else:
print(f"Series not found: {test_imdb}")
else:
print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing")