"""Explicit original-language requests without changing shared quality defaults.""" import asyncio import copy import hashlib import json import re import httpx from fastapi import HTTPException _profile_lock = asyncio.Lock() _prefix = "Magent Original " def language_info(details): code = str(details.get("originalLanguage") or details.get("original_language") or "").lower() if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}: return None return {"code": code} def profile_body(profile): return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}} def profile_name(body): return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16] def is_original_profile(profile): return ((profile.get("language") or {}).get("id") == -2 and profile.get("name") == profile_name(profile_body(profile))) async def original_profile(client, default_id): # Reuse immutable copies; never edit a profile already used by other titles. async with _profile_lock: try: profiles = await client.get_quality_profiles() except httpx.HTTPError as exc: raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc if not isinstance(profiles, list): raise HTTPException(502, "Radarr returned invalid quality profiles.") default = next((p for p in profiles if p.get("id") == default_id), None) if not default: raise HTTPException(409, "The default quality profile changed. Reload the request.") body = profile_body(default) body["language"] = {"id": -2, "name": "Original"} name = profile_name(body) match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None) if match: return match["id"] try: result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name}) except httpx.HTTPError as exc: raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc if not isinstance(result, dict) or not isinstance(result.get("id"), int): raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") return result["id"] async def apply_original_to_movie(client, tmdb_id): movies = await client.get_movie_by_tmdb_id(tmdb_id) if not isinstance(movies, list): raise HTTPException(502, "Radarr did not return the movie list.") matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id] if not matches: return None if len(matches) != 1: raise HTTPException(409, "Radarr returned multiple movies for this identity.") movie = matches[0] profile_id = await original_profile(client, movie['qualityProfileId']) if movie['qualityProfileId'] != profile_id: movie['qualityProfileId'] = profile_id await client.update_movie(movie) verified = await client.get_movie(movie['id']) if not verified or verified.get('qualityProfileId') != profile_id: raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.") return profile_id async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2): command_id = command.get('id') if isinstance(command, dict) else None if not isinstance(command_id, int): return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'} for attempt in range(attempts): state = await client.get(f'/api/v3/command/{command_id}') status = str((state or {}).get('status', '')).lower() queue = await client.get_queue(movie_id) records = queue.get('records', []) if isinstance(queue, dict) else queue or [] matching = [item for item in records if item.get('movieId') == movie_id] if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching): return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'} if matching: return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'} if status in {'failed', 'aborted', 'cancelled'}: return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'} if status == 'completed': movie = await client.get_movie(movie_id) if (movie or {}).get('hasFile'): return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'} return {'status': 'attention', 'message': 'Search finished, but no download appeared in Radarr. Use Search and choose a download to see matching releases and rejection reasons. For a foreign-language title, review the audio choice above.'} if attempt + 1 < attempts: await asyncio.sleep(delay) return {'status': 'searching', 'message': 'Radarr is still searching. No download is confirmed yet; the pipeline will keep checking. You can close this window.'} async def series_search_outcome(client, series_id, commands, attempts=12, delay=2): ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)] if not ids: return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'} for attempt in range(attempts): states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids)) queue = await client.get_queue(series_id) records = queue.get('records', []) if isinstance(queue, dict) else queue or [] matching = [item for item in records if item.get('seriesId') == series_id] if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching): return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'} if matching: return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'} statuses = {str((state or {}).get('status', '')).lower() for state in states} if statuses & {'failed', 'aborted', 'cancelled'}: return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'} if statuses == {'completed'}: return {'status': 'attention', 'message': 'Sonarr finished searching, but no download is visible yet. Use Search and choose a download to review available releases and rejection reasons.'} if attempt + 1 < attempts: await asyncio.sleep(delay) return {'status': 'searching', 'message': 'Sonarr is still searching. No download is confirmed yet; you can close this window and follow the pipeline.'}