initial updates to mirror github files
This commit is contained in:
@@ -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")
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
# Import path mapper for proper path handling
|
||||
try:
|
||||
from core.path_mapper import path_mapper
|
||||
except ImportError:
|
||||
# Fallback for standalone testing
|
||||
class DummyPathMapper:
|
||||
def analyze_import_source_path(self, path):
|
||||
return "/downloads/" in path.lower(), "basic_check"
|
||||
|
||||
def is_download_path(self, path):
|
||||
return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower()
|
||||
|
||||
path_mapper = DummyPathMapper()
|
||||
|
||||
# Import database client for enhanced performance
|
||||
try:
|
||||
from clients.radarr_db_client import RadarrDbClient
|
||||
except ImportError:
|
||||
RadarrDbClient = None
|
||||
|
||||
|
||||
class RadarrClient:
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
# Radarr History API event types (HistoryEventType enum)
|
||||
# From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs
|
||||
EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer
|
||||
EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library
|
||||
EVENT_TYPE_FAILED = 4 # Download or import failed
|
||||
EVENT_TYPE_RETAGGED = 6 # Files were tagged
|
||||
EVENT_TYPE_RENAMED = 8 # Files were renamed
|
||||
|
||||
# Event types that indicate real imports
|
||||
REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events
|
||||
|
||||
# These are now handled by path_mapper, but keeping for backward compatibility
|
||||
DOWNLOAD_PATH_INDICATORS = [
|
||||
'/downloads/', '/download/', '/completed/', '/importing/',
|
||||
'/nzbs/', '/torrents/', '/temp/', '/tmp/',
|
||||
'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
|
||||
'usenet', 'torrent', 'radarr', 'completed', 'processing'
|
||||
]
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, retries)
|
||||
|
||||
# Initialize database client - REQUIRED for operation
|
||||
self.db_client = None
|
||||
if RadarrDbClient:
|
||||
try:
|
||||
self.db_client = RadarrDbClient.from_env()
|
||||
if self.db_client:
|
||||
_log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled")
|
||||
else:
|
||||
_log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}")
|
||||
self.db_client = None
|
||||
else:
|
||||
_log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies")
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||
"""Make GET request to Radarr API with retries"""
|
||||
if not self.api_key:
|
||||
return None
|
||||
|
||||
attempt = 0
|
||||
last_err = None
|
||||
|
||||
while attempt <= self.retries:
|
||||
try:
|
||||
params = params or {}
|
||||
params["apikey"] = self.api_key
|
||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
|
||||
if params:
|
||||
url = url + ("&" if "?" in url else "?") + urlencode(params)
|
||||
|
||||
_log("DEBUG", f"Radarr API Request: {url}")
|
||||
req = UrlRequest(url, headers={"Accept": "application/json"})
|
||||
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
result = json.loads(data)
|
||||
return result
|
||||
|
||||
except (URLError, HTTPError, json.JSONDecodeError) as e:
|
||||
last_err = e
|
||||
_log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}")
|
||||
time.sleep(min(2 ** attempt, 5)) # Exponential backoff
|
||||
attempt += 1
|
||||
|
||||
_log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}")
|
||||
return None
|
||||
|
||||
def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find movie by IMDb ID - DATABASE ONLY mode"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
_log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
|
||||
|
||||
# Database required - no API fallback
|
||||
if self.db_client:
|
||||
try:
|
||||
movie = self.db_client.get_movie_by_imdb(imdb_id)
|
||||
if movie:
|
||||
_log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})")
|
||||
return movie
|
||||
else:
|
||||
_log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database lookup failed: {e}")
|
||||
return None
|
||||
|
||||
# No database client available
|
||||
_log("ERROR", "Database client required for movie lookup - API mode disabled")
|
||||
return None
|
||||
|
||||
def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Analyze a history event to determine if it's a real import.
|
||||
|
||||
Args:
|
||||
event: The history event to analyze
|
||||
movie_info: Optional movie information to validate paths against
|
||||
|
||||
Returns:
|
||||
(is_real_import, reason, date_iso)
|
||||
"""
|
||||
event_type = event.get("eventType")
|
||||
date_str = event.get("date")
|
||||
event_data = event.get("data", {})
|
||||
|
||||
# Parse date
|
||||
date_iso = None
|
||||
if date_str:
|
||||
try:
|
||||
date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
date_iso = None
|
||||
|
||||
if not date_iso:
|
||||
return False, "no_valid_date", None
|
||||
|
||||
# Convert event type to int if needed
|
||||
try:
|
||||
event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type
|
||||
except (ValueError, TypeError):
|
||||
event_type_int = None
|
||||
|
||||
# Check if event type indicates import
|
||||
if event_type_int not in self.REAL_IMPORT_EVENT_TYPES:
|
||||
return False, f"event_type_not_import({event_type})", date_iso
|
||||
|
||||
# Get all possible source paths/titles
|
||||
source_items = []
|
||||
|
||||
# Get both sourcePath and importedPath if available
|
||||
if event_data:
|
||||
for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']:
|
||||
if event_data.get(key):
|
||||
source_items.append(event_data[key])
|
||||
|
||||
# Also check event root for these fields
|
||||
for key in ['sourcePath', 'sourceTitle', 'importedPath']:
|
||||
if event.get(key):
|
||||
source_items.append(event[key])
|
||||
|
||||
# Clean up and make unique
|
||||
source_items = [str(s).lower().strip() for s in source_items if s]
|
||||
source_items = list(set(source_items)) # Remove duplicates
|
||||
|
||||
if not source_items:
|
||||
return False, "no_source_paths", date_iso
|
||||
|
||||
# If we have movie info, look for title/year match
|
||||
if movie_info:
|
||||
movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.')
|
||||
movie_year = str(movie_info.get('year', ''))
|
||||
|
||||
for source in source_items:
|
||||
# Clean up source text for comparison
|
||||
source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.')
|
||||
|
||||
# Check if both title and year are in the source
|
||||
if movie_title and movie_year:
|
||||
if movie_title in source_clean and movie_year in source_clean:
|
||||
_log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}")
|
||||
return True, "matched_title_and_year", date_iso
|
||||
|
||||
# Also check for downloads path as secondary validation
|
||||
if path_mapper.is_download_path(source):
|
||||
_log("DEBUG", f"Source is from downloads: {source}")
|
||||
return True, "from_downloads_path", date_iso
|
||||
|
||||
_log("DEBUG", f"⚠️ No match found in sources: {source_items}")
|
||||
return False, "no_title_year_match", date_iso
|
||||
|
||||
# Fallback to basic path validation if no movie info
|
||||
for source in source_items:
|
||||
if path_mapper.is_download_path(source):
|
||||
return True, "basic_download_path_match", date_iso
|
||||
|
||||
return False, "no_download_path_match", date_iso
|
||||
|
||||
def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]:
|
||||
"""
|
||||
Find earliest real import event with optimized querying.
|
||||
Stops as soon as we find valid import events instead of loading everything.
|
||||
"""
|
||||
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||
|
||||
# Get movie info for path validation
|
||||
movie_info = self._get(f"/api/v3/movie/{movie_id}")
|
||||
if not movie_info or not isinstance(movie_info, dict):
|
||||
_log("ERROR", f"Could not get movie info for ID {movie_id}")
|
||||
return None
|
||||
|
||||
earliest_real_import = None
|
||||
first_grab = None
|
||||
page = 1
|
||||
page_size = 50 # Smaller pages for faster iteration
|
||||
total_processed = 0
|
||||
|
||||
while page <= 20: # Safety limit
|
||||
# Get history in chronological order
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": str(movie_id),
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending"
|
||||
})
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
items = data if isinstance(data, list) else data.get("records", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
_log("DEBUG", f"Page {page}: Processing {len(items)} events")
|
||||
|
||||
for event in items:
|
||||
total_processed += 1
|
||||
event_type = event.get("eventType")
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
# Convert event type to int or handle string types
|
||||
try:
|
||||
if isinstance(event_type, str):
|
||||
# Map string event types to numeric values
|
||||
string_to_numeric = {
|
||||
"grabbed": self.EVENT_TYPE_GRABBED,
|
||||
"downloadFolderImported": self.EVENT_TYPE_IMPORTED,
|
||||
"movieFileImported": self.EVENT_TYPE_IMPORTED,
|
||||
"downloadFailed": self.EVENT_TYPE_FAILED,
|
||||
"movieFileRenamed": self.EVENT_TYPE_RENAMED,
|
||||
"movieFileDeleted": 5 # Not in our constants but common
|
||||
}
|
||||
event_type = string_to_numeric.get(event_type, 0)
|
||||
else:
|
||||
event_type = int(event_type)
|
||||
except (ValueError, TypeError):
|
||||
_log("DEBUG", f"Unknown event type: {event_type}")
|
||||
continue
|
||||
|
||||
# Check for grab events (type 1) - but validate it's a real download
|
||||
if event_type == self.EVENT_TYPE_GRABBED and not first_grab:
|
||||
if event.get("date"):
|
||||
try:
|
||||
# Get event data to check if this is a real grab with download info
|
||||
event_data = event.get("data", {})
|
||||
if isinstance(event_data, str):
|
||||
try:
|
||||
event_data = json.loads(event_data)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
event_data = {}
|
||||
|
||||
# Check if this grab has actual download/indexer info
|
||||
source_title = event_data.get("sourceTitle", "")
|
||||
indexer = event_data.get("indexer", "")
|
||||
|
||||
# Only count grabs that have actual download metadata
|
||||
if source_title or indexer:
|
||||
first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}")
|
||||
else:
|
||||
_log("DEBUG", f"Skipping grab event without download info at {event.get('date')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only process import events (type 3)
|
||||
if event_type != self.EVENT_TYPE_IMPORTED:
|
||||
continue
|
||||
|
||||
# Get imported path from event data
|
||||
imported_path = None
|
||||
event_data = event.get("data", {})
|
||||
|
||||
# Handle both string and dict data
|
||||
if isinstance(event_data, str):
|
||||
try:
|
||||
event_data = json.loads(event_data)
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
_log("DEBUG", f"Failed to parse event data JSON: {e}")
|
||||
continue
|
||||
elif not isinstance(event_data, dict):
|
||||
continue
|
||||
|
||||
imported_path = event_data.get("importedPath", "")
|
||||
if not imported_path:
|
||||
continue
|
||||
|
||||
imported_path = imported_path.lower()
|
||||
|
||||
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||
movie_title = (movie_info.get("title", "") or "").lower()
|
||||
movie_year = str(movie_info.get("year", ""))
|
||||
|
||||
# First try IMDb ID match
|
||||
# First try IMDb ID match
|
||||
if movie_imdb and (
|
||||
f"[imdb-{movie_imdb}]" in imported_path or
|
||||
f"[{movie_imdb}]" in imported_path or
|
||||
movie_imdb in imported_path
|
||||
):
|
||||
_log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}")
|
||||
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Then try title/year match with fuzzy path cleaning
|
||||
if movie_title and movie_year:
|
||||
# Clean strings for comparison
|
||||
clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower()
|
||||
clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower()
|
||||
|
||||
# Look for both title and year in the path
|
||||
if clean_title in clean_path and movie_year in clean_path:
|
||||
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})")
|
||||
_log("INFO", f"✅ FOUND IMPORT at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Fallback to normal import analysis
|
||||
is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info)
|
||||
if is_real and date_iso:
|
||||
source_path = (event.get("data", {}).get("sourcePath", "") or
|
||||
event.get("data", {}).get("droppedPath", "") or
|
||||
event.get("sourcePath", "") or
|
||||
event.get("data", {}).get("importedPath", "") or
|
||||
event.get("importedPath", "") or "").lower()
|
||||
|
||||
if source_path:
|
||||
# Check for path match
|
||||
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||
if movie_imdb and (
|
||||
f"[imdb-{movie_imdb}]" in source_path or
|
||||
f"[{movie_imdb}]" in source_path or
|
||||
movie_imdb in source_path
|
||||
):
|
||||
_log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Check for title/year match
|
||||
movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".")
|
||||
movie_year = str(movie_info.get("year", ""))
|
||||
if movie_title and movie_year and movie_title in source_path and movie_year in source_path:
|
||||
_log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
elif event_type == 3:
|
||||
_log("DEBUG", f"⚠️ Skipped import event: {reason}")
|
||||
|
||||
# If we found a real import, no need to continue
|
||||
if earliest_real_import:
|
||||
break
|
||||
|
||||
# If we got less than page size, we've seen all events
|
||||
if len(items) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
_log("INFO", f"Processed {total_processed} events across {page-1} pages")
|
||||
|
||||
if earliest_real_import:
|
||||
_log("INFO", f"✅ Using earliest real import: {earliest_real_import}")
|
||||
return earliest_real_import
|
||||
|
||||
if first_grab:
|
||||
_log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}")
|
||||
return first_grab
|
||||
|
||||
_log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}")
|
||||
return None
|
||||
|
||||
def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get movie files for a movie - DATABASE ONLY mode"""
|
||||
if self.db_client:
|
||||
_log("INFO", "Using database for movie files lookup")
|
||||
# Database handles this internally in get_movie_file_date()
|
||||
return []
|
||||
|
||||
_log("ERROR", "Database client required for movie files - API mode disabled")
|
||||
return []
|
||||
|
||||
def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
|
||||
"""Get earliest file dateAdded - DATABASE ONLY mode"""
|
||||
if self.db_client:
|
||||
try:
|
||||
return self.db_client.get_movie_file_date(movie_id)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database file date query failed: {e}")
|
||||
return None
|
||||
|
||||
_log("ERROR", "Database client required for file dates - API mode disabled")
|
||||
return None
|
||||
|
||||
def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get the best import date for a movie - DATABASE ONLY mode.
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Database required - no API fallback
|
||||
if self.db_client:
|
||||
try:
|
||||
date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date)
|
||||
if date_iso:
|
||||
return date_iso, source
|
||||
else:
|
||||
_log("WARNING", f"No import date found in database for movie_id {movie_id}")
|
||||
return None, "radarr:db.no_date_found"
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database import date query failed: {e}")
|
||||
return None, "radarr:db.error"
|
||||
|
||||
# No database client available
|
||||
_log("ERROR", "Database client required for import date detection - API mode disabled")
|
||||
return None, "radarr:db.not_configured"
|
||||
|
||||
def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]:
|
||||
"""Get the earliest import date from Radarr history."""
|
||||
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||
|
||||
earliest_real_import = None
|
||||
earliest_grab_date = None
|
||||
page = 1
|
||||
page_size = 50
|
||||
total_events = 0
|
||||
|
||||
# Get full movie history
|
||||
while True:
|
||||
# Get page of history
|
||||
history_data = self._get_movie_history_page(movie_id, page, page_size)
|
||||
if not history_data:
|
||||
break
|
||||
|
||||
# Process events on this page
|
||||
for event in history_data:
|
||||
event_type = event.get("eventType")
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
# Parse event date
|
||||
event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
# Track earliest grab date as fallback (EventType 1)
|
||||
if event_type == 1 and not earliest_grab_date:
|
||||
earliest_grab_date = event_date
|
||||
_log("DEBUG", f"Found first grab event at {earliest_grab_date}")
|
||||
continue
|
||||
|
||||
# Look for import events (EventType 3)
|
||||
if event_type == 3:
|
||||
try:
|
||||
data = json.loads(event.get("data", "{}"))
|
||||
if data.get("importedPath"):
|
||||
_log("INFO", f"✅ FOUND IMPORT at {event_date}")
|
||||
earliest_real_import = event_date
|
||||
break
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Break if we found an import
|
||||
if earliest_real_import:
|
||||
break
|
||||
|
||||
total_events += len(history_data)
|
||||
page += 1
|
||||
|
||||
_log("INFO", f"Processed {total_events} events across {page}")
|
||||
|
||||
if earliest_real_import:
|
||||
return earliest_real_import
|
||||
if earliest_grab_date:
|
||||
_log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}")
|
||||
return earliest_grab_date
|
||||
return None
|
||||
|
||||
def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]:
|
||||
"""Get a page of movie history."""
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": str(movie_id),
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending"
|
||||
})
|
||||
return data if isinstance(data, list) else data.get("records", [])
|
||||
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct Radarr Database Client for NFOGuard
|
||||
Provides high-performance access to Radarr's SQLite/PostgreSQL database
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class RadarrDbClient:
|
||||
"""Direct database client for Radarr's SQLite or PostgreSQL database"""
|
||||
|
||||
def __init__(self,
|
||||
db_type: str = "sqlite",
|
||||
db_path: Optional[str] = None,
|
||||
db_host: Optional[str] = None,
|
||||
db_port: Optional[int] = None,
|
||||
db_name: Optional[str] = None,
|
||||
db_user: Optional[str] = None,
|
||||
db_password: Optional[str] = None):
|
||||
"""
|
||||
Initialize Radarr database client
|
||||
|
||||
Args:
|
||||
db_type: "sqlite" or "postgresql"
|
||||
db_path: Path to SQLite database file
|
||||
db_host: PostgreSQL host
|
||||
db_port: PostgreSQL port
|
||||
db_name: PostgreSQL database name
|
||||
db_user: PostgreSQL username
|
||||
db_password: PostgreSQL password
|
||||
"""
|
||||
self.db_type = db_type.lower()
|
||||
self.db_path = db_path
|
||||
self.db_host = db_host
|
||||
self.db_port = db_port or 5432
|
||||
self.db_name = db_name
|
||||
self.db_user = db_user
|
||||
self.db_password = db_password
|
||||
|
||||
self._test_connection()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Optional['RadarrDbClient']:
|
||||
"""Create client from environment variables"""
|
||||
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||
|
||||
if not db_type:
|
||||
return None
|
||||
|
||||
if db_type == "sqlite":
|
||||
db_path = os.environ.get("RADARR_DB_PATH")
|
||||
if not db_path or not Path(db_path).exists():
|
||||
_log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}")
|
||||
return None
|
||||
return cls(db_type="sqlite", db_path=db_path)
|
||||
|
||||
elif db_type == "postgresql":
|
||||
# Support both individual vars and connection string
|
||||
db_url = os.environ.get("RADARR_DB_URL")
|
||||
if db_url:
|
||||
parsed = urlparse(db_url)
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=parsed.hostname,
|
||||
db_port=parsed.port or 5432,
|
||||
db_name=parsed.path.lstrip('/'),
|
||||
db_user=parsed.username,
|
||||
db_password=parsed.password
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=os.environ.get("RADARR_DB_HOST"),
|
||||
db_port=int(os.environ.get("RADARR_DB_PORT", "5432")),
|
||||
db_name=os.environ.get("RADARR_DB_NAME"),
|
||||
db_user=os.environ.get("RADARR_DB_USER"),
|
||||
db_password=os.environ.get("RADARR_DB_PASSWORD")
|
||||
)
|
||||
else:
|
||||
_log("ERROR", f"Unsupported database type: {db_type}")
|
||||
return None
|
||||
|
||||
def _test_connection(self) -> None:
|
||||
"""Test database connection on initialization"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
if conn:
|
||||
conn.close()
|
||||
_log("INFO", f"Connected to Radarr {self.db_type} database successfully")
|
||||
else:
|
||||
raise Exception("Failed to create connection")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to connect to Radarr database: {e}")
|
||||
raise
|
||||
|
||||
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
|
||||
"""Get database connection"""
|
||||
if self.db_type == "sqlite":
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
elif self.db_type == "postgresql":
|
||||
conn = psycopg2.connect(
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
user=self.db_user,
|
||||
password=self.db_password
|
||||
)
|
||||
return conn
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {self.db_type}")
|
||||
|
||||
def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find movie by IMDb ID using database query
|
||||
|
||||
Returns:
|
||||
Dictionary with movie info including id, imdbId, title, year, path
|
||||
"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m."Id" as id,
|
||||
m."Path" as path,
|
||||
m."Added" as added,
|
||||
mm."ImdbId" as imdb_id,
|
||||
mm."Title" as title,
|
||||
mm."Year" as year,
|
||||
mm."DigitalRelease" as digital_release
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (imdb_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row) if self.db_type == "sqlite" else row
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date from History table, accounting for upgrade scenarios
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# If first event is rename, all subsequent imports are upgrades - skip them
|
||||
if self.is_first_event_rename_based(movie_id):
|
||||
_log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping")
|
||||
return None, "radarr:db.upgrade_imports_skipped"
|
||||
|
||||
# Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import)
|
||||
import_query = """
|
||||
SELECT
|
||||
h."Date" as event_date,
|
||||
h."Data" as event_data,
|
||||
h."EventType" as event_type
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
AND h."EventType" = 3
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
# Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab)
|
||||
grab_query = """
|
||||
SELECT
|
||||
h."Date" as event_date,
|
||||
h."Data" as event_data,
|
||||
h."EventType" as event_type
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
AND h."EventType" = 1
|
||||
AND h."Data" IS NOT NULL
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
import_query = import_query.replace("%s", "?")
|
||||
grab_query = grab_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try import events first
|
||||
cursor.execute(import_query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||
return date_iso, "radarr:db.history.import"
|
||||
|
||||
# Fallback to grab events
|
||||
cursor.execute(grab_query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||
return date_iso, "radarr:db.history.grab"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for movie {movie_id}: {e}")
|
||||
|
||||
return None, "radarr:db.no_date_found"
|
||||
|
||||
def is_first_event_rename_based(self, movie_id: int) -> bool:
|
||||
"""
|
||||
Check if the first event in history is rename-based (not a true import)
|
||||
|
||||
This helps identify movies where:
|
||||
- First event: movieFileRenamed (EventType = 8)
|
||||
- Followed by: downloadFolderImported (EventType = 3) - this is an upgrade
|
||||
|
||||
In such cases, we should prefer release dates over the upgrade date
|
||||
"""
|
||||
query = """
|
||||
SELECT h."EventType" as event_type, h."Date" as event_date
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 5
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (movie_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
_log("INFO", f"Movie {movie_id} history debug - first 5 events:")
|
||||
for i, row in enumerate(rows):
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[0]
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[1]
|
||||
_log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}")
|
||||
|
||||
first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0]
|
||||
# EventType 8 = movieFileRenamed
|
||||
# Also check for EventType 7 = movieFileRenamed in some Radarr versions
|
||||
is_rename_first = first_event_type in [7, 8]
|
||||
_log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}")
|
||||
|
||||
if is_rename_first:
|
||||
_log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates")
|
||||
|
||||
return is_rename_first
|
||||
else:
|
||||
_log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error checking first event type for movie {movie_id}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def get_movie_file_date(self, movie_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get earliest file dateAdded as fallback
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
|
||||
Returns:
|
||||
ISO date string or None
|
||||
"""
|
||||
query = """
|
||||
SELECT MIN(mf."DateAdded") as earliest_date
|
||||
FROM "MovieFiles" mf
|
||||
WHERE mf."MovieId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0]
|
||||
if date_value:
|
||||
if isinstance(date_value, str):
|
||||
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = date_value.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for movie file date {movie_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get the best import date for a movie using optimized database queries
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
fallback_to_file_date: Whether to fall back to file dateAdded
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Try history first - this handles upgrade detection internally
|
||||
date_iso, source = self.get_earliest_import_date(movie_id)
|
||||
if date_iso:
|
||||
return date_iso, source
|
||||
|
||||
# Check if we skipped upgrades and should prefer release dates
|
||||
if source == "radarr:db.upgrade_imports_skipped":
|
||||
_log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates")
|
||||
return None, "radarr:db.prefer_release_dates"
|
||||
|
||||
# Fallback to file date if requested
|
||||
if fallback_to_file_date:
|
||||
file_date = self.get_movie_file_date(movie_id)
|
||||
if file_date:
|
||||
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
||||
return file_date, "radarr:db.file.dateAdded"
|
||||
|
||||
return None, "radarr:db.no_date_found"
|
||||
|
||||
def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]:
|
||||
"""
|
||||
Get import dates for multiple movies in a single query
|
||||
|
||||
Args:
|
||||
imdb_ids: List of IMDb IDs
|
||||
|
||||
Returns:
|
||||
Dictionary mapping imdb_id -> (date_iso, source)
|
||||
"""
|
||||
if not imdb_ids:
|
||||
return {}
|
||||
|
||||
# Ensure all IMDb IDs have tt prefix
|
||||
clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids]
|
||||
|
||||
placeholders = ",".join(["%s"] * len(clean_imdb_ids))
|
||||
if self.db_type == "sqlite":
|
||||
placeholders = ",".join(["?"] * len(clean_imdb_ids))
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
mm."ImdbId" as imdb_id,
|
||||
m."Id" as movie_id,
|
||||
MIN(h."Date") as earliest_import
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3
|
||||
WHERE mm."ImdbId" IN ({placeholders})
|
||||
GROUP BY mm."ImdbId", m."Id"
|
||||
"""
|
||||
|
||||
results = {}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, clean_imdb_ids)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
if self.db_type == "postgresql":
|
||||
imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import']
|
||||
else:
|
||||
imdb_id, movie_id, earliest_import = row[0], row[1], row[2]
|
||||
|
||||
if earliest_import:
|
||||
if isinstance(earliest_import, str):
|
||||
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = earliest_import.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
results[imdb_id] = (date_iso, "radarr:db.bulk.import")
|
||||
else:
|
||||
results[imdb_id] = (None, "radarr:db.bulk.no_import")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Bulk query error: {e}")
|
||||
# Return empty results for failed queries
|
||||
for imdb_id in clean_imdb_ids:
|
||||
if imdb_id not in results:
|
||||
results[imdb_id] = (None, "radarr:db.bulk.error")
|
||||
|
||||
return results
|
||||
|
||||
def get_database_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic statistics about the Radarr database"""
|
||||
stats = {}
|
||||
|
||||
queries = {
|
||||
"total_movies": 'SELECT COUNT(*) FROM "Movies"',
|
||||
"total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"',
|
||||
"total_history_events": 'SELECT COUNT(*) FROM "History"',
|
||||
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
|
||||
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for stat_name, query in queries.items():
|
||||
cursor.execute(query)
|
||||
result = cursor.fetchone()
|
||||
stats[stat_name] = result[0] if result else 0
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Stats query error: {e}")
|
||||
stats["error"] = str(e)
|
||||
|
||||
return stats
|
||||
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Comprehensive health check for the Radarr database connection
|
||||
|
||||
Returns:
|
||||
Dictionary with health status, connection info, and basic functionality tests
|
||||
"""
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"database_type": self.db_type,
|
||||
"connection": "ok",
|
||||
"readable": False,
|
||||
"writable": False,
|
||||
"tables_exist": False,
|
||||
"sample_data": False,
|
||||
"issues": [],
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
}
|
||||
|
||||
try:
|
||||
# Test 1: Basic connection
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test 2: Check if we can read (basic query)
|
||||
try:
|
||||
cursor.execute('SELECT 1')
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
health["readable"] = True
|
||||
health["connection"] = "readable"
|
||||
else:
|
||||
health["issues"].append("Basic SELECT query failed")
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Read test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 3: Check required tables exist
|
||||
required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"]
|
||||
existing_tables = []
|
||||
|
||||
try:
|
||||
if self.db_type == "postgresql":
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||
""")
|
||||
else: # SQLite
|
||||
cursor.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
existing_tables = [row[0] for row in rows]
|
||||
|
||||
if len(existing_tables) == len(required_tables):
|
||||
health["tables_exist"] = True
|
||||
else:
|
||||
missing = set(required_tables) - set(existing_tables)
|
||||
health["issues"].append(f"Missing tables: {list(missing)}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
health["existing_tables"] = existing_tables
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Table check failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 4: Check for sample data
|
||||
if health["tables_exist"]:
|
||||
try:
|
||||
cursor.execute('SELECT COUNT(*) FROM "Movies"')
|
||||
movie_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "History"')
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
if movie_count > 0 and history_count > 0:
|
||||
health["sample_data"] = True
|
||||
health["movie_count"] = movie_count
|
||||
health["history_count"] = history_count
|
||||
else:
|
||||
health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Sample data check failed: {e}")
|
||||
|
||||
# Test 5: Test a real query (movie with IMDb lookup)
|
||||
if health["sample_data"]:
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" IS NOT NULL
|
||||
""")
|
||||
imdb_movies = cursor.fetchone()[0]
|
||||
health["movies_with_imdb"] = imdb_movies
|
||||
|
||||
if imdb_movies > 0:
|
||||
health["functional"] = True
|
||||
else:
|
||||
health["issues"].append("No movies with IMDb IDs found")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Functional test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
except Exception as e:
|
||||
health["status"] = "error"
|
||||
health["connection"] = "failed"
|
||||
health["issues"].append(f"Connection failed: {e}")
|
||||
_log("ERROR", f"Database health check failed: {e}")
|
||||
|
||||
# Overall status determination
|
||||
if health["issues"]:
|
||||
if health["status"] == "healthy":
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Add connection details (safe info only)
|
||||
health["connection_info"] = {
|
||||
"type": self.db_type,
|
||||
"host": self.db_host if self.db_type == "postgresql" else None,
|
||||
"port": self.db_port if self.db_type == "postgresql" else None,
|
||||
"database": self.db_name if self.db_type == "postgresql" else None,
|
||||
"path": self.db_path if self.db_type == "sqlite" else None
|
||||
}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the database client
|
||||
print("Testing RadarrDbClient...")
|
||||
|
||||
# Test with environment variables
|
||||
client = RadarrDbClient.from_env()
|
||||
if client:
|
||||
print("✅ Connected to Radarr database")
|
||||
|
||||
# Test stats
|
||||
stats = client.get_database_stats()
|
||||
print(f"Database stats: {stats}")
|
||||
|
||||
# Test movie lookup
|
||||
test_movie = client.get_movie_by_imdb("tt1596343")
|
||||
if test_movie:
|
||||
print(f"Found test movie: {test_movie}")
|
||||
|
||||
# Test import date
|
||||
movie_id = test_movie['id']
|
||||
date_iso, source = client.get_movie_import_date_optimized(movie_id)
|
||||
print(f"Import date: {date_iso} (source: {source})")
|
||||
else:
|
||||
print("Test movie not found")
|
||||
else:
|
||||
print("❌ Could not connect to database - check environment variables")
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Sonarr API client for TV show metadata and episode management
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class SonarrClient:
|
||||
"""Enhanced Sonarr API client for TV series and episode management"""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, retries)
|
||||
self.enabled = bool(self.base_url and self.api_key)
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||
"""Make GET request to Sonarr API with retries"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/v3{path}"
|
||||
if params:
|
||||
url += "?" + urlencode(params)
|
||||
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
|
||||
for attempt in range(self.retries):
|
||||
try:
|
||||
_log("DEBUG", f"Sonarr API Request: {url}")
|
||||
req = UrlRequest(url, headers=headers)
|
||||
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
result = json.loads(data) if data else None
|
||||
return result
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 401:
|
||||
_log("ERROR", "Sonarr authentication failed - check API key")
|
||||
return None
|
||||
elif e.code == 429:
|
||||
wait_time = (attempt + 1) * 2
|
||||
_log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
_log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}")
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}")
|
||||
|
||||
if attempt < self.retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
_log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
|
||||
return None
|
||||
|
||||
def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find series by IMDb ID using lookup endpoint"""
|
||||
search_term = f"imdbid:{imdb_id}"
|
||||
_log("DEBUG", f"Searching Sonarr with term: {search_term}")
|
||||
|
||||
result = self._get("/series/lookup", {"term": search_term})
|
||||
if not result:
|
||||
_log("WARNING", f"No results from Sonarr lookup for: {search_term}")
|
||||
return None
|
||||
|
||||
_log("DEBUG", f"Sonarr lookup returned {len(result)} results")
|
||||
|
||||
# Log all results for debugging
|
||||
for i, series in enumerate(result):
|
||||
series_imdb = series.get("imdbId", "")
|
||||
series_title = series.get("title", "")
|
||||
series_id = series.get("id", "")
|
||||
_log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
|
||||
|
||||
# Find exact IMDb match (case insensitive)
|
||||
target_imdb = imdb_id.lower()
|
||||
for series in result:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if series_imdb == target_imdb:
|
||||
_log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
# Try partial match as fallback
|
||||
for series in result:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if target_imdb in series_imdb or series_imdb in target_imdb:
|
||||
_log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
|
||||
return None
|
||||
|
||||
def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Search for series by title as fallback when IMDb lookup fails"""
|
||||
_log("DEBUG", f"Searching Sonarr by title: {title}")
|
||||
|
||||
result = self._get("/series/lookup", {"term": title})
|
||||
if not result:
|
||||
_log("WARNING", f"No results from Sonarr title search for: {title}")
|
||||
return None
|
||||
|
||||
_log("DEBUG", f"Sonarr title search returned {len(result)} results")
|
||||
|
||||
title_lower = title.lower()
|
||||
|
||||
# Look for exact title match
|
||||
for series in result:
|
||||
series_title = (series.get("title") or "").lower()
|
||||
if series_title == title_lower:
|
||||
_log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
# Look for partial title match
|
||||
for series in result:
|
||||
series_title = (series.get("title") or "").lower()
|
||||
if title_lower in series_title or series_title in title_lower:
|
||||
_log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No title match found for: {title}")
|
||||
return None
|
||||
|
||||
def get_all_series(self) -> List[Dict[str, Any]]:
|
||||
"""Get all series from Sonarr"""
|
||||
return self._get("/series") or []
|
||||
|
||||
def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find series by scanning all series for IMDb match (slower but more reliable)"""
|
||||
_log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}")
|
||||
all_series = self.get_all_series()
|
||||
|
||||
target_imdb = imdb_id.lower()
|
||||
for series in all_series:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if series_imdb == target_imdb:
|
||||
_log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||
return None
|
||||
|
||||
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
return self._get("/episode", {"seriesId": series_id}) or []
|
||||
|
||||
def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get episode file details"""
|
||||
return self._get(f"/episodefile/{episode_file_id}")
|
||||
|
||||
def get_episode_import_history(self, episode_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get the original import date from history with enhanced detection.
|
||||
Focuses on finding the earliest REAL import, not upgrades.
|
||||
"""
|
||||
all_records = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
|
||||
# Collect all history records for this episode
|
||||
while True:
|
||||
history = self._get("/history", {
|
||||
"episodeId": episode_id,
|
||||
"sortKey": "date",
|
||||
"sortDir": "asc",
|
||||
"page": page,
|
||||
"pageSize": page_size
|
||||
})
|
||||
|
||||
if not history:
|
||||
break
|
||||
|
||||
records = history.get("records", []) if isinstance(history, dict) else []
|
||||
if not records:
|
||||
break
|
||||
|
||||
all_records.extend(records)
|
||||
|
||||
if len(records) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
if page > 10: # Safety valve
|
||||
break
|
||||
|
||||
_log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
|
||||
|
||||
# Categorize events
|
||||
import_events = []
|
||||
grabbed_events = []
|
||||
rename_events = []
|
||||
|
||||
for event in all_records:
|
||||
event_type = event.get("eventType", "").lower()
|
||||
date = event.get("date")
|
||||
|
||||
if not date:
|
||||
continue
|
||||
|
||||
_log("DEBUG", f"History event: {event_type} at {date}")
|
||||
|
||||
if event_type == "downloadfolderimported":
|
||||
import_events.append({"date": date, "event": event})
|
||||
elif event_type == "grabbed":
|
||||
grabbed_events.append({"date": date, "event": event})
|
||||
elif event_type == "episodefilerenamed":
|
||||
rename_events.append({"date": date, "event": event})
|
||||
|
||||
# Use the earliest real import event
|
||||
if import_events:
|
||||
earliest_import = min(import_events, key=lambda x: x["date"])
|
||||
import_date = earliest_import["date"]
|
||||
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
|
||||
|
||||
# Check if this looks like an upgrade by comparing to renames
|
||||
if rename_events:
|
||||
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||
rename_date = earliest_rename["date"]
|
||||
|
||||
try:
|
||||
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
|
||||
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
|
||||
days_diff = (import_dt - rename_dt).days
|
||||
|
||||
# If import is significantly after rename, prefer rename date
|
||||
if days_diff > 30:
|
||||
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
|
||||
return rename_date
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Error comparing dates: {e}")
|
||||
|
||||
return import_date
|
||||
|
||||
# Fallback to grab event
|
||||
if grabbed_events:
|
||||
earliest_grab = min(grabbed_events, key=lambda x: x["date"])
|
||||
_log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}")
|
||||
return earliest_grab["date"]
|
||||
|
||||
_log("WARNING", f"No reliable import events found for episode {episode_id}")
|
||||
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")
|
||||
Reference in New Issue
Block a user