282 lines
10 KiB
Python
282 lines
10 KiB
Python
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 = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
|
<rect fill="#374151" width="200" height="200"/>
|
|
<circle cx="100" cy="100" r="70" fill="#1f2937" stroke="#4B5563" stroke-width="2"/>
|
|
<circle cx="100" cy="100" r="50" fill="none" stroke="#4B5563" stroke-width="1"/>
|
|
<circle cx="100" cy="100" r="30" fill="none" stroke="#4B5563" stroke-width="1"/>
|
|
<circle cx="100" cy="100" r="12" fill="#4B5563"/>
|
|
<circle cx="100" cy="100" r="4" fill="#374151"/>
|
|
</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 = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
|
<rect fill="#374151" width="200" height="200"/>
|
|
<circle cx="100" cy="100" r="70" fill="#1f2937" stroke="#4B5563" stroke-width="2"/>
|
|
<circle cx="100" cy="100" r="50" fill="none" stroke="#4B5563" stroke-width="1"/>
|
|
<circle cx="100" cy="100" r="30" fill="none" stroke="#4B5563" stroke-width="1"/>
|
|
<circle cx="100" cy="100" r="12" fill="#4B5563"/>
|
|
<circle cx="100" cy="100" r="4" fill="#374151"/>
|
|
</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 = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
|
<rect fill="#374151" width="200" height="200"/>
|
|
<circle cx="100" cy="80" r="30" fill="#6B7280"/>
|
|
<path d="M60 120 Q100 140 140 120 L140 160 Q100 180 60 160 Z" fill="#6B7280"/>
|
|
</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
|