Files
musicseerr/backend/services/home/genre_service.py
T
Harvey a69a26852e Cut down unnecessary logging (#48)
* Cut down unnecessary logging

* fix format etc

* fix checks

* fix tests
2026-04-14 00:02:38 +01:00

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}