Files
musicseerr/backend/api/v1/routes/covers.py
T
2026-04-03 15:53:00 +01:00

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