"""Sync service for *arr instances""" import httpx import logging from typing import Dict, List, Optional from app.core.config import settings from app.core.database import pool import json logger = logging.getLogger(__name__) async def fetch_radarr_movies() -> List[Dict]: """Fetch all movies from Radarr""" if not settings.radarr_api_key: logger.warning("Radarr API key not configured") return [] async with httpx.AsyncClient() as client: try: response = await client.get( f"{settings.radarr_url}/api/v3/movie", headers={"X-Api-Key": settings.radarr_api_key}, timeout=30.0 ) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Failed to fetch Radarr movies: {e}") return [] async def fetch_sonarr_series() -> List[Dict]: """Fetch all series from Sonarr""" if not settings.sonarr_api_key: logger.warning("Sonarr API key not configured") return [] async with httpx.AsyncClient() as client: try: response = await client.get( f"{settings.sonarr_url}/api/v3/series", headers={"X-Api-Key": settings.sonarr_api_key}, timeout=30.0 ) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Failed to fetch Sonarr series: {e}") return [] async def fetch_lidarr_artists() -> List[Dict]: """Fetch all artists from Lidarr""" if not settings.lidarr_api_key: logger.warning("Lidarr API key not configured") return [] async with httpx.AsyncClient() as client: try: response = await client.get( f"{settings.lidarr_url}/api/v1/artist", headers={"X-Api-Key": settings.lidarr_api_key}, timeout=30.0 ) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Failed to fetch Lidarr artists: {e}") return [] def extract_country_from_radarr(movie: Dict) -> Optional[str]: """Extract country code from Radarr movie metadata""" # Try productionCountries first if "productionCountries" in movie and movie["productionCountries"]: countries = movie["productionCountries"] if isinstance(countries, list) and len(countries) > 0: country = countries[0] if isinstance(country, dict) and "iso_3166_1" in country: return country["iso_3166_1"].upper() elif isinstance(country, str): # Try to map country name to code (simplified) return None # Would need a mapping table # Try to get from TMDB metadata if available if "tmdbId" in movie and movie.get("movieMetadata", {}).get("productionCountries"): countries = movie["movieMetadata"]["productionCountries"] if isinstance(countries, list) and len(countries) > 0: country = countries[0] if isinstance(country, dict) and "iso_3166_1" in country: return country["iso_3166_1"].upper() return None def extract_country_from_sonarr(series: Dict) -> Optional[str]: """Extract country code from Sonarr series metadata""" # Sonarr doesn't always have country info directly # Check network origin or other metadata if "network" in series and series["network"]: # Network name might hint at country, but not reliable pass # Check if there's any country metadata if "seriesMetadata" in series: metadata = series["seriesMetadata"] if "originCountry" in metadata and metadata["originCountry"]: # originCountry might be a list or string origin = metadata["originCountry"] if isinstance(origin, list) and len(origin) > 0: return origin[0].upper() if len(origin[0]) == 2 else None elif isinstance(origin, str) and len(origin) == 2: return origin.upper() return None def extract_country_from_lidarr(artist: Dict) -> Optional[str]: """Extract country code from Lidarr artist metadata""" # Lidarr has a country field if "country" in artist and artist["country"]: country = artist["country"] if isinstance(country, str) and len(country) == 2: return country.upper() # Might be a country name, would need mapping return None async def upsert_media_item(source_kind: str, source_item_id: int, title: str, year: Optional[int], media_type: str, arr_raw: Dict): """Upsert a media item into the database""" # Pool should be initialized on startup if not pool: from app.core.database import init_db await init_db() async with pool.connection() as conn: async with conn.cursor() as cur: # Upsert media item query = """ INSERT INTO moviemap.media_item (source_kind, source_item_id, title, year, media_type, arr_raw) VALUES (%s, %s, %s, %s, %s, %s::jsonb) ON CONFLICT (source_kind, source_item_id) DO UPDATE SET title = EXCLUDED.title, year = EXCLUDED.year, arr_raw = EXCLUDED.arr_raw RETURNING id """ await cur.execute( query, (source_kind, source_item_id, title, year, media_type, json.dumps(arr_raw)) ) result = await cur.fetchone() media_item_id = result[0] # Extract and upsert country country_code = None if source_kind == "radarr": country_code = extract_country_from_radarr(arr_raw) elif source_kind == "sonarr": country_code = extract_country_from_sonarr(arr_raw) elif source_kind == "lidarr": country_code = extract_country_from_lidarr(arr_raw) # Delete existing country associations await cur.execute( "DELETE FROM moviemap.media_country WHERE media_item_id = %s", (media_item_id,) ) # Insert new country association if found if country_code: await cur.execute( "INSERT INTO moviemap.media_country (media_item_id, country_code) VALUES (%s, %s)", (media_item_id, country_code) ) await conn.commit() return media_item_id async def sync_radarr(): """Sync movies from Radarr""" movies = await fetch_radarr_movies() synced = 0 for movie in movies: try: await upsert_media_item( source_kind="radarr", source_item_id=movie.get("id"), title=movie.get("title", "Unknown"), year=movie.get("year"), media_type="movie", arr_raw=movie ) synced += 1 except Exception as e: logger.error(f"Failed to sync movie {movie.get('id')}: {e}") return {"radarr": synced} async def sync_sonarr(): """Sync series from Sonarr""" series = await fetch_sonarr_series() synced = 0 for s in series: try: await upsert_media_item( source_kind="sonarr", source_item_id=s.get("id"), title=s.get("title", "Unknown"), year=s.get("year"), media_type="show", arr_raw=s ) synced += 1 except Exception as e: logger.error(f"Failed to sync series {s.get('id')}: {e}") return {"sonarr": synced} async def sync_lidarr(): """Sync artists from Lidarr""" artists = await fetch_lidarr_artists() synced = 0 for artist in artists: try: await upsert_media_item( source_kind="lidarr", source_item_id=artist.get("id"), title=artist.get("artistName", "Unknown"), year=None, # Artists don't have a year media_type="music", arr_raw=artist ) synced += 1 except Exception as e: logger.error(f"Failed to sync artist {artist.get('id')}: {e}") return {"lidarr": synced} async def sync_all_arrs() -> Dict: """Sync from all *arr instances""" logger.info("Starting sync from all *arr instances") results = {} # Sync each service try: results.update(await sync_radarr()) except Exception as e: logger.error(f"Radarr sync failed: {e}") results["radarr"] = 0 try: results.update(await sync_sonarr()) except Exception as e: logger.error(f"Sonarr sync failed: {e}") results["sonarr"] = 0 try: results.update(await sync_lidarr()) except Exception as e: logger.error(f"Lidarr sync failed: {e}") results["lidarr"] = 0 # Update last sync time (pool should be initialized) if not pool: from app.core.database import init_db await init_db() async with pool.connection() as conn: async with conn.cursor() as cur: for source_kind in ["radarr", "sonarr", "lidarr"]: await cur.execute( """ INSERT INTO moviemap.source (kind, base_url, enabled, last_sync_at) VALUES (%s, %s, %s, NOW()) ON CONFLICT (kind) DO UPDATE SET last_sync_at = NOW() """, (source_kind, getattr(settings, f"{source_kind}_url"), True) ) await conn.commit() logger.info(f"Sync completed: {results}") return results