Files
musicseerr/backend/services/requests_page_service.py
T
Harvey a69a26852e Cut down unnecessary logging (#48)
* Cut down unnecessary logging

* fix format etc

* fix checks

* fix tests
2026-04-14 00:02:38 +01:00

504 lines
19 KiB
Python

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)