from __future__ import annotations import asyncio import logging from pathlib import Path from typing import TYPE_CHECKING import msgspec from infrastructure.validators import validate_mbid, validate_audiodb_image_url from infrastructure.queue.priority_queue import RequestPriority from infrastructure.http.disconnect import DisconnectCallable, check_disconnected from core.exceptions import ClientDisconnectedError if TYPE_CHECKING: from services.audiodb_image_service import AudioDBImageService from repositories.lidarr import LidarrRepository from repositories.musicbrainz_repository import MusicBrainzRepository from repositories.jellyfin_repository import JellyfinRepository logger = logging.getLogger(__name__) class _ReleaseGroupMetadataResponse(msgspec.Struct): release: str | None = None def _decode_json_response(response, decode_type: type[_ReleaseGroupMetadataResponse]) -> _ReleaseGroupMetadataResponse: content = getattr(response, "content", None) if isinstance(content, (bytes, bytearray, memoryview)): return msgspec.json.decode(content, type=decode_type) return msgspec.convert(response.json(), type=decode_type) def _log_task_error(task: asyncio.Task) -> None: if not task.cancelled() and task.exception(): logger.error(f"Background cache write failed: {task.exception()}") COVER_ART_ARCHIVE_BASE = "https://coverartarchive.org" VALID_IMAGE_CONTENT_TYPES = frozenset([ "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/avif", "image/svg+xml", ]) LOCAL_SOURCE_TIMEOUT_SECONDS = 1.0 def _is_valid_image_content_type(content_type: str) -> bool: if not content_type: return False base_type = content_type.split(";")[0].strip().lower() return base_type in VALID_IMAGE_CONTENT_TYPES class AlbumCoverFetcher: def __init__( self, http_get_fn, write_cache_fn, lidarr_repo: 'LidarrRepository' | None = None, mb_repo: 'MusicBrainzRepository' | None = None, jellyfin_repo: 'JellyfinRepository' | None = None, audiodb_service: 'AudioDBImageService' | None = None, ): self._http_get = http_get_fn self._write_disk_cache = write_cache_fn self._lidarr_repo = lidarr_repo self._mb_repo = mb_repo self._jellyfin_repo = jellyfin_repo self._audiodb_service = audiodb_service async def fetch_release_group_cover( self, release_group_id: str, size: str | None, file_path: Path, priority: RequestPriority = RequestPriority.IMAGE_FETCH, is_disconnected: DisconnectCallable | None = None, ) -> tuple[bytes, str, str] | None: size_int = int(size) if size and size.isdigit() else 500 await check_disconnected(is_disconnected) result = await self._fetch_from_audiodb(release_group_id, file_path, priority=priority) if result: return result result = None try: await check_disconnected(is_disconnected) result = await asyncio.wait_for( self._fetch_release_group_local_sources(release_group_id, file_path, size_int, priority=priority), timeout=LOCAL_SOURCE_TIMEOUT_SECONDS, ) except TimeoutError: pass if result: return result size_suffix = f"-{size}" if size else "" front_url = f"{COVER_ART_ARCHIVE_BASE}/release-group/{release_group_id}/front{size_suffix}" await check_disconnected(is_disconnected) try: response = await self._http_get( front_url, priority, source="coverart", ) if response.status_code == 200: content_type = response.headers.get("content-type", "") if not _is_valid_image_content_type(content_type): logger.warning(f"Non-image content-type from CoverArtArchive: {content_type}") else: content = response.content task = asyncio.create_task( self._write_disk_cache( file_path, content, content_type, {"source": "cover-art-archive"}, ) ) task.add_done_callback(_log_task_error) return (content, content_type, "cover-art-archive") except ClientDisconnectedError: raise except Exception as e: # noqa: BLE001 logger.debug(f"Failed to fetch cover via release group: {e}") await check_disconnected(is_disconnected) return await self._get_cover_from_best_release(release_group_id, size, file_path, priority=priority, is_disconnected=is_disconnected) async def _fetch_release_group_local_sources( self, release_group_id: str, file_path: Path, size: int, priority: RequestPriority = RequestPriority.IMAGE_FETCH, ) -> tuple[bytes, str, str] | None: result = await self._fetch_from_lidarr(release_group_id, file_path, size=size, priority=priority) if result: return result return await self._fetch_from_jellyfin(release_group_id, file_path, priority=priority) async def _fetch_from_audiodb( self, release_group_id: str, file_path: Path, priority: RequestPriority = RequestPriority.IMAGE_FETCH, ) -> tuple[bytes, str, str] | None: if self._audiodb_service is None: return None try: cached_images = await self._audiodb_service.fetch_and_cache_album_images(release_group_id) if cached_images is None or cached_images.is_negative or not cached_images.album_thumb_url: return None if not validate_audiodb_image_url(cached_images.album_thumb_url): logger.warning("[IMG:AudioDB] Rejected unsafe URL for album %s", release_group_id[:8]) return None response = await self._http_get( cached_images.album_thumb_url, priority, source="audiodb", ) if response.status_code != 200: return None content_type = response.headers.get("content-type", "") if not _is_valid_image_content_type(content_type): logger.warning(f"[IMG:AudioDB] Non-image content-type ({content_type}) for {release_group_id[:8]}") return None content = response.content task = asyncio.create_task( self._write_disk_cache(file_path, content, content_type, {"source": "audiodb"}) ) task.add_done_callback(_log_task_error) return (content, content_type, "audiodb") except ClientDisconnectedError: raise except Exception as e: # noqa: BLE001 return None async def _get_cover_from_best_release( self, release_group_id: str, size: str | None, cache_path: Path, priority: RequestPriority = RequestPriority.IMAGE_FETCH, is_disconnected: DisconnectCallable | None = None, ) -> tuple[bytes, str, str] | None: try: metadata_url = f"{COVER_ART_ARCHIVE_BASE}/release-group/{release_group_id}" response = await self._http_get( metadata_url, priority, source="coverart", headers={"Accept": "application/json"}, ) if response.status_code != 200: return None data = _decode_json_response(response, _ReleaseGroupMetadataResponse) release_url = data.release or "" if not release_url: return None release_id = release_url.split("/")[-1] try: release_id = validate_mbid(release_id, "release") except ValueError as e: logger.warning(f"Invalid release MBID extracted from metadata: {e}") return None await check_disconnected(is_disconnected) size_suffix = f"-{size}" if size else "" release_front_url = f"{COVER_ART_ARCHIVE_BASE}/release/{release_id}/front{size_suffix}" response = await self._http_get( release_front_url, priority, source="coverart", ) if response.status_code == 200: content_type = response.headers.get("content-type", "") if not _is_valid_image_content_type(content_type): logger.warning(f"Non-image content-type from release: {content_type}") return None content = response.content task = asyncio.create_task( self._write_disk_cache( cache_path, content, content_type, {"source": "cover-art-archive"}, ) ) task.add_done_callback(_log_task_error) return (content, content_type, "cover-art-archive") except ClientDisconnectedError: raise except Exception as e: # noqa: BLE001 return None return None async def _fetch_from_lidarr( self, release_group_id: str, file_path: Path, size: int | None = 500, priority: RequestPriority = RequestPriority.IMAGE_FETCH, ) -> tuple[bytes, str, str] | None: if not self._lidarr_repo: return None if not self._lidarr_repo.is_configured(): return None try: image_url = await self._lidarr_repo.get_album_image_url(release_group_id, size=size) if not image_url: return None response = await self._http_get( image_url, priority, source="lidarr", ) if response.status_code != 200: return None content_type = response.headers.get("content-type", "") if not _is_valid_image_content_type(content_type): logger.warning(f"Non-image content-type from Lidarr album: {content_type}") return None content = response.content task = asyncio.create_task(self._write_disk_cache(file_path, content, content_type, {"source": "lidarr"})) task.add_done_callback(_log_task_error) return (content, content_type, "lidarr") except Exception as e: # noqa: BLE001 return None async def _fetch_from_jellyfin( self, musicbrainz_id: str, file_path: Path, priority: RequestPriority = RequestPriority.IMAGE_FETCH, ) -> tuple[bytes, str, str] | None: if not self._jellyfin_repo or not self._jellyfin_repo.is_configured(): return None try: album = await self._jellyfin_repo.get_album_by_mbid(musicbrainz_id) if not album: return None image_url = self._jellyfin_repo.get_image_url(album.id, album.image_tag) if not image_url: return None response = await self._http_get( image_url, priority, source="jellyfin", headers=self._jellyfin_repo.get_auth_headers(), ) if response.status_code != 200: return None content_type = response.headers.get("content-type", "") if not _is_valid_image_content_type(content_type): logger.warning(f"Non-image content-type from Jellyfin album: {content_type}") return None content = response.content task = asyncio.create_task( self._write_disk_cache(file_path, content, content_type, {"source": "jellyfin"}) ) task.add_done_callback(_log_task_error) return (content, content_type, "jellyfin") except Exception as e: # noqa: BLE001 return None async def fetch_release_cover( self, release_id: str, size: str | None, file_path: Path, priority: RequestPriority = RequestPriority.IMAGE_FETCH, is_disconnected: DisconnectCallable | None = None, ) -> tuple[bytes, str, str] | None: release_group_id = None if self._mb_repo: await check_disconnected(is_disconnected) try: release_group_id = await self._mb_repo.get_release_group_id_from_release(release_id) except ClientDisconnectedError: raise except Exception as e: # noqa: BLE001 pass result = None try: await check_disconnected(is_disconnected) result = await asyncio.wait_for( self._fetch_release_local_sources(release_id, file_path, size, release_group_id, priority=priority), timeout=LOCAL_SOURCE_TIMEOUT_SECONDS, ) except TimeoutError: pass if result: return result if release_group_id: await check_disconnected(is_disconnected) result = await self._fetch_from_audiodb(release_group_id, file_path, priority=priority) if result: return result size_suffix = f"-{size}" if size else "" url = f"{COVER_ART_ARCHIVE_BASE}/release/{release_id}/front{size_suffix}" await check_disconnected(is_disconnected) try: response = await self._http_get(url, priority) if response.status_code == 200: content_type = response.headers.get("content-type", "") if not _is_valid_image_content_type(content_type): logger.warning(f"Non-image content-type from release cover: {content_type}") return None content = response.content task = asyncio.create_task( self._write_disk_cache( file_path, content, content_type, {"source": "cover-art-archive"}, ) ) task.add_done_callback(_log_task_error) return (content, content_type, "cover-art-archive") except ClientDisconnectedError: raise except Exception as e: # noqa: BLE001 pass return None async def _fetch_release_local_sources( self, release_id: str, file_path: Path, size: str | None, release_group_id: str | None = None, priority: RequestPriority = RequestPriority.IMAGE_FETCH, ) -> tuple[bytes, str, str] | None: size_int = int(size) if size and size.isdigit() else 500 if release_group_id is None and self._mb_repo: release_group_id = await self._mb_repo.get_release_group_id_from_release(release_id) if release_group_id: result = await self._fetch_from_lidarr(release_group_id, file_path, size=size_int, priority=priority) if result: return result result = await self._fetch_from_jellyfin(release_group_id, file_path, priority=priority) if result: return result return await self._fetch_from_jellyfin(release_id, file_path, priority=priority)