a69a26852e
* Cut down unnecessary logging * fix format etc * fix checks * fix tests
395 lines
14 KiB
Python
395 lines
14 KiB
Python
import logging
|
|
from typing import Any
|
|
|
|
import msgspec
|
|
|
|
from models.search import SearchResult
|
|
from services.preferences_service import PreferencesService
|
|
from infrastructure.cache.memory_cache import CacheInterface
|
|
from infrastructure.cache.cache_keys import (
|
|
mb_album_search_key,
|
|
mb_release_group_key,
|
|
mb_release_key,
|
|
MB_RG_BY_TAG_PREFIX,
|
|
MB_RG_DETAIL_PREFIX,
|
|
MB_RELEASE_DETAIL_PREFIX,
|
|
MB_RELEASE_TO_RG_PREFIX,
|
|
MB_RELEASE_REC_PREFIX,
|
|
MB_RECORDING_PREFIX,
|
|
)
|
|
from infrastructure.queue.priority_queue import RequestPriority
|
|
from repositories.musicbrainz_base import (
|
|
mb_api_get,
|
|
mb_deduplicator,
|
|
dedupe_by_id,
|
|
get_score,
|
|
should_include_release,
|
|
extract_artist_name,
|
|
parse_year,
|
|
build_musicbrainz_tag_query,
|
|
)
|
|
from infrastructure.degradation import try_get_degradation_context
|
|
from infrastructure.integration_result import IntegrationResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _record_mb_degradation(msg: str) -> None:
|
|
ctx = try_get_degradation_context()
|
|
if ctx:
|
|
ctx.record(IntegrationResult.error(source="musicbrainz", msg=msg))
|
|
|
|
|
|
class _ReleaseGroupSearchPayload(msgspec.Struct):
|
|
release_groups: list[dict[str, Any]] = msgspec.field(name="release-groups", default_factory=list)
|
|
|
|
|
|
class _ReleaseLookupPayload(msgspec.Struct):
|
|
release_group: dict[str, Any] = msgspec.field(name="release-group", default_factory=dict)
|
|
media: list[dict[str, Any]] = msgspec.field(default_factory=list)
|
|
|
|
|
|
def _rg_priority(rg: dict) -> int:
|
|
rg_type = rg.get("primary-type", "")
|
|
priority = 0
|
|
if rg_type == "Album":
|
|
priority = 3
|
|
elif rg_type == "EP":
|
|
priority = 2
|
|
elif rg_type == "Single":
|
|
priority = 1
|
|
secondary = rg.get("secondary-types", [])
|
|
if secondary:
|
|
priority = max(0, priority - 1)
|
|
return priority
|
|
|
|
|
|
def _pick_best_release_group(releases: list[dict]) -> tuple[str, str] | None:
|
|
candidates: list[tuple[str, str, int]] = []
|
|
for release in releases:
|
|
rg = release.get("release-group", {})
|
|
rg_id = rg.get("id")
|
|
rg_title = rg.get("title", "")
|
|
if rg_id:
|
|
candidates.append((rg_id, rg_title, _rg_priority(rg)))
|
|
if not candidates:
|
|
return None
|
|
candidates.sort(key=lambda c: c[2], reverse=True)
|
|
return (candidates[0][0], candidates[0][1])
|
|
|
|
|
|
class MusicBrainzAlbumMixin:
|
|
_cache: CacheInterface
|
|
_preferences_service: PreferencesService
|
|
|
|
def _map_release_group_to_result(
|
|
self,
|
|
rg: dict[str, Any],
|
|
included_secondary_types: set[str] | None = None
|
|
) -> SearchResult | None:
|
|
if not should_include_release(rg, included_secondary_types):
|
|
return None
|
|
|
|
primary_type = rg.get("primary-type", "")
|
|
secondary_types = rg.get("secondary-types", [])
|
|
if secondary_types:
|
|
type_info = f"{primary_type} + {', '.join(secondary_types)}"
|
|
else:
|
|
type_info = primary_type or None
|
|
|
|
return SearchResult(
|
|
type="album",
|
|
title=rg.get("title", "Unknown Album"),
|
|
artist=extract_artist_name(rg),
|
|
year=parse_year(rg.get("first-release-date")),
|
|
musicbrainz_id=rg.get("id", ""),
|
|
in_library=False,
|
|
type_info=type_info,
|
|
disambiguation=rg.get("disambiguation") or None,
|
|
score=get_score(rg),
|
|
)
|
|
|
|
async def search_albums(
|
|
self,
|
|
query: str,
|
|
limit: int = 10,
|
|
offset: int = 0,
|
|
included_secondary_types: set[str] | None = None
|
|
) -> list[SearchResult]:
|
|
cache_key = mb_album_search_key(query, limit, offset, included_secondary_types)
|
|
|
|
cached = await self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
internal_limit = min(100, max(int(limit * 1.5), 25))
|
|
|
|
result = await mb_api_get(
|
|
"/release-group",
|
|
params={
|
|
"query": f'releasegroup:"{query}"^3 OR release:"{query}"^2 OR {query}',
|
|
"limit": internal_limit,
|
|
"offset": offset,
|
|
},
|
|
priority=RequestPriority.USER_INITIATED,
|
|
decode_type=_ReleaseGroupSearchPayload,
|
|
)
|
|
release_groups = result.release_groups
|
|
release_groups = dedupe_by_id(release_groups)
|
|
|
|
results = []
|
|
for rg in release_groups:
|
|
mapped = self._map_release_group_to_result(rg, included_secondary_types)
|
|
if mapped:
|
|
results.append(mapped)
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
advanced_settings = self._preferences_service.get_advanced_settings()
|
|
await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search)
|
|
return results
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"MusicBrainz album search failed: {e}")
|
|
_record_mb_degradation(f"album search failed: {e}")
|
|
return []
|
|
|
|
async def search_release_groups_by_tag(
|
|
self,
|
|
tag: str,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
included_secondary_types: set[str] | None = None
|
|
) -> list[SearchResult]:
|
|
cache_key = f"{MB_RG_BY_TAG_PREFIX}{tag.lower()}:{limit}:{offset}"
|
|
|
|
cached = await self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
internal_limit = min(100, max(int(limit * 1.5), 25))
|
|
|
|
result = await mb_api_get(
|
|
"/release-group",
|
|
params={
|
|
"query": build_musicbrainz_tag_query(tag),
|
|
"limit": internal_limit,
|
|
"offset": offset,
|
|
},
|
|
priority=RequestPriority.BACKGROUND_SYNC,
|
|
decode_type=_ReleaseGroupSearchPayload,
|
|
)
|
|
release_groups = result.release_groups
|
|
release_groups = dedupe_by_id(release_groups)
|
|
|
|
results = []
|
|
for rg in release_groups:
|
|
mapped = self._map_release_group_to_result(rg, included_secondary_types)
|
|
if mapped:
|
|
results.append(mapped)
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
advanced_settings = self._preferences_service.get_advanced_settings()
|
|
await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search * 2)
|
|
return results
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"MusicBrainz release group tag search failed for '{tag}': {e}")
|
|
_record_mb_degradation(f"release group tag search failed: {e}")
|
|
return []
|
|
|
|
async def get_release_group_by_id(
|
|
self,
|
|
mbid: str,
|
|
includes: list[str] | None = None,
|
|
priority: RequestPriority = RequestPriority.USER_INITIATED,
|
|
) -> dict | None:
|
|
if includes is None:
|
|
includes = ["artist-credits", "releases"]
|
|
|
|
cache_key = mb_release_group_key(mbid, includes)
|
|
|
|
cached = await self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
includes_str = "+".join(sorted(includes))
|
|
dedupe_key = f"{MB_RG_DETAIL_PREFIX}{mbid}:{includes_str}"
|
|
return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_release_group_by_id(mbid, includes, cache_key, priority))
|
|
|
|
async def _fetch_release_group_by_id(
|
|
self,
|
|
mbid: str,
|
|
includes: list[str],
|
|
cache_key: str,
|
|
priority: RequestPriority = RequestPriority.USER_INITIATED,
|
|
) -> dict | None:
|
|
try:
|
|
inc_str = "+".join(sorted(includes))
|
|
result = await mb_api_get(
|
|
f"/release-group/{mbid}",
|
|
params={"inc": inc_str},
|
|
priority=priority,
|
|
)
|
|
if not result:
|
|
return None
|
|
await self._cache.set(cache_key, result, ttl_seconds=3600)
|
|
return result
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch release group {mbid}: {e}")
|
|
_record_mb_degradation(f"release group fetch failed: {e}")
|
|
return None
|
|
|
|
async def get_release_by_id(
|
|
self,
|
|
release_id: str,
|
|
includes: list[str] | None = None
|
|
) -> dict | None:
|
|
if includes is None:
|
|
includes = ["recordings", "labels"]
|
|
|
|
cache_key = mb_release_key(release_id, includes)
|
|
|
|
cached = await self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
includes_str = "+".join(sorted(includes))
|
|
dedupe_key = f"{MB_RELEASE_DETAIL_PREFIX}{release_id}:{includes_str}"
|
|
return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_release_by_id(release_id, includes, cache_key))
|
|
|
|
async def _fetch_release_by_id(
|
|
self,
|
|
release_id: str,
|
|
includes: list[str],
|
|
cache_key: str
|
|
) -> dict | None:
|
|
try:
|
|
inc_str = "+".join(sorted(includes))
|
|
result = await mb_api_get(
|
|
f"/release/{release_id}",
|
|
params={"inc": inc_str},
|
|
priority=RequestPriority.USER_INITIATED,
|
|
)
|
|
if not result:
|
|
return None
|
|
await self._cache.set(cache_key, result, ttl_seconds=3600)
|
|
return result
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch release {release_id}: {e}")
|
|
_record_mb_degradation(f"release fetch failed: {e}")
|
|
return None
|
|
|
|
async def get_release_group_id_from_release(
|
|
self,
|
|
release_id: str
|
|
) -> str | None:
|
|
cache_key = f"{MB_RELEASE_TO_RG_PREFIX}{release_id}"
|
|
cached = await self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached if cached != "" else None
|
|
|
|
dedupe_key = f"{MB_RELEASE_TO_RG_PREFIX}{release_id}"
|
|
return await mb_deduplicator.dedupe(
|
|
dedupe_key,
|
|
lambda: self._fetch_release_group_id_from_release(release_id, cache_key),
|
|
)
|
|
|
|
async def _fetch_release_group_id_from_release(
|
|
self,
|
|
release_id: str,
|
|
cache_key: str,
|
|
) -> str | None:
|
|
try:
|
|
result = await mb_api_get(
|
|
f"/release/{release_id}",
|
|
params={"inc": "release-groups+recordings"},
|
|
priority=RequestPriority.BACKGROUND_SYNC,
|
|
decode_type=_ReleaseLookupPayload,
|
|
)
|
|
rg = result.release_group
|
|
rg_id = rg.get("id")
|
|
await self._cache.set(cache_key, rg_id or "", ttl_seconds=86400)
|
|
|
|
positions: dict[str, list[int]] = {}
|
|
for medium in result.media:
|
|
disc = medium.get("position", 1)
|
|
for track in medium.get("tracks", medium.get("track-list", [])):
|
|
rec = track.get("recording", {})
|
|
rec_id = rec.get("id")
|
|
trk_pos = track.get("position")
|
|
if rec_id and trk_pos is not None:
|
|
positions[rec_id] = [disc, trk_pos]
|
|
if positions:
|
|
pos_cache_key = f"{MB_RELEASE_REC_PREFIX}{release_id}"
|
|
await self._cache.set(pos_cache_key, positions, ttl_seconds=86400)
|
|
|
|
return rg_id
|
|
except Exception as e: # noqa: BLE001
|
|
_record_mb_degradation(f"release-to-rg lookup failed: {e}")
|
|
await self._cache.set(cache_key, "", ttl_seconds=3600)
|
|
return None
|
|
|
|
async def get_recording_position_on_release(
|
|
self,
|
|
release_id: str,
|
|
recording_mbid: str,
|
|
) -> tuple[int, int] | None:
|
|
pos_cache_key = f"{MB_RELEASE_REC_PREFIX}{release_id}"
|
|
positions = await self._cache.get(pos_cache_key)
|
|
if positions and recording_mbid in positions:
|
|
disc, track = positions[recording_mbid]
|
|
return (disc, track)
|
|
return None
|
|
|
|
@staticmethod
|
|
def extract_youtube_url_from_relations(entity_data: dict) -> str | None:
|
|
for rel in entity_data.get("relations", []):
|
|
url_obj = rel.get("url", {})
|
|
url = url_obj.get("resource", "") if isinstance(url_obj, dict) else ""
|
|
if "youtube.com" in url or "youtu.be" in url:
|
|
return url
|
|
return None
|
|
|
|
@staticmethod
|
|
def youtube_url_to_embed(url: str) -> str | None:
|
|
import re
|
|
patterns = [
|
|
r"youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})",
|
|
r"youtu\.be/([a-zA-Z0-9_-]{11})",
|
|
r"youtube\.com/embed/([a-zA-Z0-9_-]{11})",
|
|
]
|
|
for pattern in patterns:
|
|
match = re.search(pattern, url)
|
|
if match:
|
|
return f"https://www.youtube.com/embed/{match.group(1)}"
|
|
return None
|
|
|
|
async def get_recording_by_id(
|
|
self,
|
|
recording_id: str,
|
|
includes: list[str] | None = None,
|
|
) -> dict | None:
|
|
if includes is None:
|
|
includes = ["url-rels"]
|
|
inc_str = "+".join(sorted(includes))
|
|
cache_key = f"{MB_RECORDING_PREFIX}{recording_id}:{inc_str}"
|
|
cached = await self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
result = await mb_api_get(
|
|
f"/recording/{recording_id}",
|
|
params={"inc": inc_str},
|
|
priority=RequestPriority.BACKGROUND_SYNC,
|
|
)
|
|
if not result:
|
|
return None
|
|
await self._cache.set(cache_key, result, ttl_seconds=3600)
|
|
return result
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(f"Failed to fetch recording {recording_id}: {e}")
|
|
_record_mb_degradation(f"recording fetch failed: {e}")
|
|
return None
|