from typing import Any, Dict, List, Optional import asyncio import logging import re from datetime import datetime, timezone from urllib.parse import quote import httpx from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyfin import JellyfinClient from ..clients.sonarr import SonarrClient from ..clients.radarr import RadarrClient from ..clients.prowlarr import ProwlarrClient from ..clients.qbittorrent import QBittorrentClient from ..runtime import get_runtime_settings from ..db import ( save_snapshot, get_request_cache_payload, get_request_cache_by_id, get_recent_snapshots, get_setting, set_setting, is_seerr_media_failure_suppressed, record_seerr_media_failure, clear_seerr_media_failure, ) from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop logger = logging.getLogger(__name__) JELLYFIN_SCAN_COOLDOWN_SECONDS = 300 _jellyfin_scan_key = "jellyfin_scan_last_at" STATUS_LABELS = { 1: "Waiting for approval", 2: "Approved", 3: "Declined", 4: "Ready to watch", 5: "Working on it", 6: "Partially ready", } def _status_label(value: Any) -> str: try: numeric = int(value) return STATUS_LABELS.get(numeric, f"Status {numeric}") except (TypeError, ValueError): return "Unknown" def _pick_first(value: Any) -> Optional[Dict[str, Any]]: if isinstance(value, list): return value[0] if value else None if isinstance(value, dict): return value return None def _normalize_media_title(value: Any) -> Optional[str]: if not isinstance(value, str): return None normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip() return normalized or None def _canonical_provider_key(value: str) -> str: normalized = value.strip().lower() if normalized.endswith("id"): normalized = normalized[:-2] return normalized def extract_request_provider_ids(payload: Any) -> Dict[str, str]: provider_ids: Dict[str, str] = {} candidates: List[Any] = [] if isinstance(payload, dict): candidates.append(payload) media = payload.get("media") if isinstance(media, dict): candidates.append(media) for candidate in candidates: if not isinstance(candidate, dict): continue embedded = candidate.get("ProviderIds") or candidate.get("providerIds") if isinstance(embedded, dict): for key, value in embedded.items(): if value is None: continue text = str(value).strip() if text: provider_ids[_canonical_provider_key(str(key))] = text for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"): value = candidate.get(key) if value is None: continue text = str(value).strip() if text: provider_ids[_canonical_provider_key(key)] = text return provider_ids def jellyfin_item_matches_request( item: Dict[str, Any], *, title: Optional[str], year: Optional[int], request_type: RequestType, request_payload: Optional[Dict[str, Any]] = None, ) -> bool: request_provider_ids = extract_request_provider_ids(request_payload or {}) item_provider_ids = extract_request_provider_ids(item) provider_priority = ("tmdb", "tvdb", "imdb") for key in provider_priority: request_id = request_provider_ids.get(key) item_id = item_provider_ids.get(key) if request_id and item_id and request_id == item_id: return True request_title = _normalize_media_title(title) if not request_title: return False item_titles = [ _normalize_media_title(item.get("Name")), _normalize_media_title(item.get("OriginalTitle")), _normalize_media_title(item.get("SortName")), _normalize_media_title(item.get("SeriesName")), _normalize_media_title(item.get("title")), ] item_titles = [candidate for candidate in item_titles if candidate] item_year = item.get("ProductionYear") or item.get("Year") try: item_year_value = int(item_year) if item_year is not None else None except (TypeError, ValueError): item_year_value = None if year and item_year_value and int(year) != item_year_value: return False if request_title in item_titles: return True if request_type == RequestType.tv: for candidate in item_titles: if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)): return True return False def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]: response = exc.response if response is None: return None try: payload = response.json() except ValueError: payload = response.text if isinstance(payload, dict): message = payload.get("message") or payload.get("error") return str(message).strip() if message else str(payload) if isinstance(payload, str): trimmed = payload.strip() return trimmed or None return str(payload) def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool: response = exc.response if response is None: return False return response.status_code == 404 or response.status_code >= 500 async def _get_seerr_media_details( jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int ) -> Optional[Dict[str, Any]]: media_type = request_type.value if media_type not in {"movie", "tv"}: return None if is_seerr_media_failure_suppressed(media_type, tmdb_id): logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id) return None try: if request_type == RequestType.movie: details = await jellyseerr.get_movie(int(tmdb_id)) else: details = await jellyseerr.get_tv(int(tmdb_id)) except httpx.HTTPStatusError as exc: if _should_persist_seerr_media_failure(exc): record_seerr_media_failure( media_type, int(tmdb_id), status_code=exc.response.status_code if exc.response is not None else None, error_message=_extract_http_error_message(exc), ) return None if isinstance(details, dict): clear_seerr_media_failure(media_type, int(tmdb_id)) return details return None async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None: if snapshot.state not in {NormalizedState.available, NormalizedState.completed}: return runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): return last_scan = get_setting(_jellyfin_scan_key) if last_scan: try: parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00")) if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS: return except ValueError: pass previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1) if previous: prev_state = previous[0].get("state") if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}: return try: await client.refresh_library() except Exception as exc: logger.warning("Jellyfin library refresh failed: %s", exc) return set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat()) logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id) def _queue_records(queue: Any) -> List[Dict[str, Any]]: if isinstance(queue, dict): records = queue.get("records") if isinstance(records, list): return records if isinstance(queue, list): return queue return [] def _filter_queue(queue: Any, item_id: Optional[int], request_type: RequestType) -> Any: if not item_id: return queue records = _queue_records(queue) if not records: return queue key = "seriesId" if request_type == RequestType.tv else "movieId" filtered = [record for record in records if record.get(key) == item_id] if isinstance(queue, dict): filtered_queue = dict(queue) filtered_queue["records"] = filtered filtered_queue["totalRecords"] = len(filtered) return filtered_queue return filtered def _download_ids(records: List[Dict[str, Any]]) -> List[str]: ids = [] for record in records: download_id = record.get("downloadId") or record.get("download_id") if isinstance(download_id, str) and download_id: ids.append(download_id) return ids def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]: if not isinstance(episodes, list): return {} grouped: Dict[int, List[int]] = {} now = datetime.now(timezone.utc) for episode in episodes: if not isinstance(episode, dict): continue if not episode.get("monitored", True): continue if episode.get("hasFile"): continue air_date = episode.get("airDateUtc") if isinstance(air_date, str): try: aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00")) except ValueError: aired_at = None if aired_at and aired_at > now: continue season_number = episode.get("seasonNumber") episode_number = episode.get("episodeNumber") if not isinstance(episode_number, int): episode_number = episode.get("absoluteEpisodeNumber") if isinstance(season_number, int) and isinstance(episode_number, int): grouped.setdefault(season_number, []).append(episode_number) for season_number in list(grouped.keys()): grouped[season_number] = sorted(set(grouped[season_number])) return grouped def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]: if not torrents: return {"state": "idle", "message": "0 active downloads."} downloading_states = {"downloading", "stalleddl", "queueddl", "checkingdl", "forceddl"} paused_states = {"pauseddl", "pausedup"} completed_states = {"uploading", "stalledup", "queuedup", "checkingup", "forcedup", "stoppedup"} downloading = [t for t in torrents if str(t.get("state", "")).lower() in downloading_states] paused = [t for t in torrents if str(t.get("state", "")).lower() in paused_states] completed = [t for t in torrents if str(t.get("state", "")).lower() in completed_states] if downloading: return { "state": "downloading", "message": f"Downloading ({len(downloading)} active).", } if paused: return { "state": "paused", "message": f"Paused ({len(paused)} paused).", } if completed: return { "state": "completed", "message": f"Completed/seeding ({len(completed)} seeding).", } return { "state": "idle", "message": "0 active downloads.", } def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]: if not path: return None if not path.startswith("/"): path = f"/{path}" if cache_mode == "cache": return f"/images/tmdb?path={quote(path)}&size={size}" return f"https://image.tmdb.org/t/p/{size}{path}" async def build_snapshot(request_id: str) -> Snapshot: timeline = [] runtime = get_runtime_settings() jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) qbittorrent = QBittorrentClient( runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password, ) snapshot = Snapshot( request_id=request_id, title="Unknown", state=NormalizedState.unknown, state_reason="Awaiting configuration", ) cached_request = None mode = (runtime.requests_data_source or "prefer_cache").lower() if mode != "always_js" and request_id.isdigit(): cached_request = get_request_cache_payload(int(request_id)) if cached_request is not None: logging.getLogger(__name__).debug( "snapshot cache hit: request_id=%s mode=%s", request_id, mode ) else: logging.getLogger(__name__).debug( "snapshot cache miss: request_id=%s mode=%s", request_id, mode ) if cached_request is not None: cache_meta = get_request_cache_by_id(int(request_id)) cached_title = cache_meta.get("title") if cache_meta else None if cached_title and isinstance(cached_request, dict): media = cached_request.get("media") if not isinstance(media, dict): media = {} cached_request["media"] = media if not media.get("title") and not media.get("name"): media["title"] = cached_title media["name"] = cached_title if not cached_request.get("title") and not cached_request.get("name"): cached_request["title"] = cached_title allow_remote = mode == "always_js" and jellyseerr.configured() if not jellyseerr.configured() and not cached_request: timeline.append(TimelineHop(service="Seerr", status="not_configured")) timeline.append(TimelineHop(service="Sonarr/Radarr", status="not_configured")) timeline.append(TimelineHop(service="Prowlarr", status="not_configured")) timeline.append(TimelineHop(service="qBittorrent", status="not_configured")) snapshot.timeline = timeline return snapshot if cached_request is None and not allow_remote: timeline.append(TimelineHop(service="Seerr", status="cache_miss")) snapshot.timeline = timeline snapshot.state = NormalizedState.unknown snapshot.state_reason = "Request not found in cache" return snapshot jelly_request = cached_request if allow_remote and (jelly_request is None or mode == "always_js"): try: jelly_request = await jellyseerr.get_request(request_id) logging.getLogger(__name__).debug( "snapshot Seerr fetch: request_id=%s mode=%s", request_id, mode ) except Exception as exc: timeline.append(TimelineHop(service="Seerr", status="error", details={"error": str(exc)})) snapshot.timeline = timeline snapshot.state = NormalizedState.failed snapshot.state_reason = "Failed to reach Seerr" return snapshot if not jelly_request: timeline.append(TimelineHop(service="Seerr", status="not_found")) snapshot.timeline = timeline snapshot.state = NormalizedState.unknown snapshot.state_reason = "Request not found in Seerr" return snapshot jelly_status = jelly_request.get("status", "unknown") jelly_status_label = _status_label(jelly_status) jelly_type = jelly_request.get("type") or "unknown" media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {} if not isinstance(media, dict): media = {} snapshot.title = ( media.get("title") or media.get("name") or jelly_request.get("title") or jelly_request.get("name") or "Unknown" ) snapshot.year = media.get("year") or jelly_request.get("year") snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown poster_path = None backdrop_path = None if isinstance(media, dict): poster_path = media.get("posterPath") or media.get("poster_path") backdrop_path = media.get("backdropPath") or media.get("backdrop_path") if snapshot.title in {None, "", "Unknown"} and allow_remote: tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id)) if isinstance(details, dict): if snapshot.request_type == RequestType.movie: snapshot.title = details.get("title") or snapshot.title release_date = details.get("releaseDate") snapshot.year = int(release_date[:4]) if release_date else snapshot.year elif snapshot.request_type == RequestType.tv: snapshot.title = details.get("name") or details.get("title") or snapshot.title first_air = details.get("firstAirDate") snapshot.year = int(first_air[:4]) if first_air else snapshot.year poster_path = poster_path or details.get("posterPath") or details.get("poster_path") backdrop_path = ( backdrop_path or details.get("backdropPath") or details.get("backdrop_path") ) cache_mode = (runtime.artwork_cache_mode or "remote").lower() snapshot.artwork = { "poster_path": poster_path, "backdrop_path": backdrop_path, "poster_url": _artwork_url(poster_path, "w342", cache_mode), "backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode), } timeline.append( TimelineHop( service="Seerr", status=jelly_status_label, details={ "requestedBy": jelly_request.get("requestedBy", {}).get("displayName") or jelly_request.get("requestedBy", {}).get("username") or jelly_request.get("requestedBy", {}).get("jellyfinUsername") or jelly_request.get("requestedBy", {}).get("email"), "createdAt": jelly_request.get("createdAt"), "updatedAt": jelly_request.get("updatedAt"), "approved": jelly_request.get("isApproved"), "statusCode": jelly_status, }, ) ) arr_state = None arr_details: Dict[str, Any] = {} arr_item = None arr_queue = None media_status = jelly_request.get("media", {}).get("status") try: media_status_code = int(media_status) if media_status is not None else None except (TypeError, ValueError): media_status_code = None if snapshot.request_type == RequestType.tv: tvdb_id = jelly_request.get("media", {}).get("tvdbId") if tvdb_id: try: series = await sonarr.get_series_by_tvdb_id(int(tvdb_id)) arr_item = _pick_first(series) arr_details["series"] = arr_item arr_state = "added" if arr_item else "missing" if arr_item: stats = arr_item.get("statistics") if isinstance(arr_item, dict) else None if isinstance(stats, dict): file_count = stats.get("episodeFileCount") total_count = ( stats.get("totalEpisodeCount") if isinstance(stats.get("totalEpisodeCount"), int) else stats.get("episodeCount") ) if ( isinstance(file_count, int) and isinstance(total_count, int) and total_count > 0 and file_count >= total_count ): arr_state = "available" if arr_item and isinstance(arr_item.get("id"), int): series_id = int(arr_item["id"]) arr_queue = await sonarr.get_queue(series_id) arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv) arr_details["queue"] = arr_queue episodes = await sonarr.get_episodes(series_id) missing_by_season = _missing_episode_numbers_by_season(episodes) if missing_by_season: arr_details["missingEpisodes"] = missing_by_season except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) elif snapshot.request_type == RequestType.movie: tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: try: movie = await radarr.get_movie_by_tmdb_id(int(tmdb_id)) arr_item = _pick_first(movie) if not arr_item: title_hint = ( jelly_request.get("media", {}).get("title") or jelly_request.get("title") or snapshot.title ) year_hint = ( jelly_request.get("media", {}).get("year") or jelly_request.get("year") or snapshot.year ) try: all_movies = await radarr.get_movies() except Exception: all_movies = None if isinstance(all_movies, list): for candidate in all_movies: if not isinstance(candidate, dict): continue if tmdb_id and candidate.get("tmdbId") == int(tmdb_id): arr_item = candidate break if title_hint and candidate.get("title") == title_hint: if not year_hint or candidate.get("year") == year_hint: arr_item = candidate break arr_details["movie"] = arr_item if arr_item: if arr_item.get("hasFile"): arr_state = "available" elif arr_item.get("isAvailable"): arr_state = "searching" else: arr_state = "added" else: arr_state = "missing" if arr_item and isinstance(arr_item.get("id"), int): arr_queue = await radarr.get_queue(int(arr_item["id"])) arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie) arr_details["queue"] = arr_queue except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) if arr_state is None: arr_state = "unknown" timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) try: prowlarr_health = await prowlarr.get_health() if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0: timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health})) else: timeline.append(TimelineHop(service="Prowlarr", status="ok")) except Exception as exc: timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)})) jellyfin_available = False jellyfin_item = None if jellyfin.configured() and snapshot.title: types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"] try: search = await jellyfin.search_items(snapshot.title, types, limit=50) except Exception: search = None if isinstance(search, dict): items = search.get("Items") or search.get("items") or [] for item in items: if not isinstance(item, dict): continue if jellyfin_item_matches_request( item, title=snapshot.title, year=snapshot.year, request_type=snapshot.request_type, request_payload=jelly_request, ): jellyfin_available = True jellyfin_item = item break if jellyfin_available and arr_state == "missing" and runtime.jellyfin_sync_to_arr: arr_details["note"] = "Found in Jellyfin but not tracked in Sonarr/Radarr." if snapshot.request_type == RequestType.movie: if runtime.radarr_quality_profile_id and runtime.radarr_root_folder: radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) if radarr_client.configured(): root_folder = await _resolve_root_folder_path( radarr_client, runtime.radarr_root_folder, "Radarr" ) tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: try: await radarr_client.add_movie( int(tmdb_id), runtime.radarr_quality_profile_id, root_folder, monitored=False, search_for_movie=False, ) except Exception: pass if snapshot.request_type == RequestType.tv: if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder: sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) if sonarr_client.configured(): root_folder = await _resolve_root_folder_path( sonarr_client, runtime.sonarr_root_folder, "Sonarr" ) tvdb_id = jelly_request.get("media", {}).get("tvdbId") if tvdb_id: try: await sonarr_client.add_series( int(tvdb_id), runtime.sonarr_quality_profile_id, root_folder, monitored=False, search_missing=False, ) except Exception: pass qbit_state = None qbit_message = None try: download_ids = _download_ids(_queue_records(arr_queue)) torrent_list: List[Dict[str, Any]] = [] if qbittorrent.configured(): if download_ids: torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids)) torrent_list = torrents if isinstance(torrents, list) else [] else: category = f"magent-{request_id}" torrents = await qbittorrent.get_torrents_by_category(category) torrent_list = torrents if isinstance(torrents, list) else [] summary = _summarize_qbit(torrent_list) qbit_state = summary.get("state") qbit_message = summary.get("message") timeline.append( TimelineHop( service="qBittorrent", status=summary["state"], details={ "summary": summary["message"], "torrents": torrent_list, }, ) ) except Exception as exc: timeline.append(TimelineHop(service="qBittorrent", status="error", details={"error": str(exc)})) status_code = None try: status_code = int(jelly_status) except (TypeError, ValueError): status_code = None derived_approved = bool(jelly_request.get("isApproved")) or status_code in {2, 4, 5, 6} if derived_approved: snapshot.state = NormalizedState.approved snapshot.state_reason = "Approved and queued for processing." else: snapshot.state = NormalizedState.requested snapshot.state_reason = "Waiting for approval before we can search." queue_records = _queue_records(arr_queue) if qbit_state in {"downloading", "paused"}: snapshot.state = NormalizedState.downloading snapshot.state_reason = "Downloading in qBittorrent." if qbit_message: snapshot.state_reason = qbit_message elif qbit_state == "completed": if arr_state == "available": snapshot.state = NormalizedState.completed snapshot.state_reason = "In your library and ready to watch." else: snapshot.state = NormalizedState.importing snapshot.state_reason = "Download finished. Waiting for library import." elif queue_records: if arr_state == "missing": snapshot.state_reason = "Queue shows a download, but qBittorrent has no active torrent." else: snapshot.state_reason = "Waiting for download to start in qBittorrent." elif arr_state == "missing" and derived_approved: snapshot.state = NormalizedState.needs_add snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr." elif arr_state == "searching": snapshot.state = NormalizedState.searching snapshot.state_reason = "Searching for a matching release." elif arr_state == "available": snapshot.state = NormalizedState.completed snapshot.state_reason = "In your library and ready to watch." elif arr_state == "added" and snapshot.state == NormalizedState.approved: snapshot.state = NormalizedState.added_to_arr snapshot.state_reason = "Item is present in Sonarr/Radarr" if jellyfin_available: missing_episodes = arr_details.get("missingEpisodes") if snapshot.request_type == RequestType.tv and isinstance(missing_episodes, dict) and missing_episodes: snapshot.state = NormalizedState.importing snapshot.state_reason = "Some episodes are available in Jellyfin, but the request is still incomplete." for hop in timeline: if hop.service == "Seerr": hop.status = "Partially ready" else: snapshot.state = NormalizedState.completed snapshot.state_reason = "Ready to watch in Jellyfin." for hop in timeline: if hop.service == "Seerr": hop.status = "Available" elif hop.service == "Sonarr/Radarr" and hop.status not in {"error"}: hop.status = "available" snapshot.timeline = timeline actions: List[ActionOption] = [] if arr_state == "missing": actions.append( ActionOption( id="readd_to_arr", label="Push to Sonarr/Radarr", risk="medium", ) ) elif arr_item and arr_state != "available": actions.append( ActionOption( id="search_auto", label="Search and auto-download", risk="low", ) ) actions.append( ActionOption( id="search_releases", label="Search and choose a download", risk="low", ) ) download_ids = _download_ids(_queue_records(arr_queue)) if download_ids and qbittorrent.configured(): actions.append( ActionOption( id="resume_torrent", label="Resume the download", risk="low", ) ) snapshot.actions = actions jellyfin_link = None if runtime.jellyfin_public_url and snapshot.state in { NormalizedState.available, NormalizedState.completed, }: base_url = runtime.jellyfin_public_url.rstrip("/") query = quote(snapshot.title or "") jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}" snapshot.raw = { "jellyseerr": jelly_request, "arr": { "item": arr_item, "queue": arr_queue, }, "jellyfin": { "publicUrl": runtime.jellyfin_public_url, "available": snapshot.state in { NormalizedState.available, NormalizedState.completed, }, "link": jellyfin_link, "item": jellyfin_item, }, } await _maybe_refresh_jellyfin(snapshot) await asyncio.to_thread(save_snapshot, snapshot) return snapshot