import logging import hashlib from typing import Optional from fastapi import APIRouter, HTTPException, Path, Query, Depends, Request from fastapi.responses import Response from core.dependencies import get_coverart_repository from infrastructure.msgspec_fastapi import MsgSpecRoute from repositories.coverart_repository import CoverArtRepository router = APIRouter(route_class=MsgSpecRoute, prefix="/covers", tags=["covers"]) log = logging.getLogger(__name__) _ALLOWED_SIZES = {"250", "500", "1200"} _SIZE_ALIAS_NONE = {"", "original", "full", "max", "largest"} def _quote_etag(content_hash: str) -> str: return f'"{content_hash}"' def _etag_matches(if_none_match: Optional[str], etag_header: str) -> bool: if not if_none_match: return False candidates = [token.strip() for token in if_none_match.split(",")] if "*" in candidates: return True if etag_header in candidates: return True weak_etag = f"W/{etag_header}" return weak_etag in candidates def _normalize_size(size: Optional[str]) -> Optional[str]: if size is None: return "500" normalized = size.strip().lower() if normalized in _SIZE_ALIAS_NONE: return None if normalized not in _ALLOWED_SIZES: raise HTTPException( status_code=400, detail=f"Unsupported size '{size}'. Choose one of 250, 500, 1200 or original.", ) return normalized @router.get("/release-group/{release_group_id}") async def cover_from_release_group( request: Request, release_group_id: str = Path(..., min_length=1, description="MusicBrainz release group ID"), size: Optional[str] = Query( "500", description="Preferred size: 250, 500, 1200, or 'original' for full size", ), coverart_repo: CoverArtRepository = Depends(get_coverart_repository) ): desired_size = _normalize_size(size) etag_hash = await coverart_repo.get_release_group_cover_etag(release_group_id, desired_size) etag_header = _quote_etag(etag_hash) if etag_hash else None if etag_header and _etag_matches(request.headers.get("if-none-match"), etag_header): return Response( status_code=304, headers={ "Cache-Control": "public, max-age=31536000, immutable", "ETag": etag_header, }, ) result = await coverart_repo.get_release_group_cover(release_group_id, desired_size, is_disconnected=request.is_disconnected) if result: image_data, content_type, source = result if not etag_header: etag_header = _quote_etag(hashlib.sha1(image_data).hexdigest()) return Response( content=image_data, media_type=content_type, headers={ "Cache-Control": "public, max-age=31536000, immutable", "X-Cover-Source": source, "ETag": etag_header, } ) placeholder_svg = ''' ''' return Response( content=placeholder_svg.encode(), media_type="image/svg+xml", headers={ "Cache-Control": "public, max-age=86400", "X-Cover-Source": "placeholder", } ) @router.get("/release/{release_id}") async def cover_from_release( request: Request, release_id: str = Path(..., min_length=1, description="MusicBrainz release ID"), size: Optional[str] = Query( "500", description="Preferred size: 250, 500, 1200, or 'original' for full size", ), coverart_repo: CoverArtRepository = Depends(get_coverart_repository) ): desired_size = _normalize_size(size) etag_hash = await coverart_repo.get_release_cover_etag(release_id, desired_size) etag_header = _quote_etag(etag_hash) if etag_hash else None if etag_header and _etag_matches(request.headers.get("if-none-match"), etag_header): return Response( status_code=304, headers={ "Cache-Control": "public, max-age=31536000, immutable", "ETag": etag_header, }, ) result = await coverart_repo.get_release_cover(release_id, desired_size, is_disconnected=request.is_disconnected) if result: image_data, content_type, source = result if not etag_header: etag_header = _quote_etag(hashlib.sha1(image_data).hexdigest()) return Response( content=image_data, media_type=content_type, headers={ "Cache-Control": "public, max-age=31536000, immutable", "X-Cover-Source": source, "ETag": etag_header, } ) placeholder_svg = ''' ''' return Response( content=placeholder_svg.encode(), media_type="image/svg+xml", headers={ "Cache-Control": "public, max-age=86400", "X-Cover-Source": "placeholder", } ) @router.get("/artist/{artist_id}") async def get_artist_cover( request: Request, artist_id: str, size: Optional[int] = Query(None, description="Preferred size in pixels for width"), coverart_repo: CoverArtRepository = Depends(get_coverart_repository) ): etag_hash = await coverart_repo.get_artist_image_etag(artist_id, size) etag_header = _quote_etag(etag_hash) if etag_hash else None if etag_header and _etag_matches(request.headers.get("if-none-match"), etag_header): return Response( status_code=304, headers={ "Cache-Control": "public, max-age=31536000, immutable", "ETag": etag_header, }, ) result = await coverart_repo.get_artist_image(artist_id, size, is_disconnected=request.is_disconnected) if not result: placeholder_svg = ''' ''' return Response( content=placeholder_svg.encode(), media_type="image/svg+xml", headers={ "Cache-Control": "public, max-age=86400", "X-Cover-Source": "placeholder", } ) image_data, content_type, source = result if not etag_header: etag_header = _quote_etag(hashlib.sha1(image_data).hexdigest()) return Response( content=image_data, media_type=content_type, headers={ "Cache-Control": "public, max-age=31536000, immutable", "X-Cover-Source": source, "ETag": etag_header, } ) @router.get("/debug/artist/{artist_id}") async def debug_artist_cover( artist_id: str, coverart_repo: CoverArtRepository = Depends(get_coverart_repository) ): """ Debug endpoint that returns diagnostic info about an artist image fetch. Shows cache state, Lidarr availability, MusicBrainz relations, and Wikidata URL. """ from infrastructure.validators import validate_mbid debug_info = { "artist_id": artist_id, "is_valid_mbid": False, "validated_mbid": None, "disk_cache": { "exists_250": False, "exists_500": False, "negative_250": False, "negative_500": False, "meta_250": None, "meta_500": None, }, "lidarr": { "configured": False, "has_image_url": False, "image_url": None, }, "musicbrainz": { "artist_found": False, "has_wikidata_relation": False, "wikidata_url": None, }, "memory_cache": { "wikidata_url_cached": False, "cached_value": None, }, "circuit_breakers": {}, "recommendation": None, } try: validated_id = validate_mbid(artist_id, "artist") debug_info["is_valid_mbid"] = True debug_info["validated_mbid"] = validated_id except ValueError as e: debug_info["recommendation"] = f"Invalid MBID format: {e}. No image can be fetched." return debug_info debug_info = await coverart_repo.debug_artist_image(validated_id, debug_info) if debug_info["disk_cache"]["negative_250"] or debug_info["disk_cache"]["negative_500"]: debug_info["recommendation"] = "Artist has a negative cache entry. Wait for expiry or purge negative cache." elif debug_info["disk_cache"]["exists_250"] or debug_info["disk_cache"]["exists_500"]: debug_info["recommendation"] = "Image is cached on disk - should load successfully." elif any( breaker.get("state") == "open" for breaker in debug_info.get("circuit_breakers", {}).values() if isinstance(breaker, dict) ): debug_info["recommendation"] = "One or more cover fetch circuit breakers are OPEN. Retry after cooldown or reset breakers." elif debug_info["lidarr"]["has_image_url"]: debug_info["recommendation"] = "Lidarr has an image URL - fetch should succeed from Lidarr." elif debug_info["musicbrainz"]["has_wikidata_relation"]: debug_info["recommendation"] = "Wikidata URL found - fetch should succeed from Wikidata/Wikimedia." else: debug_info["recommendation"] = "No image source found. This artist will show a placeholder." return debug_info