a69a26852e
* Cut down unnecessary logging * fix format etc * fix checks * fix tests
387 lines
15 KiB
Python
387 lines
15 KiB
Python
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)
|