import logging import math import time as _time from collections.abc import Callable, Coroutine from datetime import datetime, timezone from typing import Any, Optional, TYPE_CHECKING from api.v1.schemas.requests_page import ( ActiveRequestItem, ActiveRequestsResponse, CancelRequestResponse, RequestHistoryItem, RequestHistoryResponse, RetryRequestResponse, StatusMessage, ) from infrastructure.cover_urls import prefer_release_group_cover_url from infrastructure.persistence.request_history import RequestHistoryRecord, RequestHistoryStore from repositories.protocols import LidarrRepositoryProtocol from services.request_utils import extract_cover_url, parse_eta, resolve_display_status if TYPE_CHECKING: from infrastructure.queue.request_queue import RequestQueue logger = logging.getLogger(__name__) _CANCELLABLE_STATUSES = {"pending", "downloading"} _RETRYABLE_STATUSES = {"failed", "cancelled", "incomplete"} _CLEARABLE_STATUSES = {"imported", "incomplete", "failed", "cancelled"} _QUEUE_CACHE_TTL = 10 _LIBRARY_MBIDS_CACHE_TTL = 30 class RequestsPageService: def __init__( self, lidarr_repo: LidarrRepositoryProtocol, request_history: RequestHistoryStore, library_mbids_fn: Callable[..., Coroutine[Any, Any, set[str]]], on_import_callback: Callable[[RequestHistoryRecord], Coroutine[Any, Any, None]] | None = None, request_queue: Optional["RequestQueue"] = None, ): self._lidarr_repo = lidarr_repo self._request_history = request_history self._library_mbids_fn = library_mbids_fn self._on_import_callback = on_import_callback self._request_queue = request_queue self._queue_cache: list[dict] | None = None self._queue_cache_time: float = 0 self._library_mbids_cache: set[str] | None = None self._library_mbids_cache_time: float = 0 async def get_active_requests(self) -> ActiveRequestsResponse: active_records = await self._request_history.async_get_active_requests() if not active_records: return ActiveRequestsResponse(items=[], count=0) queue_by_mbid = await self._load_queue_map( {r.musicbrainz_id for r in active_records} ) library_mbids = await self._fetch_library_mbids() items: list[ActiveRequestItem] = [] for record in active_records: queue_item = queue_by_mbid.get(record.musicbrainz_id) if queue_item: await self._sync_active_record(record, queue_item) items.append(self._build_active_item_from_queue(record, queue_item)) else: completed = await self._check_if_completed(record, library_mbids) if completed: continue items.append(self._build_pending_item(record)) return ActiveRequestsResponse(items=items, count=len(items)) async def get_request_history( self, page: int = 1, page_size: int = 20, status_filter: Optional[str] = None, sort: Optional[str] = None, ) -> RequestHistoryResponse: records, total = await self._request_history.async_get_history( page=page, page_size=page_size, status_filter=status_filter, sort=sort ) library_mbids = await self._fetch_library_mbids() items = [ RequestHistoryItem( musicbrainz_id=r.musicbrainz_id, artist_name=r.artist_name, album_title=r.album_title, artist_mbid=r.artist_mbid, year=r.year, cover_url=r.cover_url, requested_at=datetime.fromisoformat(r.requested_at), completed_at=( datetime.fromisoformat(r.completed_at) if r.completed_at else None ), status=r.status, in_library=r.musicbrainz_id.lower() in library_mbids, ) for r in records ] total_pages = max(1, math.ceil(total / page_size)) return RequestHistoryResponse( items=items, total=total, page=page, page_size=page_size, total_pages=total_pages, ) async def cancel_request( self, musicbrainz_id: str ) -> CancelRequestResponse: record = await self._request_history.async_get_record(musicbrainz_id) if not record: return CancelRequestResponse( success=False, message="Request not found" ) if record.status not in _CANCELLABLE_STATUSES: return CancelRequestResponse( success=False, message=f"Cannot cancel request with status '{record.status}'", ) # Cancel from local queue first queue_cancelled = False if self._request_queue: queue_cancelled = await self._request_queue.cancel(musicbrainz_id) try: queue_items = await self._get_cached_queue() except Exception as e: # noqa: BLE001 logger.error("Failed to fetch queue for cancel: %s", e) return CancelRequestResponse( success=False, message="Failed to reach Lidarr" ) queue_id = None for item in queue_items: album_data = item.get("album", {}) if album_data.get("foreignAlbumId", "").lower() == musicbrainz_id.lower(): queue_id = item.get("id") break if queue_id: removed = await self._lidarr_repo.remove_queue_item(queue_id) if not removed: return CancelRequestResponse( success=False, message="Couldn't remove the item from the download queue" ) self._invalidate_queue_cache() elif not queue_cancelled: # Not in the local queue or Lidarr's download queue library_mbids = await self._fetch_library_mbids() if musicbrainz_id.lower() in library_mbids: return CancelRequestResponse( success=False, message="Album already imported, cannot cancel", ) now_iso = datetime.now(timezone.utc).isoformat() await self._request_history.async_update_status( musicbrainz_id, "cancelled", completed_at=now_iso ) return CancelRequestResponse( success=True, message=f"Cancelled download of {record.album_title}", ) async def retry_request( self, musicbrainz_id: str ) -> RetryRequestResponse: record = await self._request_history.async_get_record(musicbrainz_id) if not record: return RetryRequestResponse( success=False, message="Request not found" ) if record.status not in _RETRYABLE_STATUSES: return RetryRequestResponse( success=False, message=f"Cannot retry request with status '{record.status}'", ) # If we have a Lidarr album ID, try a targeted search first if record.lidarr_album_id: result = await self._lidarr_repo.trigger_album_search( [record.lidarr_album_id] ) if result: await self._request_history.async_update_status(musicbrainz_id, "pending") return RetryRequestResponse( success=True, message=f"Retrying search for {record.album_title}", ) # Route through queue for dedup, per-artist locking, and history callbacks if self._request_queue: try: await self._request_history.async_update_status(musicbrainz_id, "pending") enqueued = await self._request_queue.enqueue(musicbrainz_id) if enqueued: return RetryRequestResponse( success=True, message=f"Re-requested {record.album_title}", ) return RetryRequestResponse( success=True, message=f"Request already in queue for {record.album_title}", ) except Exception as e: # noqa: BLE001 logger.error("Retry via queue failed for %s: %s", musicbrainz_id, e) return RetryRequestResponse( success=False, message=f"Retry failed: {e}" ) # Fallback: direct add_album (only if no queue available) try: add_result = await self._lidarr_repo.add_album(musicbrainz_id) payload = add_result.get("payload", {}) if payload and isinstance(payload, dict): new_id = payload.get("id") if new_id: await self._request_history.async_update_lidarr_album_id( musicbrainz_id, new_id ) await self._request_history.async_update_status(musicbrainz_id, "pending") return RetryRequestResponse( success=True, message=f"Re-requested {record.album_title}", ) except Exception as e: # noqa: BLE001 logger.error("Retry failed for %s: %s", musicbrainz_id, e) return RetryRequestResponse( success=False, message=f"Retry failed: {e}" ) async def clear_history_item(self, musicbrainz_id: str) -> bool: record = await self._request_history.async_get_record(musicbrainz_id) if not record or record.status not in _CLEARABLE_STATUSES: return False return await self._request_history.async_delete_record(musicbrainz_id) async def get_active_count(self) -> int: return await self._request_history.async_get_active_count() async def sync_request_statuses(self) -> None: active_records = await self._request_history.async_get_active_requests() if not active_records: return try: queue_items = await self._get_cached_queue() except Exception as e: # noqa: BLE001 logger.warning("Status sync failed - cannot reach Lidarr: %s", e) return queue_mbids: set[str] = set() for item in queue_items: album_data = item.get("album", {}) mbid = album_data.get("foreignAlbumId") if mbid: queue_mbids.add(mbid.lower()) library_mbids = await self._fetch_library_mbids() for record in active_records: if record.musicbrainz_id.lower() in queue_mbids: if record.status != "downloading": await self._request_history.async_update_status( record.musicbrainz_id, "downloading" ) else: await self._check_if_completed(record, library_mbids) async def _fetch_library_mbids(self) -> set[str]: now = _time.monotonic() if self._library_mbids_cache is not None and (now - self._library_mbids_cache_time) < _LIBRARY_MBIDS_CACHE_TTL: return self._library_mbids_cache try: result = await self._library_mbids_fn() self._library_mbids_cache = result self._library_mbids_cache_time = now return result except Exception: # noqa: BLE001 if self._library_mbids_cache is not None: return self._library_mbids_cache return set() async def _get_cached_queue(self) -> list[dict]: now = _time.monotonic() if self._queue_cache is not None and (now - self._queue_cache_time) < _QUEUE_CACHE_TTL: return self._queue_cache try: queue_items = await self._lidarr_repo.get_queue_details() self._queue_cache = queue_items self._queue_cache_time = now return queue_items except Exception as e: # noqa: BLE001 logger.warning("Failed to fetch Lidarr queue: %s", e) return self._queue_cache or [] def _invalidate_queue_cache(self) -> None: self._queue_cache = None self._queue_cache_time = 0 async def _load_queue_map( self, active_mbids: set[str] ) -> dict[str, dict]: queue_items = await self._get_cached_queue() normalized_active = {m.lower() for m in active_mbids} queue_by_mbid: dict[str, dict] = {} for item in queue_items: album_data = item.get("album", {}) mbid = album_data.get("foreignAlbumId") if mbid and mbid.lower() in normalized_active: queue_by_mbid[mbid] = item return queue_by_mbid async def _sync_active_record( self, record: RequestHistoryRecord, queue_item: dict, ) -> None: if record.status != "downloading": await self._request_history.async_update_status( record.musicbrainz_id, "downloading" ) if not record.cover_url: album_data = queue_item.get("album", {}) cover_url = extract_cover_url(album_data) if cover_url: await self._request_history.async_update_cover_url( record.musicbrainz_id, cover_url ) record.cover_url = cover_url @staticmethod def _build_active_item_from_queue( record: RequestHistoryRecord, queue_item: dict, ) -> ActiveRequestItem: album_data = queue_item.get("album", {}) artist_data = album_data.get("artist", {}) or queue_item.get("artist", {}) cover_url = prefer_release_group_cover_url( record.musicbrainz_id, record.cover_url or extract_cover_url(album_data), size=500, ) artist_mbid = record.artist_mbid or artist_data.get("foreignArtistId") size = queue_item.get("size") sizeleft = queue_item.get("sizeleft") progress = ( round((size - (sizeleft or 0)) / size * 100, 1) if size and size > 0 else None ) eta = parse_eta(queue_item.get("estimatedCompletionTime")) status_messages = [ StatusMessage( title=msg.get("title"), messages=msg.get("messages") or [], ) for msg in (queue_item.get("statusMessages") or []) ] or None download_state = queue_item.get("trackedDownloadState") display_status = resolve_display_status(download_state) quality_data = queue_item.get("quality", {}) quality_name = None if isinstance(quality_data, dict): quality_obj = quality_data.get("quality", {}) if isinstance(quality_obj, dict): quality_name = quality_obj.get("name") return ActiveRequestItem( musicbrainz_id=record.musicbrainz_id, artist_name=record.artist_name, album_title=record.album_title, artist_mbid=artist_mbid, year=record.year, cover_url=cover_url, requested_at=datetime.fromisoformat(record.requested_at), status=display_status, progress=progress, eta=eta, size=size, size_remaining=sizeleft, download_status=queue_item.get("trackedDownloadStatus"), download_state=download_state, status_messages=status_messages, error_message=queue_item.get("errorMessage"), lidarr_queue_id=queue_item.get("id"), quality=quality_name, protocol=queue_item.get("protocol"), download_client=queue_item.get("downloadClient"), ) @staticmethod def _build_pending_item(record: RequestHistoryRecord) -> ActiveRequestItem: return ActiveRequestItem( musicbrainz_id=record.musicbrainz_id, artist_name=record.artist_name, album_title=record.album_title, artist_mbid=record.artist_mbid, year=record.year, cover_url=prefer_release_group_cover_url( record.musicbrainz_id, record.cover_url, size=500, ), requested_at=datetime.fromisoformat(record.requested_at), status=record.status, progress=None, eta=None, size=None, size_remaining=None, download_status=None, download_state=None, status_messages=None, lidarr_queue_id=None, ) async def _check_if_completed( self, record: RequestHistoryRecord, library_mbids: set[str], ) -> bool: now_iso = datetime.now(timezone.utc).isoformat() if record.musicbrainz_id.lower() in library_mbids: await self._request_history.async_update_status( record.musicbrainz_id, "imported", completed_at=now_iso ) await self._notify_import(record) return True if record.lidarr_album_id: try: history = await self._lidarr_repo.get_history_for_album( record.lidarr_album_id ) for event in history: event_type = event.get("eventType", "") if event_type in ( "downloadImported", "trackFileImported", ): await self._request_history.async_update_status( record.musicbrainz_id, "imported", completed_at=now_iso, ) await self._notify_import(record) return True if event_type == "albumImportIncomplete": await self._request_history.async_update_status( record.musicbrainz_id, "incomplete", completed_at=now_iso, ) return True if event_type == "downloadFailed": await self._request_history.async_update_status( record.musicbrainz_id, "failed", completed_at=now_iso, ) return True except Exception as e: # noqa: BLE001 logger.debug("Lidarr history check failed for %s: %s", record.musicbrainz_id, e) return False async def _notify_import(self, record: RequestHistoryRecord) -> None: self._library_mbids_cache = None self._library_mbids_cache_time = 0 if self._on_import_callback: try: await self._on_import_callback(record) except Exception as e: # noqa: BLE001 logger.warning("Import callback failed for %s: %s", record.musicbrainz_id, e)