a69a26852e
* Cut down unnecessary logging * fix format etc * fix checks * fix tests
212 lines
8.1 KiB
Python
212 lines
8.1 KiB
Python
"""Genre artist resolution and image enrichment."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from infrastructure.cache.cache_keys import GENRE_ARTIST_PREFIX, GENRE_SECTION_PREFIX
|
|
from infrastructure.cache.memory_cache import CacheInterface
|
|
from repositories.protocols import MusicBrainzRepositoryProtocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377"
|
|
GENRE_CACHE_TTL = 24 * 60 * 60
|
|
GENRE_SECTION_TTL_DEFAULT = 6 * 60 * 60
|
|
|
|
|
|
class GenreService:
|
|
def __init__(
|
|
self,
|
|
musicbrainz_repo: MusicBrainzRepositoryProtocol,
|
|
memory_cache: CacheInterface | None = None,
|
|
audiodb_image_service: Any = None,
|
|
cache_dir: Path | None = None,
|
|
preferences_service: Any = None,
|
|
):
|
|
self._mb_repo = musicbrainz_repo
|
|
self._memory_cache = memory_cache
|
|
self._audiodb_image_service = audiodb_image_service
|
|
self._preferences_service = preferences_service
|
|
self._genre_build_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
self._genre_section_dir: Path | None = None
|
|
if cache_dir:
|
|
self._genre_section_dir = cache_dir / "genre_sections"
|
|
self._genre_section_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _get_genre_section_ttl(self) -> int:
|
|
if self._preferences_service:
|
|
try:
|
|
adv = self._preferences_service.get_advanced_settings()
|
|
return getattr(adv, "genre_section_ttl", GENRE_SECTION_TTL_DEFAULT)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return GENRE_SECTION_TTL_DEFAULT
|
|
|
|
async def get_cached_genre_section(
|
|
self, source_key: str
|
|
) -> tuple[dict[str, str | None], dict[str, str | None]] | None:
|
|
cache_key = f"{GENRE_SECTION_PREFIX}{source_key}"
|
|
ttl = self._get_genre_section_ttl()
|
|
|
|
if self._memory_cache:
|
|
cached = await self._memory_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
if self._genre_section_dir:
|
|
file_path = self._genre_section_dir / f"{source_key}.json"
|
|
try:
|
|
if file_path.exists():
|
|
data = json.loads(file_path.read_text())
|
|
built_at = data.get("built_at", 0)
|
|
if time.time() - built_at < ttl:
|
|
result = (data["genre_artists"], data["genre_artist_images"])
|
|
if self._memory_cache:
|
|
remaining = max(1, int(ttl - (time.time() - built_at)))
|
|
await self._memory_cache.set(cache_key, result, remaining)
|
|
return result
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
return None
|
|
|
|
async def save_genre_section(
|
|
self,
|
|
source_key: str,
|
|
genre_artists: dict[str, str | None],
|
|
genre_artist_images: dict[str, str | None],
|
|
) -> None:
|
|
cache_key = f"{GENRE_SECTION_PREFIX}{source_key}"
|
|
ttl = self._get_genre_section_ttl()
|
|
result = (genre_artists, genre_artist_images)
|
|
|
|
if self._memory_cache:
|
|
await self._memory_cache.set(cache_key, result, ttl)
|
|
|
|
if self._genre_section_dir:
|
|
file_path = self._genre_section_dir / f"{source_key}.json"
|
|
try:
|
|
payload = json.dumps({
|
|
"genre_artists": genre_artists,
|
|
"genre_artist_images": genre_artist_images,
|
|
"built_at": time.time(),
|
|
})
|
|
file_path.write_text(payload)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("Failed to write genre section to disk for %s", source_key)
|
|
|
|
async def build_and_cache_genre_section(
|
|
self, source_key: str, genre_names: list[str]
|
|
) -> None:
|
|
if source_key not in self._genre_build_locks:
|
|
self._genre_build_locks[source_key] = asyncio.Lock()
|
|
lock = self._genre_build_locks[source_key]
|
|
if lock.locked():
|
|
return
|
|
async with lock:
|
|
try:
|
|
genre_artists = await self.get_genre_artists_batch(genre_names)
|
|
genre_artist_images = await self.resolve_genre_artist_images(genre_artists)
|
|
await self.save_genre_section(source_key, genre_artists, genre_artist_images)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Genre section build failed for source=%s: %s", source_key, exc)
|
|
|
|
async def get_genre_artist(
|
|
self, genre_name: str, exclude_mbids: set[str] | None = None
|
|
) -> str | None:
|
|
cache_key = f"{GENRE_ARTIST_PREFIX}{genre_name.lower()}"
|
|
|
|
if self._memory_cache and not exclude_mbids:
|
|
cached = await self._memory_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached if cached != "" else None
|
|
|
|
try:
|
|
artists = await self._mb_repo.search_artists_by_tag(genre_name, limit=10)
|
|
for artist in artists:
|
|
if not artist.musicbrainz_id or artist.musicbrainz_id == VARIOUS_ARTISTS_MBID:
|
|
continue
|
|
if exclude_mbids and artist.musicbrainz_id in exclude_mbids:
|
|
continue
|
|
if self._memory_cache and not exclude_mbids:
|
|
await self._memory_cache.set(cache_key, artist.musicbrainz_id, GENRE_CACHE_TTL)
|
|
return artist.musicbrainz_id
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(f"Failed to fetch artist for genre '{genre_name}': {e}")
|
|
|
|
if self._memory_cache and not exclude_mbids:
|
|
await self._memory_cache.set(cache_key, "", GENRE_CACHE_TTL)
|
|
|
|
return None
|
|
|
|
async def get_genre_artists_batch(self, genres: list[str]) -> dict[str, str | None]:
|
|
if not genres:
|
|
return {}
|
|
capped = genres[:20]
|
|
|
|
raw_results = await asyncio.gather(
|
|
*(self.get_genre_artist(genre) for genre in capped)
|
|
)
|
|
|
|
used_mbids: set[str] = set()
|
|
results: dict[str, str | None] = {}
|
|
for genre, mbid in zip(capped, raw_results):
|
|
if mbid and mbid not in used_mbids:
|
|
results[genre] = mbid
|
|
used_mbids.add(mbid)
|
|
elif mbid and mbid in used_mbids:
|
|
alt = await self.get_genre_artist(genre, exclude_mbids=used_mbids)
|
|
results[genre] = alt
|
|
if alt:
|
|
used_mbids.add(alt)
|
|
else:
|
|
results[genre] = None
|
|
return results
|
|
|
|
def clear_disk_cache(self) -> int:
|
|
"""Delete all genre section JSON files from disk."""
|
|
if not self._genre_section_dir or not self._genre_section_dir.exists():
|
|
return 0
|
|
count = 0
|
|
for f in self._genre_section_dir.glob("*.json"):
|
|
f.unlink(missing_ok=True)
|
|
count += 1
|
|
return count
|
|
|
|
async def resolve_genre_artist_images(
|
|
self, genre_artists: dict[str, str | None]
|
|
) -> dict[str, str | None]:
|
|
if not self._audiodb_image_service or not genre_artists:
|
|
return {}
|
|
|
|
sem = asyncio.Semaphore(5)
|
|
|
|
async def _resolve_one(genre: str, mbid: str) -> tuple[str, str | None]:
|
|
async with sem:
|
|
try:
|
|
images = await self._audiodb_image_service.fetch_and_cache_artist_images(mbid)
|
|
if images and not images.is_negative:
|
|
url = images.wide_thumb_url or images.banner_url or images.fanart_url
|
|
if url:
|
|
return (genre, url)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("Failed to resolve genre image for %s: %s", genre, exc)
|
|
return (genre, None)
|
|
|
|
tasks = [
|
|
_resolve_one(genre, mbid)
|
|
for genre, mbid in genre_artists.items()
|
|
if mbid
|
|
]
|
|
if not tasks:
|
|
return {}
|
|
results = await asyncio.gather(*tasks)
|
|
return {genre: url for genre, url in results if url}
|