refactor
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
#!/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
|
||||
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Placeholder logging function - replace with your actual logger"""
|
||||
print(f"[{datetime.now().isoformat()}] {level}: {msg}")
|
||||
|
||||
|
||||
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None) -> 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 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 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"""
|
||||
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") == 4 and release.get("release_date"): # Digital 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):
|
||||
self.tmdb = TMDBClient()
|
||||
self.omdb = OMDbClient()
|
||||
self.jellyseerr = JellyseerrClient()
|
||||
|
||||
def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]:
|
||||
"""Get digital release date candidates from all sources"""
|
||||
candidates = []
|
||||
|
||||
# TMDB digital release
|
||||
if self.tmdb.enabled:
|
||||
tmdb_date = self.tmdb.get_digital_release_date(imdb_id)
|
||||
if tmdb_date:
|
||||
candidates.append((tmdb_date, "tmdb:digital"))
|
||||
|
||||
# OMDb DVD/digital release
|
||||
if self.omdb.enabled:
|
||||
omdb_date = self.omdb.get_dvd_release_date(imdb_id)
|
||||
if omdb_date:
|
||||
candidates.append((omdb_date, "omdb:dvd"))
|
||||
|
||||
# Jellyseerr digital releases
|
||||
if self.jellyseerr.enabled and self.tmdb.enabled:
|
||||
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)
|
||||
for date in jellyseerr_dates:
|
||||
candidates.append((date, "jellyseerr:digital"))
|
||||
|
||||
return candidates
|
||||
|
||||
def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]:
|
||||
"""Get the earliest digital release date"""
|
||||
candidates = self.get_digital_release_candidates(imdb_id)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
try:
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda x: datetime.fromisoformat(x[0].replace("Z", "+00:00"))
|
||||
)[0]
|
||||
except Exception:
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
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,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Radarr API client with improved history event detection and path mapping
|
||||
"""
|
||||
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
|
||||
|
||||
# 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"
|
||||
path_mapper = DummyPathMapper()
|
||||
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Placeholder logging function - replace with your actual logger"""
|
||||
print(f"[{datetime.now().isoformat()}] {level}: {msg}")
|
||||
|
||||
|
||||
class RadarrClient:
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
# Known Radarr event types that indicate actual imports
|
||||
REAL_IMPORT_EVENTS = {
|
||||
# v4+ events
|
||||
"downloadfolderimported",
|
||||
"moviefileimported",
|
||||
"imported",
|
||||
"downloadimported",
|
||||
"fileimported",
|
||||
"movieimported",
|
||||
# v3 events
|
||||
"downloadedepisode",
|
||||
"downloadedmovie",
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
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 with enhanced validation"""
|
||||
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}")
|
||||
|
||||
# Method 1: Get all movies and filter by IMDb (most reliable)
|
||||
all_movies = self._get("/api/v3/movie")
|
||||
if isinstance(all_movies, list):
|
||||
_log("DEBUG", f"Got {len(all_movies)} total movies, filtering by IMDb ID")
|
||||
for movie in all_movies:
|
||||
movie_imdb = movie.get("imdbId", "").lower()
|
||||
if movie_imdb == imdb_id.lower():
|
||||
_log("INFO", f"Found exact match: {movie.get('title')} (ID: {movie.get('id')})")
|
||||
return movie
|
||||
|
||||
# Method 2: Direct API search with validation
|
||||
result = self._get("/api/v3/movie", {"imdbId": imdb_id})
|
||||
if isinstance(result, list) and result:
|
||||
for movie in result:
|
||||
movie_imdb = movie.get("imdbId", "").lower()
|
||||
if movie_imdb == imdb_id.lower():
|
||||
_log("INFO", f"Found via search: {movie.get('title')} (ID: {movie.get('id')})")
|
||||
return movie
|
||||
|
||||
# Method 3: Lookup endpoint
|
||||
lookup_result = self._get("/api/v3/movie/lookup/imdb", {"imdbId": imdb_id})
|
||||
if isinstance(lookup_result, dict) and lookup_result.get("imdbId", "").lower() == imdb_id.lower():
|
||||
_log("INFO", f"Found via lookup: {lookup_result.get('title')} (ID: {lookup_result.get('id')})")
|
||||
return lookup_result
|
||||
|
||||
_log("ERROR", f"No movie found for IMDb ID: {imdb_id}")
|
||||
return None
|
||||
|
||||
def _analyze_event_for_import(self, event: Dict[str, Any]) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Analyze a history event to determine if it's a real import.
|
||||
|
||||
Returns:
|
||||
(is_real_import, reason, date_iso)
|
||||
"""
|
||||
event_type = (event.get("eventType") or event.get("type") or "").lower()
|
||||
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
|
||||
|
||||
# Check if event type indicates import
|
||||
if event_type not in self.REAL_IMPORT_EVENTS:
|
||||
return False, f"event_type_not_import({event_type})", date_iso
|
||||
|
||||
# Extract source path information
|
||||
source_path = (
|
||||
event_data.get('droppedPath', '') or
|
||||
event_data.get('sourcePath', '') or
|
||||
event_data.get('path', '') or
|
||||
event_data.get('sourceTitle', '') or
|
||||
event.get('sourcePath', '') or
|
||||
event.get('sourceTitle', '')
|
||||
).lower()
|
||||
|
||||
if not source_path:
|
||||
return False, "no_source_path", date_iso
|
||||
|
||||
# Use path mapper for more sophisticated path analysis
|
||||
is_from_downloads, path_reason = path_mapper.analyze_import_source_path(source_path)
|
||||
|
||||
if not is_from_downloads:
|
||||
return False, f"not_from_downloads({path_reason})", date_iso
|
||||
|
||||
return True, f"real_import_from_downloads({path_reason})", 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}")
|
||||
|
||||
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
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": movie_id,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending" # Start from oldest
|
||||
})
|
||||
|
||||
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") or "").lower()
|
||||
|
||||
# Check for grab events (fallback)
|
||||
if event_type == "grabbed" and not first_grab:
|
||||
if event.get("date"):
|
||||
try:
|
||||
first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("DEBUG", f"Found grab event at {first_grab}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Analyze for real import
|
||||
is_real, reason, date_iso = self._analyze_event_for_import(event)
|
||||
|
||||
if is_real and date_iso:
|
||||
_log("INFO", f"✅ FOUND REAL IMPORT: {event_type} at {date_iso} - {reason}")
|
||||
earliest_real_import = date_iso
|
||||
# We found a real import - we can stop here since events are chronologically ordered
|
||||
break
|
||||
elif event_type in self.REAL_IMPORT_EVENTS:
|
||||
_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"""
|
||||
result = self._get("/api/v3/moviefile", {"movieId": movie_id})
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
|
||||
"""Get earliest file dateAdded as fallback"""
|
||||
files = self.movie_files(movie_id)
|
||||
if not files:
|
||||
return None
|
||||
|
||||
earliest = None
|
||||
for file_obj in files:
|
||||
date_added = file_obj.get("dateAdded")
|
||||
if not date_added:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_added.replace("Z", "+00:00"))
|
||||
if earliest is None or dt < earliest:
|
||||
earliest = dt
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if earliest:
|
||||
return earliest.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
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.
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Try history first
|
||||
import_date = self.earliest_import_event_optimized(movie_id)
|
||||
if import_date:
|
||||
return import_date, "radarr:history.import"
|
||||
|
||||
# Fallback to file dateAdded if requested
|
||||
if fallback_to_file_date:
|
||||
file_date = self.earliest_file_dateadded(movie_id)
|
||||
if file_date:
|
||||
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
||||
return file_date, "radarr:file.dateAdded"
|
||||
|
||||
return None, "radarr:no_date_found"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the client
|
||||
import os
|
||||
|
||||
base_url = os.environ.get("RADARR_URL", "")
|
||||
api_key = os.environ.get("RADARR_API_KEY", "")
|
||||
|
||||
if base_url and api_key:
|
||||
client = RadarrClient(base_url, api_key)
|
||||
|
||||
# Test with a known movie
|
||||
test_imdb = "tt1596343" # Example
|
||||
movie = client.movie_by_imdb(test_imdb)
|
||||
|
||||
if movie:
|
||||
movie_id = movie.get("id")
|
||||
print(f"Found movie: {movie.get('title')} (ID: {movie_id})")
|
||||
|
||||
import_date, source = client.get_movie_import_date(movie_id)
|
||||
print(f"Import date: {import_date} ({source})")
|
||||
else:
|
||||
print(f"Movie not found: {test_imdb}")
|
||||
else:
|
||||
print("Please set RADARR_URL and RADARR_API_KEY environment variables for testing")
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Sonarr API client extracted from media_date_cache.py
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Placeholder logging function - replace with your actual logger"""
|
||||
print(f"[{datetime.now().isoformat()}] {level}: {msg}")
|
||||
|
||||
|
||||
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