Checkpoint metadata runtime cleanup
- remove runtime from metadata helper APIs where it only carried config, logger, mutagen, and database access - keep runtime only for the source-ID enrichment path that still needs live worker handles - add the new metadata helper modules and update the tests to match the slimmer interfaces
This commit is contained in:
parent
6872e5080d
commit
edd9048f86
9 changed files with 3535 additions and 1782 deletions
412
core/import_resolution.py
Normal file
412
core/import_resolution.py
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
"""Single-track import lookup and context-building helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("import_resolution")
|
||||
|
||||
|
||||
def _get_metadata_service():
|
||||
from core import metadata_service
|
||||
|
||||
return metadata_service
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]:
|
||||
if not artists:
|
||||
return []
|
||||
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
name = _extract_lookup_value(artist, 'name', 'artist_name', 'title', default='') or ''
|
||||
artist_id = _extract_lookup_value(artist, 'id', 'artist_id', default='') or ''
|
||||
entry: Dict[str, Any] = {}
|
||||
if name:
|
||||
entry['name'] = str(name)
|
||||
if artist_id:
|
||||
entry['id'] = str(artist_id)
|
||||
genres = _extract_lookup_value(artist, 'genres', default=None)
|
||||
if genres is not None:
|
||||
entry['genres'] = genres
|
||||
if entry:
|
||||
normalized.append(entry)
|
||||
continue
|
||||
|
||||
name = str(artist).strip()
|
||||
if name:
|
||||
normalized.append({'name': name})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(
|
||||
source_override: Optional[str] = None,
|
||||
allow_fallback: bool = True,
|
||||
) -> List[str]:
|
||||
metadata_service = _get_metadata_service()
|
||||
primary_source = metadata_service.get_primary_source()
|
||||
source_chain = list(metadata_service.get_source_priority(primary_source))
|
||||
override = (source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _build_track_search_query(source: str, title: str, artist: str) -> str:
|
||||
base_query = " ".join(part for part in (title, artist) if part).strip()
|
||||
if source == 'deezer' and title:
|
||||
if artist:
|
||||
return f'artist:"{artist}" track:"{title}"'
|
||||
return f'track:"{title}"'
|
||||
return base_query or title or artist
|
||||
|
||||
|
||||
def _pick_best_track_match(search_results: List[Any], title: str, artist: str = '') -> Optional[Any]:
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
target_title = str(title or '').strip().lower()
|
||||
target_artist = str(artist or '').strip().lower()
|
||||
|
||||
for candidate in search_results:
|
||||
candidate_title = str(_extract_lookup_value(candidate, 'name', 'title', 'track_name', default='') or '').strip().lower()
|
||||
if candidate_title != target_title:
|
||||
continue
|
||||
|
||||
if not target_artist:
|
||||
return candidate
|
||||
|
||||
candidate_artists = _normalize_context_artists(_extract_lookup_value(candidate, 'artists', default=[]))
|
||||
candidate_artist_name = candidate_artists[0]['name'].strip().lower() if candidate_artists else ''
|
||||
if candidate_artist_name == target_artist:
|
||||
return candidate
|
||||
|
||||
return search_results[0]
|
||||
|
||||
|
||||
def _search_tracks_for_source(source: str, client: Any, query: str, limit: int = 1) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_tracks(query, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _build_single_import_context_payload(
|
||||
track_data: Any,
|
||||
source: Optional[str],
|
||||
source_priority: List[str],
|
||||
requested_title: str = '',
|
||||
requested_artist: str = '',
|
||||
) -> Dict[str, Any]:
|
||||
album_data = _extract_lookup_value(track_data, 'album', default=None)
|
||||
|
||||
track_id = str(_extract_lookup_value(track_data, 'id', 'track_id', 'trackId', default='') or '')
|
||||
track_name = _extract_lookup_value(track_data, 'name', 'title', 'trackName', default='') or requested_title or 'Unknown Track'
|
||||
track_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'artists', default=[]))
|
||||
if not track_artists and requested_artist:
|
||||
track_artists = [{'name': requested_artist}]
|
||||
|
||||
primary_track_artist = track_artists[0] if track_artists else {}
|
||||
primary_artist_name = primary_track_artist.get('name') or requested_artist or 'Unknown Artist'
|
||||
primary_artist_id = str(primary_track_artist.get('id', '') or _extract_lookup_value(track_data, 'artist_id', 'artistId', default='') or '')
|
||||
|
||||
album_name = _extract_lookup_value(track_data, 'album_name', 'collectionName', default='') or ''
|
||||
album_id = str(_extract_lookup_value(track_data, 'album_id', 'collectionId', 'albumId', default='') or '')
|
||||
release_date = str(_extract_lookup_value(track_data, 'release_date', default='') or '')
|
||||
album_type = str(_extract_lookup_value(track_data, 'album_type', default='album') or 'album')
|
||||
total_tracks = int(_extract_lookup_value(track_data, 'total_tracks', 'track_count', default=0) or 0)
|
||||
album_images: List[Dict[str, Any]] = []
|
||||
album_image_url = str(_extract_lookup_value(track_data, 'image_url', 'thumb_url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'album_artists', 'artists', default=[]))
|
||||
|
||||
if isinstance(album_data, dict):
|
||||
album_name = _extract_lookup_value(album_data, 'name', 'title', 'collectionName', default=album_name) or album_name
|
||||
album_id = str(_extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', default=album_id) or album_id)
|
||||
release_date = str(_extract_lookup_value(album_data, 'release_date', default=release_date) or release_date)
|
||||
album_type = str(_extract_lookup_value(album_data, 'album_type', default=album_type) or album_type)
|
||||
total_tracks = int(_extract_lookup_value(album_data, 'total_tracks', 'track_count', 'nb_tracks', default=total_tracks) or total_tracks)
|
||||
album_images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not album_image_url:
|
||||
album_image_url = str(_extract_lookup_value(album_data, 'image_url', 'thumb_url', default='') or '')
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(album_data, 'artists', default=[]))
|
||||
elif album_data:
|
||||
album_name = album_name or str(album_data)
|
||||
|
||||
if not album_artists and primary_artist_name:
|
||||
album_artists = [{'name': primary_artist_name}]
|
||||
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
|
||||
track_info = {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'track_number': int(_extract_lookup_value(track_data, 'track_number', 'trackNumber', default=1) or 1),
|
||||
'disc_number': int(_extract_lookup_value(track_data, 'disc_number', 'discNumber', default=1) or 1),
|
||||
'duration_ms': int(_extract_lookup_value(track_data, 'duration_ms', 'duration', 'trackTimeMillis', default=0) or 0),
|
||||
'artists': track_artists or [{'name': primary_artist_name}],
|
||||
'uri': str(_extract_lookup_value(track_data, 'uri', default='') or ''),
|
||||
'album': album_name,
|
||||
'album_id': album_id,
|
||||
'album_type': album_type,
|
||||
'release_date': release_date,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
album_payload = {
|
||||
'id': album_id,
|
||||
'name': album_name,
|
||||
'release_date': release_date,
|
||||
'total_tracks': total_tracks or 1,
|
||||
'album_type': album_type,
|
||||
'image_url': album_image_url,
|
||||
'images': album_images,
|
||||
'artists': album_artists,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
artist_payload = {
|
||||
'id': primary_artist_id,
|
||||
'name': primary_artist_name,
|
||||
'genres': [],
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
original_search = {
|
||||
'title': track_name,
|
||||
'artist': primary_artist_name,
|
||||
'album': album_name,
|
||||
'track_number': track_info['track_number'],
|
||||
'disc_number': track_info['disc_number'],
|
||||
'clean_title': track_name,
|
||||
'clean_album': album_name,
|
||||
'clean_artist': primary_artist_name,
|
||||
'artists': track_info['artists'],
|
||||
'duration_ms': track_info['duration_ms'],
|
||||
'id': track_id,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
return {
|
||||
'success': bool(track_id or track_name != requested_title or album_name),
|
||||
'source': source,
|
||||
'source_priority': source_priority,
|
||||
'context': {
|
||||
'artist': artist_payload,
|
||||
'album': album_payload,
|
||||
'track_info': track_info,
|
||||
'original_search_result': original_search,
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': bool(track_id),
|
||||
'has_full_metadata': bool(track_id),
|
||||
'source': source,
|
||||
'source_priority': source_priority,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_single_import_fallback_context(
|
||||
requested_title: str,
|
||||
requested_artist: str,
|
||||
source_priority: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
artist_name = requested_artist or 'Unknown Artist'
|
||||
title = requested_title or 'Unknown Track'
|
||||
return {
|
||||
'success': False,
|
||||
'source': None,
|
||||
'source_priority': source_priority,
|
||||
'context': {
|
||||
'artist': {
|
||||
'id': '',
|
||||
'name': artist_name,
|
||||
'genres': [],
|
||||
'_source': '',
|
||||
},
|
||||
'album': {
|
||||
'id': '',
|
||||
'name': '',
|
||||
'release_date': '',
|
||||
'total_tracks': 1,
|
||||
'album_type': 'album',
|
||||
'image_url': '',
|
||||
'images': [],
|
||||
'artists': [],
|
||||
'_source': '',
|
||||
},
|
||||
'track_info': {
|
||||
'id': '',
|
||||
'name': title,
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'duration_ms': 0,
|
||||
'artists': [{'name': artist_name}],
|
||||
'uri': '',
|
||||
'album': '',
|
||||
'album_id': '',
|
||||
'album_type': 'album',
|
||||
'release_date': '',
|
||||
'_source': '',
|
||||
},
|
||||
'original_search_result': {
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': '',
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'clean_title': title,
|
||||
'clean_album': '',
|
||||
'clean_artist': artist_name,
|
||||
'artists': [{'name': artist_name}],
|
||||
'duration_ms': 0,
|
||||
'id': '',
|
||||
'_source': '',
|
||||
},
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': False,
|
||||
'has_full_metadata': False,
|
||||
'source': None,
|
||||
'source_priority': source_priority,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_single_track_import_context(
|
||||
title: str,
|
||||
artist: str = '',
|
||||
override_id: Optional[str] = None,
|
||||
override_source: str = 'spotify',
|
||||
source_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build an import context for singles using source-priority metadata lookup."""
|
||||
metadata_service = _get_metadata_service()
|
||||
source_priority = _get_source_chain_for_lookup(source_override=source_override, allow_fallback=True)
|
||||
title = (title or '').strip()
|
||||
artist = (artist or '').strip()
|
||||
|
||||
if override_id:
|
||||
chosen_source = (override_source or 'spotify').strip().lower() or 'spotify'
|
||||
client = metadata_service.get_client_for_source(chosen_source)
|
||||
if client and hasattr(client, 'get_track_details'):
|
||||
try:
|
||||
track_data = client.get_track_details(str(override_id))
|
||||
if track_data:
|
||||
payload = _build_single_import_context_payload(
|
||||
track_data,
|
||||
chosen_source,
|
||||
source_priority,
|
||||
requested_title=title,
|
||||
requested_artist=artist,
|
||||
)
|
||||
if payload['context']['artist'].get('id') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
artist_details = client.get_artist(payload['context']['artist']['id'])
|
||||
if artist_details:
|
||||
payload['context']['artist']['genres'] = _extract_lookup_value(
|
||||
artist_details,
|
||||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Override track lookup failed on %s for %s: %s", chosen_source, override_id, exc)
|
||||
|
||||
for source in source_priority:
|
||||
client = metadata_service.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
search_query = _build_track_search_query(source, title, artist)
|
||||
if not search_query:
|
||||
continue
|
||||
|
||||
search_results = _search_tracks_for_source(source, client, search_query, limit=5)
|
||||
if not search_results and search_query != title:
|
||||
search_results = _search_tracks_for_source(source, client, title, limit=5)
|
||||
if not search_results and artist and search_query != artist:
|
||||
search_results = _search_tracks_for_source(source, client, artist, limit=5)
|
||||
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
best_match = _pick_best_track_match(search_results, title or search_query, artist)
|
||||
if not best_match:
|
||||
continue
|
||||
|
||||
resolved_track_id = str(_extract_lookup_value(best_match, 'id', 'track_id', 'trackId', default='') or '')
|
||||
resolved_data = best_match
|
||||
if resolved_track_id and hasattr(client, 'get_track_details'):
|
||||
try:
|
||||
detailed = client.get_track_details(resolved_track_id)
|
||||
if detailed:
|
||||
resolved_data = detailed
|
||||
except Exception as exc:
|
||||
logger.debug("Track detail lookup failed on %s for %s: %s", source, resolved_track_id, exc)
|
||||
|
||||
payload = _build_single_import_context_payload(
|
||||
resolved_data,
|
||||
source,
|
||||
source_priority,
|
||||
requested_title=title,
|
||||
requested_artist=artist,
|
||||
)
|
||||
if payload['context']['artist'].get('id') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
artist_details = client.get_artist(payload['context']['artist']['id'])
|
||||
if artist_details:
|
||||
payload['context']['artist']['genres'] = _extract_lookup_value(
|
||||
artist_details,
|
||||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
|
||||
return _build_single_import_fallback_context(title, artist, source_priority)
|
||||
151
core/metadata_artwork.py
Normal file
151
core/metadata_artwork.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Album artwork helpers for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
from core.import_context import get_import_context_album
|
||||
from core.metadata_common import (
|
||||
_get_config_manager,
|
||||
_get_image_dimensions,
|
||||
_get_logger,
|
||||
_get_mutagen_symbols,
|
||||
)
|
||||
|
||||
|
||||
def embed_album_art_metadata(audio_file, metadata: dict):
|
||||
cfg = _get_config_manager()
|
||||
logger_ = _get_logger()
|
||||
symbols = _get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
try:
|
||||
image_data = None
|
||||
mime_type = None
|
||||
|
||||
release_mbid = metadata.get("musicbrainz_release_id")
|
||||
if release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
|
||||
try:
|
||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front"
|
||||
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
mime_type = response.info().get_content_type() or "image/jpeg"
|
||||
if not image_data or len(image_data) <= 1000:
|
||||
image_data = None
|
||||
except Exception:
|
||||
image_data = None
|
||||
|
||||
if not image_data:
|
||||
art_url = metadata.get("album_art_url")
|
||||
if not art_url:
|
||||
logger_.warning("No album art URL available for embedding.")
|
||||
return
|
||||
with urllib.request.urlopen(art_url, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
mime_type = response.info().get_content_type() or "image/jpeg"
|
||||
|
||||
if not image_data:
|
||||
logger_.error("Failed to download album art data.")
|
||||
return
|
||||
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.APIC(encoding=3, mime=mime_type, type=3, desc="Cover", data=image_data))
|
||||
elif isinstance(audio_file, symbols.FLAC):
|
||||
picture = symbols.Picture()
|
||||
picture.data = image_data
|
||||
picture.type = 3
|
||||
picture.mime = mime_type
|
||||
width, height = _get_image_dimensions(image_data)
|
||||
picture.width = width or 640
|
||||
picture.height = height or 640
|
||||
picture.depth = 24
|
||||
audio_file.add_picture(picture)
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
fmt = symbols.MP4Cover.FORMAT_JPEG if "jpeg" in mime_type else symbols.MP4Cover.FORMAT_PNG
|
||||
audio_file["covr"] = [symbols.MP4Cover(image_data, imageformat=fmt)]
|
||||
|
||||
logger_.info("Album art successfully embedded.")
|
||||
except Exception as exc:
|
||||
logger_.error("Error embedding album art: %s", exc)
|
||||
|
||||
|
||||
def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
|
||||
cfg = _get_config_manager()
|
||||
logger_ = _get_logger()
|
||||
if cfg.get("metadata_enhancement.cover_art_download", True) is False:
|
||||
return
|
||||
|
||||
try:
|
||||
cover_path = os.path.join(target_dir, "cover.jpg")
|
||||
album_info = album_info or {}
|
||||
release_mbid = album_info.get("musicbrainz_release_id")
|
||||
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False)
|
||||
|
||||
if os.path.exists(cover_path):
|
||||
if release_mbid and prefer_caa:
|
||||
try:
|
||||
existing_size = os.path.getsize(cover_path)
|
||||
if existing_size > 200_000:
|
||||
return
|
||||
is_upgrade = True
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
is_upgrade = False
|
||||
|
||||
image_data = None
|
||||
if release_mbid and prefer_caa:
|
||||
try:
|
||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front"
|
||||
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
if not image_data or len(image_data) <= 1000:
|
||||
image_data = None
|
||||
except Exception:
|
||||
image_data = None
|
||||
|
||||
if is_upgrade and not image_data:
|
||||
logger_.error("CAA upgrade failed - keeping existing cover.jpg")
|
||||
return
|
||||
|
||||
if not image_data:
|
||||
art_url = album_info.get("album_image_url")
|
||||
if not art_url and context:
|
||||
album_ctx = get_import_context_album(context)
|
||||
art_url = album_ctx.get("image_url")
|
||||
if not art_url and album_ctx.get("images"):
|
||||
images = album_ctx.get("images", [])
|
||||
if images and isinstance(images[0], dict):
|
||||
art_url = images[0].get("url", "")
|
||||
if art_url:
|
||||
logger_.info("Using cover art URL from album context")
|
||||
if art_url and "i.scdn.co" in art_url:
|
||||
try:
|
||||
from core.spotify_client import _upgrade_spotify_image_url
|
||||
|
||||
art_url = _upgrade_spotify_image_url(art_url)
|
||||
except Exception:
|
||||
pass
|
||||
elif art_url and "mzstatic.com" in art_url:
|
||||
art_url = re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
|
||||
if not art_url:
|
||||
logger_.warning("No cover art URL available for download.")
|
||||
return
|
||||
with urllib.request.urlopen(art_url, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
|
||||
if not image_data:
|
||||
return
|
||||
|
||||
with open(cover_path, "wb") as handle:
|
||||
handle.write(image_data)
|
||||
logger_.info("Cover art downloaded to: %s", cover_path)
|
||||
except Exception as exc:
|
||||
logger_.error("Error downloading cover.jpg: %s", exc)
|
||||
276
core/metadata_common.py
Normal file
276
core/metadata_common.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Shared low-level helpers for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("metadata_enrichment")
|
||||
|
||||
_FILE_LOCKS: Dict[str, threading.Lock] = {}
|
||||
_FILE_LOCKS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class _NullConfigManager:
|
||||
def get(self, _key: str, default: Any = None) -> Any:
|
||||
return default
|
||||
|
||||
|
||||
def _get_logger():
|
||||
return logger
|
||||
|
||||
|
||||
def _get_config_manager():
|
||||
try:
|
||||
from config.settings import config_manager as settings_config_manager
|
||||
|
||||
return settings_config_manager
|
||||
except Exception:
|
||||
return _NullConfigManager()
|
||||
|
||||
|
||||
def _get_database():
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
|
||||
return get_database()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_itunes_client():
|
||||
try:
|
||||
from core.metadata_service import get_itunes_client
|
||||
|
||||
return get_itunes_client()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_artist_name(artist: Any) -> str:
|
||||
if isinstance(artist, dict):
|
||||
return str(artist.get("name", "") or "")
|
||||
if hasattr(artist, "name"):
|
||||
return str(getattr(artist, "name") or "")
|
||||
return str(artist) if artist else ""
|
||||
|
||||
|
||||
def _get_mutagen_symbols():
|
||||
"""Lazy mutagen import so tests can monkeypatch this without the package installed."""
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.apev2 import APEv2, APENoHeaderError
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import (
|
||||
APIC,
|
||||
ID3,
|
||||
TBPM,
|
||||
TCOP,
|
||||
TDOR,
|
||||
TDRC,
|
||||
TCON,
|
||||
TIT2,
|
||||
TALB,
|
||||
TPE1,
|
||||
TPE2,
|
||||
TPOS,
|
||||
TPUB,
|
||||
TRCK,
|
||||
TSRC,
|
||||
TXXX,
|
||||
UFID,
|
||||
TMED,
|
||||
)
|
||||
from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
try:
|
||||
from mutagen.oggopus import OggOpus
|
||||
except Exception:
|
||||
OggOpus = None
|
||||
except Exception as exc:
|
||||
logger.debug("Mutagen unavailable for metadata enrichment: %s", exc)
|
||||
return None
|
||||
|
||||
return SimpleNamespace(
|
||||
File=MutagenFile,
|
||||
APEv2=APEv2,
|
||||
APENoHeaderError=APENoHeaderError,
|
||||
FLAC=FLAC,
|
||||
Picture=Picture,
|
||||
ID3=ID3,
|
||||
APIC=APIC,
|
||||
TBPM=TBPM,
|
||||
TCOP=TCOP,
|
||||
TDOR=TDOR,
|
||||
TDRC=TDRC,
|
||||
TCON=TCON,
|
||||
TIT2=TIT2,
|
||||
TALB=TALB,
|
||||
TPE1=TPE1,
|
||||
TPE2=TPE2,
|
||||
TPOS=TPOS,
|
||||
TPUB=TPUB,
|
||||
TRCK=TRCK,
|
||||
TSRC=TSRC,
|
||||
TXXX=TXXX,
|
||||
UFID=UFID,
|
||||
TMED=TMED,
|
||||
MP4=MP4,
|
||||
MP4Cover=MP4Cover,
|
||||
MP4FreeForm=MP4FreeForm,
|
||||
OggVorbis=OggVorbis,
|
||||
OggOpus=OggOpus,
|
||||
)
|
||||
|
||||
|
||||
def _get_file_lock(file_path: str) -> threading.Lock:
|
||||
with _FILE_LOCKS_LOCK:
|
||||
lock = _FILE_LOCKS.get(file_path)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_FILE_LOCKS[file_path] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _is_ogg_opus(audio_file: Any) -> bool:
|
||||
return type(audio_file).__name__ == "OggOpus"
|
||||
|
||||
|
||||
def _is_vorbis_like(audio_file: Any, symbols: Any) -> bool:
|
||||
vorbis_classes = tuple(
|
||||
cls for cls in (
|
||||
getattr(symbols, "FLAC", None),
|
||||
getattr(symbols, "OggVorbis", None),
|
||||
) if cls is not None
|
||||
)
|
||||
return bool(vorbis_classes) and isinstance(audio_file, vorbis_classes) or _is_ogg_opus(audio_file)
|
||||
|
||||
|
||||
def _save_audio_file(audio_file: Any, symbols: Any) -> None:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.save(v1=0, v2_version=4)
|
||||
elif isinstance(audio_file, symbols.FLAC):
|
||||
audio_file.save(deleteid3=True)
|
||||
else:
|
||||
audio_file.save()
|
||||
|
||||
|
||||
def _get_image_dimensions(data: bytes):
|
||||
try:
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
import struct
|
||||
|
||||
w, h = struct.unpack(">II", data[16:24])
|
||||
return w, h
|
||||
if data[:2] == b"\xff\xd8":
|
||||
import struct
|
||||
|
||||
i = 2
|
||||
while i < len(data) - 9:
|
||||
if data[i] != 0xFF:
|
||||
break
|
||||
marker = data[i + 1]
|
||||
if marker in (0xC0, 0xC2):
|
||||
h, w = struct.unpack(">HH", data[i + 5 : i + 9])
|
||||
return w, h
|
||||
length = struct.unpack(">H", data[i + 2 : i + 4])[0]
|
||||
i += 2 + length
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def _strip_all_non_audio_tags(file_path: str) -> dict:
|
||||
summary = {"apev2_stripped": False, "apev2_tag_count": 0}
|
||||
if os.path.splitext(file_path)[1].lower() != ".mp3":
|
||||
return summary
|
||||
|
||||
symbols = _get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return summary
|
||||
|
||||
try:
|
||||
apev2_tags = symbols.APEv2(file_path)
|
||||
tag_count = len(apev2_tags)
|
||||
tag_keys = list(apev2_tags.keys())
|
||||
apev2_tags.delete(file_path)
|
||||
summary["apev2_stripped"] = True
|
||||
summary["apev2_tag_count"] = tag_count
|
||||
logger.info("Stripped %s APEv2 tags: %s", tag_count, ", ".join(tag_keys[:10]))
|
||||
except symbols.APENoHeaderError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Could not strip APEv2 tags (non-fatal): %s", exc)
|
||||
return summary
|
||||
|
||||
|
||||
def _verify_metadata_written(file_path: str) -> bool:
|
||||
symbols = _get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return False
|
||||
|
||||
try:
|
||||
check = symbols.File(file_path)
|
||||
if check is None or check.tags is None:
|
||||
logger.info("[VERIFY] Tags are None after save: %s", file_path)
|
||||
return False
|
||||
|
||||
title_found = False
|
||||
artist_found = False
|
||||
if isinstance(check.tags, symbols.ID3):
|
||||
title_found = bool(check.tags.getall("TIT2"))
|
||||
artist_found = bool(check.tags.getall("TPE1"))
|
||||
try:
|
||||
symbols.APEv2(file_path)
|
||||
logger.info("[VERIFY] APEv2 tags still present after processing!")
|
||||
return False
|
||||
except symbols.APENoHeaderError:
|
||||
pass
|
||||
elif _is_vorbis_like(check, symbols):
|
||||
title_found = bool(check.get("title"))
|
||||
artist_found = bool(check.get("artist"))
|
||||
elif isinstance(check, symbols.MP4):
|
||||
title_found = bool(check.get("\xa9nam"))
|
||||
artist_found = bool(check.get("\xa9ART"))
|
||||
|
||||
if not title_found or not artist_found:
|
||||
logger.warning("[VERIFY] Missing metadata - title:%s artist:%s", title_found, artist_found)
|
||||
return False
|
||||
|
||||
logger.info("[VERIFY] Metadata verified OK")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("[VERIFY] Verification error (non-fatal): %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def wipe_source_tags(file_path: str) -> bool:
|
||||
try:
|
||||
_strip_all_non_audio_tags(file_path)
|
||||
symbols = _get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return False
|
||||
|
||||
audio = symbols.File(file_path)
|
||||
if audio is None:
|
||||
return False
|
||||
if hasattr(audio, "clear_pictures"):
|
||||
audio.clear_pictures()
|
||||
if audio.tags is not None:
|
||||
tag_count = len(audio.tags)
|
||||
audio.tags.clear()
|
||||
else:
|
||||
audio.add_tags()
|
||||
tag_count = 0
|
||||
_save_audio_file(audio, symbols)
|
||||
if tag_count > 0:
|
||||
logger.info("[Tag Wipe] Stripped %s source tags from: %s", tag_count, os.path.basename(file_path))
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("[Tag Wipe] Failed (non-fatal): %s", exc)
|
||||
return False
|
||||
File diff suppressed because it is too large
Load diff
63
core/metadata_lyrics.py
Normal file
63
core/metadata_lyrics.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Lyrics export helpers for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.import_context import (
|
||||
get_import_clean_album,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_original_search,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.metadata_common import _get_config_manager, _get_logger
|
||||
|
||||
|
||||
def generate_lrc_file(file_path: str, context: dict, artist: dict, album_info: dict) -> bool:
|
||||
cfg = _get_config_manager()
|
||||
logger_ = _get_logger()
|
||||
if cfg.get("metadata_enhancement.lrclib_enabled", True) is False:
|
||||
return False
|
||||
|
||||
try:
|
||||
from core.lyrics_client import lyrics_client
|
||||
|
||||
context = normalize_import_context(context)
|
||||
original_search = get_import_original_search(context)
|
||||
album_context = get_import_context_album(context)
|
||||
track_name = get_import_clean_title(context, default=original_search.get("title", "Unknown Track"))
|
||||
|
||||
if isinstance(artist, dict):
|
||||
artist_name = artist.get("name", "Unknown Artist")
|
||||
elif hasattr(artist, "name"):
|
||||
artist_name = artist.name
|
||||
else:
|
||||
artist_name = str(artist) if artist else "Unknown Artist"
|
||||
|
||||
album_name = None
|
||||
duration_seconds = None
|
||||
if album_info and album_info.get("is_album"):
|
||||
album_name = (
|
||||
get_import_clean_album(context, album_info=album_info, default="")
|
||||
or album_info.get("album_name")
|
||||
or album_context.get("name")
|
||||
)
|
||||
|
||||
if original_search.get("duration_ms"):
|
||||
duration_seconds = int(original_search["duration_ms"] / 1000)
|
||||
|
||||
success = lyrics_client.create_lrc_file(
|
||||
audio_file_path=file_path,
|
||||
track_name=track_name,
|
||||
artist_name=artist_name,
|
||||
album_name=album_name,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger_.info("LRC file generated for: %s", track_name)
|
||||
else:
|
||||
logger_.warning("No lyrics found for: %s", track_name)
|
||||
return success
|
||||
except Exception as exc:
|
||||
logger_.error("Error generating LRC file for %s: %s", file_path, exc)
|
||||
return False
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Metadata Service - Centralized metadata source selection
|
||||
Metadata Service - Centralized metadata source selection and provider access.
|
||||
|
||||
ALL metadata source decisions flow through this module. Other files import
|
||||
get_primary_source() and get_primary_client() instead of reimplementing
|
||||
|
|
@ -100,6 +100,35 @@ def get_source_priority(preferred_source: str):
|
|||
return ordered
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
||||
primary_source = get_primary_source()
|
||||
source_chain = list(get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def get_client_for_source(source: str):
|
||||
"""Get the client object for an exact metadata source.
|
||||
|
||||
|
|
@ -235,35 +264,6 @@ def get_artist_albums_for_source(
|
|||
return None
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
||||
primary_source = get_primary_source()
|
||||
source_chain = list(get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_artist_name(value: Any) -> str:
|
||||
return (value or '').strip().casefold()
|
||||
|
||||
|
|
@ -841,234 +841,6 @@ def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]:
|
|||
return normalized
|
||||
|
||||
|
||||
def _build_single_import_context_payload(
|
||||
track_data: Any,
|
||||
source: Optional[str],
|
||||
source_priority: List[str],
|
||||
requested_title: str = '',
|
||||
requested_artist: str = '',
|
||||
) -> Dict[str, Any]:
|
||||
album_data = _extract_lookup_value(track_data, 'album', default=None)
|
||||
|
||||
track_id = str(_extract_lookup_value(track_data, 'id', 'track_id', 'trackId', default='') or '')
|
||||
track_name = _extract_lookup_value(track_data, 'name', 'title', 'trackName', default='') or requested_title or 'Unknown Track'
|
||||
track_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'artists', default=[]))
|
||||
if not track_artists and requested_artist:
|
||||
track_artists = [{'name': requested_artist}]
|
||||
|
||||
primary_track_artist = track_artists[0] if track_artists else {}
|
||||
primary_artist_name = primary_track_artist.get('name') or requested_artist or 'Unknown Artist'
|
||||
primary_artist_id = str(primary_track_artist.get('id', '') or _extract_lookup_value(track_data, 'artist_id', 'artistId', default='') or '')
|
||||
|
||||
album_name = _extract_lookup_value(track_data, 'album_name', 'collectionName', default='') or ''
|
||||
album_id = str(_extract_lookup_value(track_data, 'album_id', 'collectionId', 'albumId', default='') or '')
|
||||
release_date = str(_extract_lookup_value(track_data, 'release_date', default='') or '')
|
||||
album_type = str(_extract_lookup_value(track_data, 'album_type', default='album') or 'album')
|
||||
total_tracks = int(_extract_lookup_value(track_data, 'total_tracks', 'track_count', default=0) or 0)
|
||||
album_images: List[Dict[str, Any]] = []
|
||||
album_image_url = str(_extract_lookup_value(track_data, 'image_url', 'thumb_url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'album_artists', 'artists', default=[]))
|
||||
|
||||
if isinstance(album_data, dict):
|
||||
album_name = _extract_lookup_value(album_data, 'name', 'title', 'collectionName', default=album_name) or album_name
|
||||
album_id = str(_extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', default=album_id) or album_id)
|
||||
release_date = str(_extract_lookup_value(album_data, 'release_date', default=release_date) or release_date)
|
||||
album_type = str(_extract_lookup_value(album_data, 'album_type', default=album_type) or album_type)
|
||||
total_tracks = int(_extract_lookup_value(album_data, 'total_tracks', 'track_count', 'nb_tracks', default=total_tracks) or total_tracks)
|
||||
album_images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not album_image_url:
|
||||
album_image_url = str(_extract_lookup_value(album_data, 'image_url', 'thumb_url', default='') or '')
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(album_data, 'artists', default=[]))
|
||||
elif album_data:
|
||||
album_name = album_name or str(album_data)
|
||||
|
||||
if not album_artists and primary_artist_name:
|
||||
album_artists = [{'name': primary_artist_name}]
|
||||
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
|
||||
track_info = {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'track_number': int(_extract_lookup_value(track_data, 'track_number', 'trackNumber', default=1) or 1),
|
||||
'disc_number': int(_extract_lookup_value(track_data, 'disc_number', 'discNumber', default=1) or 1),
|
||||
'duration_ms': int(_extract_lookup_value(track_data, 'duration_ms', 'duration', 'trackTimeMillis', default=0) or 0),
|
||||
'artists': track_artists or [{'name': primary_artist_name}],
|
||||
'uri': str(_extract_lookup_value(track_data, 'uri', default='') or ''),
|
||||
'album': album_name,
|
||||
'album_id': album_id,
|
||||
'album_type': album_type,
|
||||
'release_date': release_date,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
album_payload = {
|
||||
'id': album_id,
|
||||
'name': album_name,
|
||||
'release_date': release_date,
|
||||
'total_tracks': total_tracks or 1,
|
||||
'album_type': album_type,
|
||||
'image_url': album_image_url,
|
||||
'images': album_images,
|
||||
'artists': album_artists,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
artist_payload = {
|
||||
'id': primary_artist_id,
|
||||
'name': primary_artist_name,
|
||||
'genres': [],
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
original_search = {
|
||||
'title': track_name,
|
||||
'artist': primary_artist_name,
|
||||
'album': album_name,
|
||||
'track_number': track_info['track_number'],
|
||||
'disc_number': track_info['disc_number'],
|
||||
'clean_title': track_name,
|
||||
'clean_album': album_name,
|
||||
'clean_artist': primary_artist_name,
|
||||
'artists': track_info['artists'],
|
||||
'duration_ms': track_info['duration_ms'],
|
||||
'id': track_id,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
return {
|
||||
'success': bool(track_id or track_name != requested_title or album_name),
|
||||
'source': source,
|
||||
'source_priority': source_priority,
|
||||
'context': {
|
||||
'artist': artist_payload,
|
||||
'album': album_payload,
|
||||
'track_info': track_info,
|
||||
'original_search_result': original_search,
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': bool(track_id),
|
||||
'has_full_metadata': bool(track_id),
|
||||
'source': source,
|
||||
'source_priority': source_priority,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_single_import_fallback_context(
|
||||
requested_title: str,
|
||||
requested_artist: str,
|
||||
source_priority: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
artist_name = requested_artist or 'Unknown Artist'
|
||||
title = requested_title or 'Unknown Track'
|
||||
return {
|
||||
'success': False,
|
||||
'source': None,
|
||||
'source_priority': source_priority,
|
||||
'context': {
|
||||
'artist': {
|
||||
'id': '',
|
||||
'name': artist_name,
|
||||
'genres': [],
|
||||
'_source': '',
|
||||
},
|
||||
'album': {
|
||||
'id': '',
|
||||
'name': '',
|
||||
'release_date': '',
|
||||
'total_tracks': 1,
|
||||
'album_type': 'album',
|
||||
'image_url': '',
|
||||
'images': [],
|
||||
'artists': [],
|
||||
'_source': '',
|
||||
},
|
||||
'track_info': {
|
||||
'id': '',
|
||||
'name': title,
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'duration_ms': 0,
|
||||
'artists': [{'name': artist_name}],
|
||||
'uri': '',
|
||||
'album': '',
|
||||
'album_id': '',
|
||||
'album_type': 'album',
|
||||
'release_date': '',
|
||||
'_source': '',
|
||||
},
|
||||
'original_search_result': {
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': '',
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'clean_title': title,
|
||||
'clean_album': '',
|
||||
'clean_artist': artist_name,
|
||||
'artists': [{'name': artist_name}],
|
||||
'duration_ms': 0,
|
||||
'id': '',
|
||||
'_source': '',
|
||||
},
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': False,
|
||||
'has_full_metadata': False,
|
||||
'source': None,
|
||||
'source_priority': source_priority,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_track_search_query(source: str, title: str, artist: str) -> str:
|
||||
base_query = " ".join(part for part in (title, artist) if part).strip()
|
||||
if source == 'deezer' and title:
|
||||
if artist:
|
||||
return f'artist:"{artist}" track:"{title}"'
|
||||
return f'track:"{title}"'
|
||||
return base_query or title or artist
|
||||
|
||||
|
||||
def _pick_best_track_match(search_results: List[Any], title: str, artist: str = '') -> Optional[Any]:
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
target_title = str(title or '').strip().lower()
|
||||
target_artist = str(artist or '').strip().lower()
|
||||
|
||||
for candidate in search_results:
|
||||
candidate_title = str(_extract_lookup_value(candidate, 'name', 'title', 'track_name', default='') or '').strip().lower()
|
||||
if candidate_title != target_title:
|
||||
continue
|
||||
|
||||
if not target_artist:
|
||||
return candidate
|
||||
|
||||
candidate_artists = _normalize_context_artists(_extract_lookup_value(candidate, 'artists', default=[]))
|
||||
candidate_artist_name = candidate_artists[0]['name'].strip().lower() if candidate_artists else ''
|
||||
if candidate_artist_name == target_artist:
|
||||
return candidate
|
||||
|
||||
return search_results[0]
|
||||
|
||||
|
||||
def _search_tracks_for_source(source: str, client: Any, query: str, limit: int = 1) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_tracks(query, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def get_single_track_import_context(
|
||||
title: str,
|
||||
artist: str = '',
|
||||
|
|
@ -1076,94 +848,16 @@ def get_single_track_import_context(
|
|||
override_source: str = 'spotify',
|
||||
source_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build an import context for singles using source-priority metadata lookup."""
|
||||
options = MetadataLookupOptions(source_override=source_override, allow_fallback=True)
|
||||
source_priority = _get_source_chain_for_lookup(options)
|
||||
title = (title or '').strip()
|
||||
artist = (artist or '').strip()
|
||||
"""Compatibility wrapper for the single-track import resolver."""
|
||||
from core.import_resolution import get_single_track_import_context as _get_single_track_import_context
|
||||
|
||||
if override_id:
|
||||
chosen_source = (override_source or 'spotify').strip().lower() or 'spotify'
|
||||
client = get_client_for_source(chosen_source)
|
||||
if client and hasattr(client, 'get_track_details'):
|
||||
try:
|
||||
track_data = client.get_track_details(str(override_id))
|
||||
if track_data:
|
||||
payload = _build_single_import_context_payload(
|
||||
track_data,
|
||||
chosen_source,
|
||||
source_priority,
|
||||
requested_title=title,
|
||||
requested_artist=artist,
|
||||
)
|
||||
if payload['context']['artist'].get('id') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
artist_details = client.get_artist(payload['context']['artist']['id'])
|
||||
if artist_details:
|
||||
payload['context']['artist']['genres'] = _extract_lookup_value(
|
||||
artist_details,
|
||||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Override track lookup failed on %s for %s: %s", chosen_source, override_id, exc)
|
||||
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
search_query = _build_track_search_query(source, title, artist)
|
||||
if not search_query:
|
||||
continue
|
||||
|
||||
search_results = _search_tracks_for_source(source, client, search_query, limit=5)
|
||||
if not search_results and search_query != title:
|
||||
search_results = _search_tracks_for_source(source, client, title, limit=5)
|
||||
if not search_results and artist and search_query != artist:
|
||||
search_results = _search_tracks_for_source(source, client, artist, limit=5)
|
||||
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
best_match = _pick_best_track_match(search_results, title or search_query, artist)
|
||||
if not best_match:
|
||||
continue
|
||||
|
||||
resolved_track_id = str(_extract_lookup_value(best_match, 'id', 'track_id', 'trackId', default='') or '')
|
||||
resolved_data = best_match
|
||||
if resolved_track_id and hasattr(client, 'get_track_details'):
|
||||
try:
|
||||
detailed = client.get_track_details(resolved_track_id)
|
||||
if detailed:
|
||||
resolved_data = detailed
|
||||
except Exception as exc:
|
||||
logger.debug("Track detail lookup failed on %s for %s: %s", source, resolved_track_id, exc)
|
||||
|
||||
payload = _build_single_import_context_payload(
|
||||
resolved_data,
|
||||
source,
|
||||
source_priority,
|
||||
requested_title=title,
|
||||
requested_artist=artist,
|
||||
)
|
||||
if payload['context']['artist'].get('id') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
artist_details = client.get_artist(payload['context']['artist']['id'])
|
||||
if artist_details:
|
||||
payload['context']['artist']['genres'] = _extract_lookup_value(
|
||||
artist_details,
|
||||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
|
||||
return _build_single_import_fallback_context(title, artist, source_priority)
|
||||
return _get_single_track_import_context(
|
||||
title,
|
||||
artist=artist,
|
||||
override_id=override_id,
|
||||
override_source=override_source,
|
||||
source_override=source_override,
|
||||
)
|
||||
|
||||
|
||||
def resolve_album_reference(
|
||||
|
|
|
|||
896
core/metadata_source.py
Normal file
896
core/metadata_source.py
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
"""Source metadata extraction and source-ID embedding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.import_context import (
|
||||
get_import_clean_artist,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_original_search,
|
||||
get_import_source,
|
||||
get_import_source_ids,
|
||||
get_import_track_info,
|
||||
get_source_tag_names,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.metadata_common import (
|
||||
_extract_artist_name,
|
||||
_get_config_manager,
|
||||
_get_database,
|
||||
_get_itunes_client,
|
||||
_get_logger,
|
||||
_get_mutagen_symbols,
|
||||
_is_vorbis_like,
|
||||
)
|
||||
|
||||
|
||||
_MB_RELEASE_CACHE: Dict[tuple, str] = {}
|
||||
_MB_RELEASE_CACHE_LOCK = threading.RLock()
|
||||
_MB_RELEASE_DETAIL_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
_MB_RELEASE_DETAIL_CACHE_LOCK = threading.RLock()
|
||||
|
||||
_EDITION_PAREN_RE = re.compile(
|
||||
r'\s*[\(\[]\s*(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
||||
r'limited|bonus|platinum|gold|super\s*deluxe|standard)'
|
||||
r'(?:\s+(?:edition|version))?[^)\]]*[\)\]]',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EDITION_BARE_RE = re.compile(
|
||||
r'\s+(?:-\s+)?(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
||||
r'limited|bonus|platinum|gold|super\s*deluxe|standard)'
|
||||
r'(?:\s+(?:edition|version))?\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_album_cache_key(album_name: str) -> str:
|
||||
result = _EDITION_PAREN_RE.sub("", album_name or "")
|
||||
result = _EDITION_BARE_RE.sub("", result)
|
||||
return result.lower().strip()
|
||||
|
||||
|
||||
def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> dict:
|
||||
if album_info is None:
|
||||
album_info = {}
|
||||
|
||||
cfg = _get_config_manager()
|
||||
logger_ = _get_logger()
|
||||
context = normalize_import_context(context)
|
||||
original_search = get_import_original_search(context)
|
||||
album_ctx = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
source = get_import_source(context)
|
||||
source_ids = get_import_source_ids(context)
|
||||
|
||||
artist_dict = artist if isinstance(artist, dict) else {
|
||||
"name": _extract_artist_name(artist),
|
||||
"id": getattr(artist, "id", ""),
|
||||
"genres": list(getattr(artist, "genres", []) or []),
|
||||
}
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"source": source,
|
||||
"source_track_id": source_ids["track_id"],
|
||||
"source_artist_id": source_ids["artist_id"],
|
||||
"source_album_id": source_ids["album_id"],
|
||||
}
|
||||
|
||||
metadata["title"] = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", ""))
|
||||
if original_search.get("clean_title"):
|
||||
logger_.info("Metadata: Using clean title: '%s'", metadata["title"])
|
||||
elif album_info.get("clean_track_name"):
|
||||
logger_.info("Metadata: Using album info clean name: '%s'", metadata["title"])
|
||||
else:
|
||||
logger_.warning("Metadata: Using original title as fallback: '%s'", metadata["title"])
|
||||
|
||||
artists = original_search.get("artists")
|
||||
if isinstance(artists, list) and artists:
|
||||
all_artists = []
|
||||
for artist_item in artists:
|
||||
if isinstance(artist_item, dict) and artist_item.get("name"):
|
||||
all_artists.append(artist_item["name"])
|
||||
elif isinstance(artist_item, str):
|
||||
all_artists.append(artist_item)
|
||||
else:
|
||||
all_artists.append(str(artist_item))
|
||||
metadata["artist"] = ", ".join(all_artists)
|
||||
logger_.info("Metadata: Using all artists: '%s'", metadata["artist"])
|
||||
else:
|
||||
metadata["artist"] = artist_dict.get("name", "") or get_import_clean_artist(context)
|
||||
logger_.info("Metadata: Using primary artist: '%s'", metadata["artist"])
|
||||
|
||||
raw_album_artist = artist_dict.get("name", "") or metadata["artist"]
|
||||
track_info_ctx = track_info or {}
|
||||
explicit_artist = track_info_ctx.get("_explicit_artist_context") if isinstance(track_info_ctx, dict) else None
|
||||
album_artists_for_collab = None
|
||||
|
||||
if isinstance(explicit_artist, dict) and explicit_artist.get("name"):
|
||||
raw_album_artist = explicit_artist["name"]
|
||||
album_artists_for_collab = [explicit_artist]
|
||||
elif isinstance(explicit_artist, str) and explicit_artist:
|
||||
raw_album_artist = explicit_artist
|
||||
album_artists_for_collab = [{"name": explicit_artist}]
|
||||
elif album_ctx and isinstance(album_ctx, dict):
|
||||
album_artists = album_ctx.get("artists", [])
|
||||
if album_artists:
|
||||
first_album_artist = album_artists[0]
|
||||
if isinstance(first_album_artist, dict) and first_album_artist.get("name"):
|
||||
raw_album_artist = first_album_artist["name"]
|
||||
elif isinstance(first_album_artist, str) and first_album_artist:
|
||||
raw_album_artist = first_album_artist
|
||||
album_artists_for_collab = album_artists
|
||||
|
||||
collab_mode = cfg.get("file_organization.collab_artist_mode", "first")
|
||||
if collab_mode == "first" and raw_album_artist:
|
||||
context_artists = album_artists_for_collab or original_search.get("artists") or track_info_ctx.get("artists") or []
|
||||
if len(context_artists) > 1:
|
||||
first = context_artists[0]
|
||||
raw_album_artist = first.get("name", first) if isinstance(first, dict) else str(first)
|
||||
elif len(context_artists) == 1 and ("," in raw_album_artist or " & " in raw_album_artist):
|
||||
artist_id = str(artist_dict.get("id", ""))
|
||||
if source == "itunes" and artist_id.isdigit():
|
||||
try:
|
||||
itunes_client = _get_itunes_client()
|
||||
if itunes_client and hasattr(itunes_client, "resolve_primary_artist"):
|
||||
resolved = itunes_client.resolve_primary_artist(artist_id)
|
||||
if resolved and resolved != raw_album_artist:
|
||||
raw_album_artist = resolved
|
||||
except Exception:
|
||||
pass
|
||||
metadata["album_artist"] = raw_album_artist
|
||||
|
||||
if album_info.get("is_album"):
|
||||
metadata["album"] = album_info.get("album_name", "Unknown Album")
|
||||
metadata["track_number"] = album_info.get("track_number", 1)
|
||||
metadata["total_tracks"] = album_ctx.get("total_tracks", 1) if album_ctx else 1
|
||||
logger_.info("[METADATA] Album track - track_number: %s, album: %s", metadata["track_number"], metadata["album"])
|
||||
else:
|
||||
if album_ctx and album_ctx.get("name"):
|
||||
logger_.info("[SAFEGUARD] Using album context name instead of track title for album metadata")
|
||||
metadata["album"] = album_ctx["name"]
|
||||
metadata["track_number"] = album_info.get("track_number", 1) if album_info else 1
|
||||
metadata["total_tracks"] = album_ctx.get("total_tracks", 1)
|
||||
else:
|
||||
metadata["album"] = metadata["title"]
|
||||
metadata["track_number"] = 1
|
||||
metadata["total_tracks"] = 1
|
||||
|
||||
disc_num = original_search.get("disc_number")
|
||||
if disc_num is None and album_info:
|
||||
disc_num = album_info.get("disc_number")
|
||||
metadata["disc_number"] = disc_num if disc_num is not None else 1
|
||||
|
||||
if album_ctx and album_ctx.get("release_date"):
|
||||
metadata["date"] = album_ctx["release_date"][:4]
|
||||
|
||||
genres = artist_dict.get("genres") or []
|
||||
if genres:
|
||||
from core.genre_filter import filter_genres
|
||||
|
||||
filtered = filter_genres(list(genres[:2]), cfg)
|
||||
if filtered:
|
||||
metadata["genre"] = ", ".join(filtered)
|
||||
|
||||
metadata["album_art_url"] = album_info.get("album_image_url") if album_info else None
|
||||
if not metadata["album_art_url"] and album_ctx:
|
||||
album_image = album_ctx.get("image_url")
|
||||
if not album_image and album_ctx.get("images"):
|
||||
first_image = album_ctx["images"][0]
|
||||
album_image = first_image.get("url") if isinstance(first_image, dict) else None
|
||||
metadata["album_art_url"] = album_image
|
||||
|
||||
logger_.info(
|
||||
"[Metadata Summary] title='%s' | artist='%s' | album_artist='%s' | album='%s' | track=%s/%s | disc=%s",
|
||||
metadata.get("title"),
|
||||
metadata.get("artist"),
|
||||
metadata.get("album_artist"),
|
||||
metadata.get("album"),
|
||||
metadata.get("track_number"),
|
||||
metadata.get("total_tracks"),
|
||||
metadata.get("disc_number"),
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=None):
|
||||
cfg = _get_config_manager()
|
||||
logger_ = _get_logger()
|
||||
symbols = _get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
try:
|
||||
tag_config = {
|
||||
"SPOTIFY_TRACK_ID": "spotify.tags.track_id",
|
||||
"SPOTIFY_ARTIST_ID": "spotify.tags.artist_id",
|
||||
"SPOTIFY_ALBUM_ID": "spotify.tags.album_id",
|
||||
"ITUNES_TRACK_ID": "itunes.tags.track_id",
|
||||
"ITUNES_ARTIST_ID": "itunes.tags.artist_id",
|
||||
"ITUNES_ALBUM_ID": "itunes.tags.album_id",
|
||||
"MUSICBRAINZ_RECORDING_ID": "musicbrainz.tags.recording_id",
|
||||
"MUSICBRAINZ_ARTIST_ID": "musicbrainz.tags.artist_id",
|
||||
"MUSICBRAINZ_RELEASE_ID": "musicbrainz.tags.release_id",
|
||||
"MUSICBRAINZ_RELEASEGROUPID": "musicbrainz.tags.release_group_id",
|
||||
"MUSICBRAINZ_ALBUMARTISTID": "musicbrainz.tags.album_artist_id",
|
||||
"MUSICBRAINZ_RELEASETRACKID": "musicbrainz.tags.release_track_id",
|
||||
"RELEASETYPE": "musicbrainz.tags.release_type",
|
||||
"ORIGINALDATE": "musicbrainz.tags.original_date",
|
||||
"RELEASESTATUS": "musicbrainz.tags.release_status",
|
||||
"RELEASECOUNTRY": "musicbrainz.tags.release_country",
|
||||
"BARCODE": "musicbrainz.tags.barcode",
|
||||
"MEDIA": "musicbrainz.tags.media",
|
||||
"TOTALDISCS": "musicbrainz.tags.total_discs",
|
||||
"CATALOGNUMBER": "musicbrainz.tags.catalog_number",
|
||||
"SCRIPT": "musicbrainz.tags.script",
|
||||
"ASIN": "musicbrainz.tags.asin",
|
||||
"DEEZER_TRACK_ID": "deezer.tags.track_id",
|
||||
"DEEZER_ARTIST_ID": "deezer.tags.artist_id",
|
||||
"AUDIODB_TRACK_ID": "audiodb.tags.track_id",
|
||||
"TIDAL_TRACK_ID": "tidal.tags.track_id",
|
||||
"TIDAL_ARTIST_ID": "tidal.tags.artist_id",
|
||||
"QOBUZ_TRACK_ID": "qobuz.tags.track_id",
|
||||
"QOBUZ_ARTIST_ID": "qobuz.tags.artist_id",
|
||||
"GENIUS_TRACK_ID": "genius.tags.track_id",
|
||||
}
|
||||
|
||||
def _tag_enabled(path: str) -> bool:
|
||||
return cfg.get(path, True) is not False
|
||||
|
||||
def _names_match(a: str, b: str, threshold: float = 0.75) -> bool:
|
||||
if not a or not b:
|
||||
return False
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
norm = lambda s: re.sub(r"[^a-z0-9 ]", "", re.sub(r"\(.*?\)", "", s).lower()).strip()
|
||||
return SequenceMatcher(None, norm(a), norm(b)).ratio() >= threshold
|
||||
|
||||
context = normalize_import_context(context)
|
||||
source = (metadata.get("source") or "").strip().lower()
|
||||
source_ids = {}
|
||||
if source:
|
||||
source_tag_names = get_source_tag_names(source)
|
||||
source_track_id = metadata.get("source_track_id")
|
||||
source_artist_id = metadata.get("source_artist_id")
|
||||
source_album_id = metadata.get("source_album_id")
|
||||
if cfg.get(f"{source}.embed_tags", True) is not False:
|
||||
if source_tag_names.get("track") and source_track_id:
|
||||
source_ids[source_tag_names["track"]] = source_track_id
|
||||
if source_tag_names.get("artist") and source_artist_id:
|
||||
source_ids[source_tag_names["artist"]] = source_artist_id
|
||||
if source_tag_names.get("album") and source_album_id:
|
||||
source_ids[source_tag_names["album"]] = source_album_id
|
||||
|
||||
if not source_ids:
|
||||
if cfg.get("spotify.embed_tags", True) is not False:
|
||||
if metadata.get("spotify_track_id"):
|
||||
source_ids["SPOTIFY_TRACK_ID"] = metadata["spotify_track_id"]
|
||||
if metadata.get("spotify_artist_id"):
|
||||
source_ids["SPOTIFY_ARTIST_ID"] = metadata["spotify_artist_id"]
|
||||
if metadata.get("spotify_album_id"):
|
||||
source_ids["SPOTIFY_ALBUM_ID"] = metadata["spotify_album_id"]
|
||||
if cfg.get("itunes.embed_tags", True) is not False:
|
||||
if metadata.get("itunes_track_id"):
|
||||
source_ids["ITUNES_TRACK_ID"] = metadata["itunes_track_id"]
|
||||
if metadata.get("itunes_artist_id"):
|
||||
source_ids["ITUNES_ARTIST_ID"] = metadata["itunes_artist_id"]
|
||||
if metadata.get("itunes_album_id"):
|
||||
source_ids["ITUNES_ALBUM_ID"] = metadata["itunes_album_id"]
|
||||
|
||||
track_title = metadata.get("title", "")
|
||||
artist_name = metadata.get("album_artist", "") or metadata.get("artist", "")
|
||||
track_info = get_import_track_info(context)
|
||||
explicit_artist = (track_info or {}).get("_explicit_artist_context") if isinstance(track_info, dict) else None
|
||||
batch_artist_name = None
|
||||
if isinstance(explicit_artist, dict) and explicit_artist.get("name"):
|
||||
batch_artist_name = explicit_artist["name"]
|
||||
elif isinstance(explicit_artist, str) and explicit_artist:
|
||||
batch_artist_name = explicit_artist
|
||||
|
||||
pp = {
|
||||
"id_tags": source_ids,
|
||||
"track_title": track_title,
|
||||
"artist_name": artist_name,
|
||||
"batch_artist_name": batch_artist_name,
|
||||
"metadata": metadata,
|
||||
"recording_mbid": None,
|
||||
"artist_mbid": None,
|
||||
"release_mbid": "",
|
||||
"mb_genres": [],
|
||||
"isrc": None,
|
||||
"deezer_bpm": None,
|
||||
"deezer_isrc": None,
|
||||
"audiodb_mood": None,
|
||||
"audiodb_style": None,
|
||||
"audiodb_genre": None,
|
||||
"tidal_isrc": None,
|
||||
"tidal_copyright": None,
|
||||
"qobuz_isrc": None,
|
||||
"qobuz_copyright": None,
|
||||
"qobuz_label": None,
|
||||
"lastfm_tags": [],
|
||||
"lastfm_url": None,
|
||||
"genius_url": None,
|
||||
"release_year": None,
|
||||
}
|
||||
|
||||
source_order = cfg.get("metadata_enhancement.post_process_order", None)
|
||||
if not isinstance(source_order, list) or not source_order:
|
||||
source_order = ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
|
||||
|
||||
db = _get_database()
|
||||
|
||||
for source_name in source_order:
|
||||
if source_name == "musicbrainz":
|
||||
if cfg.get("musicbrainz.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
mb_worker = getattr(runtime, "mb_worker", None)
|
||||
mb_service = mb_worker.mb_service if mb_worker else None
|
||||
if not mb_service:
|
||||
continue
|
||||
try:
|
||||
result = mb_service.match_recording(track_title, artist_name)
|
||||
if result and result.get("mbid"):
|
||||
pp["recording_mbid"] = result["mbid"]
|
||||
pp["id_tags"]["MUSICBRAINZ_RECORDING_ID"] = pp["recording_mbid"]
|
||||
details = mb_service.mb_client.get_recording(pp["recording_mbid"], includes=["isrcs", "genres"])
|
||||
if details:
|
||||
isrcs = details.get("isrcs", [])
|
||||
if isrcs:
|
||||
pp["isrc"] = isrcs[0]
|
||||
pp["mb_genres"] = [g["name"] for g in sorted(details.get("genres", []), key=lambda x: x.get("count", 0), reverse=True)]
|
||||
|
||||
track_artist_name = metadata.get("artist", "") or artist_name
|
||||
if ", " in track_artist_name:
|
||||
track_artist_name = track_artist_name.split(", ")[0]
|
||||
artist_result = mb_service.match_artist(track_artist_name)
|
||||
if artist_result and artist_result.get("mbid"):
|
||||
pp["artist_mbid"] = artist_result["mbid"]
|
||||
pp["id_tags"]["MUSICBRAINZ_ARTIST_ID"] = pp["artist_mbid"]
|
||||
|
||||
album_name_for_mb = metadata.get("album", "")
|
||||
if album_name_for_mb:
|
||||
artist_key = (pp.get("batch_artist_name") or artist_name).lower().strip()
|
||||
rc_key_norm = (_normalize_album_cache_key(album_name_for_mb), artist_key)
|
||||
rc_key_exact = (album_name_for_mb.lower().strip(), artist_key)
|
||||
with _MB_RELEASE_CACHE_LOCK:
|
||||
cached = _MB_RELEASE_CACHE.get(rc_key_norm)
|
||||
if cached is None:
|
||||
cached = _MB_RELEASE_CACHE.get(rc_key_exact)
|
||||
if cached is not None:
|
||||
pp["release_mbid"] = cached
|
||||
else:
|
||||
try:
|
||||
rc_result = mb_service.match_release(album_name_for_mb, artist_name)
|
||||
pp["release_mbid"] = rc_result.get("mbid", "") if rc_result else ""
|
||||
except Exception:
|
||||
pp["release_mbid"] = ""
|
||||
_MB_RELEASE_CACHE[rc_key_norm] = pp["release_mbid"]
|
||||
_MB_RELEASE_CACHE[rc_key_exact] = pp["release_mbid"]
|
||||
if pp["release_mbid"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASE_ID"] = pp["release_mbid"]
|
||||
|
||||
if pp["release_mbid"]:
|
||||
with _MB_RELEASE_DETAIL_CACHE_LOCK:
|
||||
release_detail = _MB_RELEASE_DETAIL_CACHE.get(pp["release_mbid"])
|
||||
if release_detail is None:
|
||||
release_detail = mb_service.mb_client.get_release(
|
||||
pp["release_mbid"],
|
||||
includes=["release-groups", "labels", "media", "artist-credits", "recordings"],
|
||||
) or {}
|
||||
with _MB_RELEASE_DETAIL_CACHE_LOCK:
|
||||
_MB_RELEASE_DETAIL_CACHE[pp["release_mbid"]] = release_detail
|
||||
if release_detail:
|
||||
rg = release_detail.get("release-group", {})
|
||||
if rg.get("id"):
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASEGROUPID"] = rg["id"]
|
||||
ac = release_detail.get("artist-credit", [])
|
||||
if ac and isinstance(ac[0], dict):
|
||||
aa = ac[0].get("artist", {})
|
||||
if aa.get("id"):
|
||||
pp["id_tags"]["MUSICBRAINZ_ALBUMARTISTID"] = aa["id"]
|
||||
if rg.get("primary-type"):
|
||||
pp["id_tags"]["RELEASETYPE"] = rg["primary-type"]
|
||||
if rg.get("first-release-date"):
|
||||
pp["id_tags"]["ORIGINALDATE"] = rg["first-release-date"]
|
||||
if not pp["release_year"] and len(rg["first-release-date"]) >= 4:
|
||||
year = rg["first-release-date"][:4]
|
||||
if year.isdigit():
|
||||
pp["release_year"] = year
|
||||
if release_detail.get("status"):
|
||||
pp["id_tags"]["RELEASESTATUS"] = release_detail["status"]
|
||||
if release_detail.get("country"):
|
||||
pp["id_tags"]["RELEASECOUNTRY"] = release_detail["country"]
|
||||
if release_detail.get("barcode"):
|
||||
pp["id_tags"]["BARCODE"] = release_detail["barcode"]
|
||||
media_list = release_detail.get("media", [])
|
||||
if media_list:
|
||||
fmt = media_list[0].get("format", "")
|
||||
if fmt:
|
||||
pp["id_tags"]["MEDIA"] = fmt
|
||||
pp["id_tags"]["TOTALDISCS"] = str(len(media_list))
|
||||
label_info = release_detail.get("label-info", [])
|
||||
if label_info and isinstance(label_info[0], dict):
|
||||
cat = label_info[0].get("catalog-number", "")
|
||||
if cat:
|
||||
pp["id_tags"]["CATALOGNUMBER"] = cat
|
||||
text_rep = release_detail.get("text-representation", {})
|
||||
if isinstance(text_rep, dict) and text_rep.get("script"):
|
||||
pp["id_tags"]["SCRIPT"] = text_rep["script"]
|
||||
if release_detail.get("asin"):
|
||||
pp["id_tags"]["ASIN"] = release_detail["asin"]
|
||||
track_num = metadata.get("track_number")
|
||||
disc_num = metadata.get("disc_number") or 1
|
||||
if track_num and media_list:
|
||||
try:
|
||||
track_num_int = int(track_num)
|
||||
disc_num_int = int(disc_num)
|
||||
for medium in media_list:
|
||||
if medium.get("position", 1) == disc_num_int:
|
||||
for mtrack in (medium.get("tracks") or medium.get("track-list", [])):
|
||||
if mtrack.get("position") == track_num_int:
|
||||
if mtrack.get("id"):
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASETRACKID"] = mtrack["id"]
|
||||
release_recording = mtrack.get("recording", {})
|
||||
if release_recording.get("id"):
|
||||
pp["recording_mbid"] = release_recording["id"]
|
||||
pp["id_tags"]["MUSICBRAINZ_RECORDING_ID"] = release_recording["id"]
|
||||
break
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger_.error("MusicBrainz lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if source_name == "deezer":
|
||||
if cfg.get("deezer.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
try:
|
||||
deezer_worker = getattr(runtime, "deezer_worker", None)
|
||||
dz_client = deezer_worker.client if deezer_worker else None
|
||||
if not dz_client:
|
||||
continue
|
||||
dz_result = dz_client.search_track(artist_name, track_title)
|
||||
if dz_result and _names_match(dz_result.get("title", ""), track_title) and _names_match(dz_result.get("artist", {}).get("name", ""), artist_name):
|
||||
dz_track_id = dz_result["id"]
|
||||
pp["id_tags"]["DEEZER_TRACK_ID"] = str(dz_track_id)
|
||||
dz_artist_id = dz_result.get("artist", {}).get("id")
|
||||
if dz_artist_id:
|
||||
pp["id_tags"]["DEEZER_ARTIST_ID"] = str(dz_artist_id)
|
||||
dz_details = dz_client.get_track_details(dz_track_id)
|
||||
if dz_details:
|
||||
bpm_val = dz_details.get("bpm")
|
||||
if bpm_val and bpm_val > 0:
|
||||
pp["deezer_bpm"] = bpm_val
|
||||
dz_isrc = dz_details.get("isrc")
|
||||
if dz_isrc:
|
||||
pp["deezer_isrc"] = dz_isrc
|
||||
if not pp["release_year"]:
|
||||
dz_album = dz_result.get("album", {})
|
||||
dz_release = (dz_album.get("release_date", "") if isinstance(dz_album, dict) else "") or ""
|
||||
if len(dz_release) >= 4 and dz_release[:4].isdigit():
|
||||
pp["release_year"] = dz_release[:4]
|
||||
except Exception as exc:
|
||||
logger_.error("Deezer lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if source_name == "audiodb":
|
||||
if cfg.get("audiodb.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
try:
|
||||
audiodb_worker = getattr(runtime, "audiodb_worker", None)
|
||||
adb_client = audiodb_worker.client if audiodb_worker else None
|
||||
if not adb_client:
|
||||
continue
|
||||
adb_result = adb_client.search_track(artist_name, track_title)
|
||||
if adb_result and _names_match(adb_result.get("strTrack", ""), track_title) and _names_match(adb_result.get("strArtist", ""), artist_name):
|
||||
adb_track_id = adb_result.get("idTrack")
|
||||
if adb_track_id:
|
||||
pp["id_tags"]["AUDIODB_TRACK_ID"] = str(adb_track_id)
|
||||
adb_mb_track = adb_result.get("strMusicBrainzID")
|
||||
if adb_mb_track and "MUSICBRAINZ_RECORDING_ID" not in pp["id_tags"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_RECORDING_ID"] = adb_mb_track
|
||||
pp["recording_mbid"] = adb_mb_track
|
||||
adb_mb_artist = adb_result.get("strMusicBrainzArtistID")
|
||||
if adb_mb_artist and "MUSICBRAINZ_ARTIST_ID" not in pp["id_tags"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_ARTIST_ID"] = adb_mb_artist
|
||||
pp["artist_mbid"] = adb_mb_artist
|
||||
pp["audiodb_mood"] = adb_result.get("strMood") or None
|
||||
pp["audiodb_style"] = adb_result.get("strStyle") or None
|
||||
pp["audiodb_genre"] = adb_result.get("strGenre") or None
|
||||
except Exception as exc:
|
||||
logger_.error("AudioDB lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if source_name == "tidal":
|
||||
if cfg.get("tidal.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
try:
|
||||
tidal_client = getattr(runtime, "tidal_client", None)
|
||||
if not (tidal_client and tidal_client.is_authenticated()):
|
||||
continue
|
||||
td_result = tidal_client.search_track(artist_name, track_title)
|
||||
if td_result and _names_match(td_result.get("title", ""), track_title):
|
||||
td_track_id = td_result.get("id")
|
||||
if td_track_id:
|
||||
pp["id_tags"]["TIDAL_TRACK_ID"] = str(td_track_id)
|
||||
td_artist = td_result.get("artist", {})
|
||||
if isinstance(td_artist, dict) and td_artist.get("id"):
|
||||
pp["id_tags"]["TIDAL_ARTIST_ID"] = str(td_artist["id"])
|
||||
if td_track_id:
|
||||
td_details = tidal_client.get_track(str(td_track_id))
|
||||
if td_details:
|
||||
pp["tidal_isrc"] = td_details.get("isrc")
|
||||
td_copyright = td_details.get("copyright")
|
||||
if isinstance(td_copyright, dict):
|
||||
td_copyright = td_copyright.get("text", td_copyright.get("name", ""))
|
||||
pp["tidal_copyright"] = td_copyright or None
|
||||
if not pp["release_year"]:
|
||||
td_album = td_result.get("album", {})
|
||||
td_release = ""
|
||||
if isinstance(td_album, dict):
|
||||
td_release = str(td_album.get("release_date", "") or td_album.get("releaseDate", "") or "")
|
||||
if len(td_release) >= 4 and td_release[:4].isdigit():
|
||||
pp["release_year"] = td_release[:4]
|
||||
except Exception as exc:
|
||||
logger_.error("Tidal lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if source_name == "qobuz":
|
||||
if cfg.get("qobuz.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
try:
|
||||
qobuz_worker = getattr(runtime, "qobuz_enrichment_worker", None)
|
||||
qz_client = qobuz_worker.client if qobuz_worker else None
|
||||
if not (qz_client and qz_client.is_authenticated()):
|
||||
continue
|
||||
qz_result = qz_client.search_track(artist_name, track_title)
|
||||
if qz_result:
|
||||
qz_performer = qz_result.get("performer") or {}
|
||||
if not isinstance(qz_performer, dict):
|
||||
qz_performer = {}
|
||||
qz_artist_name = qz_performer.get("name", "")
|
||||
if _names_match(qz_result.get("title", ""), track_title) and _names_match(qz_artist_name, artist_name):
|
||||
qz_track_id = qz_result.get("id")
|
||||
if qz_track_id:
|
||||
pp["id_tags"]["QOBUZ_TRACK_ID"] = str(qz_track_id)
|
||||
if qz_performer.get("id"):
|
||||
pp["id_tags"]["QOBUZ_ARTIST_ID"] = str(qz_performer["id"])
|
||||
qz_isrc = qz_result.get("isrc")
|
||||
if isinstance(qz_isrc, dict):
|
||||
qz_isrc = qz_isrc.get("value", qz_isrc.get("id", ""))
|
||||
if qz_isrc:
|
||||
pp["qobuz_isrc"] = qz_isrc
|
||||
qz_copyright = qz_result.get("copyright")
|
||||
if isinstance(qz_copyright, dict):
|
||||
qz_copyright = qz_copyright.get("text", qz_copyright.get("name", ""))
|
||||
if isinstance(qz_copyright, str):
|
||||
pp["qobuz_copyright"] = qz_copyright
|
||||
qz_album = qz_result.get("album", {})
|
||||
if isinstance(qz_album, dict):
|
||||
qz_label_info = qz_album.get("label", {})
|
||||
if isinstance(qz_label_info, dict) and qz_label_info.get("name"):
|
||||
pp["qobuz_label"] = qz_label_info["name"]
|
||||
if not pp["release_year"]:
|
||||
qz_release = str(qz_album.get("release_date_original", "") or "")
|
||||
if not qz_release:
|
||||
qz_ts = qz_album.get("released_at")
|
||||
if qz_ts and isinstance(qz_ts, (int, float)) and qz_ts > 0:
|
||||
import datetime as _dt
|
||||
qz_release = str(_dt.datetime.utcfromtimestamp(qz_ts).year)
|
||||
if len(qz_release) >= 4 and qz_release[:4].isdigit():
|
||||
pp["release_year"] = qz_release[:4]
|
||||
except Exception as exc:
|
||||
logger_.error("Qobuz lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if source_name == "lastfm":
|
||||
if cfg.get("lastfm.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
try:
|
||||
lastfm_worker = getattr(runtime, "lastfm_worker", None)
|
||||
lf_client = lastfm_worker.client if lastfm_worker else None
|
||||
if not lf_client:
|
||||
continue
|
||||
lf_result = lf_client.get_track_info(artist_name, track_title)
|
||||
if lf_result:
|
||||
lf_url = lf_result.get("url")
|
||||
if lf_url:
|
||||
pp["lastfm_url"] = lf_url
|
||||
lf_toptags = lf_result.get("toptags", {})
|
||||
if isinstance(lf_toptags, dict):
|
||||
tag_list = lf_toptags.get("tag", [])
|
||||
if isinstance(tag_list, list):
|
||||
pp["lastfm_tags"] = [tag.get("name", "") for tag in tag_list if isinstance(tag, dict) and tag.get("name")]
|
||||
elif isinstance(tag_list, dict) and tag_list.get("name"):
|
||||
pp["lastfm_tags"] = [tag_list["name"]]
|
||||
except Exception as exc:
|
||||
logger_.error("Last.fm lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if source_name == "genius":
|
||||
if cfg.get("genius.embed_tags", True) is False:
|
||||
continue
|
||||
if not track_title or not artist_name:
|
||||
continue
|
||||
try:
|
||||
import core.genius_client as _genius_module
|
||||
|
||||
if time.time() < _genius_module._rate_limit_until:
|
||||
logger_.info("Genius rate-limited, skipping (non-blocking)")
|
||||
continue
|
||||
genius_worker = getattr(runtime, "genius_worker", None)
|
||||
g_client = genius_worker.client if genius_worker else None
|
||||
if not g_client:
|
||||
continue
|
||||
g_result = g_client.search_song(artist_name, track_title)
|
||||
if g_result:
|
||||
g_id = g_result.get("id")
|
||||
if g_id:
|
||||
pp["id_tags"]["GENIUS_TRACK_ID"] = str(g_id)
|
||||
g_url = g_result.get("url")
|
||||
if g_url:
|
||||
pp["genius_url"] = g_url
|
||||
except Exception as exc:
|
||||
logger_.error("Genius lookup failed (non-fatal): %s", exc)
|
||||
continue
|
||||
|
||||
if not pp["id_tags"] and not pp["deezer_bpm"] and not pp["deezer_isrc"] and not pp["audiodb_mood"] and not pp["audiodb_style"]:
|
||||
return
|
||||
|
||||
filtered_tags: Dict[str, str] = {}
|
||||
for tag_name, value in pp["id_tags"].items():
|
||||
config_path = tag_config.get(tag_name)
|
||||
if config_path and not _tag_enabled(config_path):
|
||||
continue
|
||||
filtered_tags[tag_name] = value
|
||||
|
||||
written = []
|
||||
id3_tag_map = {
|
||||
"MUSICBRAINZ_RECORDING_ID": ("UFID", "http://musicbrainz.org"),
|
||||
"MUSICBRAINZ_ARTIST_ID": ("TXXX", "MusicBrainz Artist Id"),
|
||||
"MUSICBRAINZ_RELEASE_ID": ("TXXX", "MusicBrainz Album Id"),
|
||||
"MUSICBRAINZ_RELEASEGROUPID": ("TXXX", "MusicBrainz Release Group Id"),
|
||||
"MUSICBRAINZ_ALBUMARTISTID": ("TXXX", "MusicBrainz Album Artist Id"),
|
||||
"MUSICBRAINZ_RELEASETRACKID": ("TXXX", "MusicBrainz Release Track Id"),
|
||||
"RELEASETYPE": ("TXXX", "MusicBrainz Album Type"),
|
||||
"RELEASESTATUS": ("TXXX", "MusicBrainz Album Status"),
|
||||
"RELEASECOUNTRY": ("TXXX", "MusicBrainz Album Release Country"),
|
||||
"ORIGINALDATE": ("TDOR", None),
|
||||
"MEDIA": ("TMED", None),
|
||||
}
|
||||
vorbis_tag_map = {
|
||||
"MUSICBRAINZ_RECORDING_ID": "MUSICBRAINZ_TRACKID",
|
||||
"MUSICBRAINZ_ARTIST_ID": "MUSICBRAINZ_ARTISTID",
|
||||
"MUSICBRAINZ_RELEASE_ID": "MUSICBRAINZ_ALBUMID",
|
||||
"MUSICBRAINZ_RELEASEGROUPID": "MUSICBRAINZ_RELEASEGROUPID",
|
||||
"MUSICBRAINZ_ALBUMARTISTID": "MUSICBRAINZ_ALBUMARTISTID",
|
||||
"MUSICBRAINZ_RELEASETRACKID": "MUSICBRAINZ_RELEASETRACKID",
|
||||
}
|
||||
mp4_tag_map = {
|
||||
"MUSICBRAINZ_RECORDING_ID": "MusicBrainz Track Id",
|
||||
"MUSICBRAINZ_ARTIST_ID": "MusicBrainz Artist Id",
|
||||
"MUSICBRAINZ_RELEASE_ID": "MusicBrainz Album Id",
|
||||
"MUSICBRAINZ_RELEASEGROUPID": "MusicBrainz Release Group Id",
|
||||
"MUSICBRAINZ_ALBUMARTISTID": "MusicBrainz Album Artist Id",
|
||||
"MUSICBRAINZ_RELEASETRACKID": "MusicBrainz Release Track Id",
|
||||
"RELEASETYPE": "MusicBrainz Album Type",
|
||||
"RELEASESTATUS": "MusicBrainz Album Status",
|
||||
"RELEASECOUNTRY": "MusicBrainz Album Release Country",
|
||||
}
|
||||
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
for tag_name, value in filtered_tags.items():
|
||||
spec = id3_tag_map.get(tag_name)
|
||||
if spec:
|
||||
frame_type, desc = spec
|
||||
if frame_type == "UFID":
|
||||
audio_file.tags.add(symbols.UFID(owner=desc, data=str(value).encode("ascii")))
|
||||
written.append(f"UFID:{desc}")
|
||||
elif frame_type == "TDOR":
|
||||
audio_file.tags.add(symbols.TDOR(encoding=3, text=[value]))
|
||||
written.append("TDOR")
|
||||
elif frame_type == "TMED":
|
||||
audio_file.tags.add(symbols.TMED(encoding=3, text=[value]))
|
||||
written.append("TMED")
|
||||
else:
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc=desc, text=[value]))
|
||||
written.append(f"TXXX:{desc}")
|
||||
else:
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc=tag_name, text=[str(value)]))
|
||||
written.append(f"TXXX:{tag_name}")
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
# Keep the dedicated MP4 path last so the same tag maps can be reused.
|
||||
for tag_name, value in filtered_tags.items():
|
||||
key = f"----:com.apple.iTunes:{mp4_tag_map.get(tag_name, tag_name)}"
|
||||
audio_file[key] = [symbols.MP4FreeForm(str(value).encode("utf-8"))]
|
||||
written.append(key)
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
for tag_name, value in filtered_tags.items():
|
||||
audio_file[vorbis_tag_map.get(tag_name, tag_name)] = [str(value)]
|
||||
written.append(vorbis_tag_map.get(tag_name, tag_name))
|
||||
|
||||
if written:
|
||||
logger_.info("Embedded IDs: %s", ", ".join(written))
|
||||
|
||||
release_year = pp["release_year"]
|
||||
needs_date_tag = bool(release_year and not metadata.get("date"))
|
||||
if needs_date_tag:
|
||||
metadata["date"] = release_year
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TDRC(encoding=3, text=[release_year]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["date"] = [release_year]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["\xa9day"] = [release_year]
|
||||
logger_.info("Date tag: %s", release_year)
|
||||
|
||||
if _tag_enabled("deezer.tags.bpm") and pp["deezer_bpm"] and pp["deezer_bpm"] > 0:
|
||||
bpm_int = int(pp["deezer_bpm"])
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TBPM(encoding=3, text=[str(bpm_int)]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["BPM"] = [str(bpm_int)]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["tmpo"] = [bpm_int]
|
||||
logger_.info("BPM: %s", bpm_int)
|
||||
|
||||
if _tag_enabled("audiodb.tags.mood") and pp["audiodb_mood"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="MOOD", text=[pp["audiodb_mood"]]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["MOOD"] = [pp["audiodb_mood"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:MOOD"] = [symbols.MP4FreeForm(pp["audiodb_mood"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled("audiodb.tags.style") and pp["audiodb_style"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="STYLE", text=[pp["audiodb_style"]]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["STYLE"] = [pp["audiodb_style"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:STYLE"] = [symbols.MP4FreeForm(pp["audiodb_style"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled("metadata_enhancement.tags.genre_merge"):
|
||||
enrichment_genres = []
|
||||
if _tag_enabled("musicbrainz.tags.genres"):
|
||||
enrichment_genres += pp["mb_genres"]
|
||||
if pp["audiodb_genre"] and _tag_enabled("audiodb.tags.genre"):
|
||||
enrichment_genres.append(pp["audiodb_genre"])
|
||||
if _tag_enabled("lastfm.tags.genres"):
|
||||
enrichment_genres += pp["lastfm_tags"]
|
||||
if enrichment_genres:
|
||||
from core.genre_filter import filter_genres as _filter_genres
|
||||
|
||||
enrichment_genres = _filter_genres(enrichment_genres, cfg)
|
||||
source_genres = [g.strip() for g in str(metadata.get("genre", "")).split(",") if g.strip()]
|
||||
seen = set()
|
||||
merged = []
|
||||
for genre in source_genres + enrichment_genres:
|
||||
key = genre.strip().lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
merged.append(genre.strip().title())
|
||||
if len(merged) >= 5:
|
||||
break
|
||||
if merged:
|
||||
genre_string = ", ".join(merged)
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TCON(encoding=3, text=[genre_string]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["GENRE"] = [genre_string]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["\xa9gen"] = [genre_string]
|
||||
logger_.info("Genres merged: %s", genre_string)
|
||||
|
||||
isrc_candidates = []
|
||||
if pp["isrc"] and _tag_enabled("musicbrainz.tags.isrc"):
|
||||
isrc_candidates.append(("MusicBrainz", pp["isrc"]))
|
||||
if pp["deezer_isrc"] and _tag_enabled("deezer.tags.isrc"):
|
||||
isrc_candidates.append(("Deezer", pp["deezer_isrc"]))
|
||||
if pp["tidal_isrc"] and _tag_enabled("tidal.tags.isrc"):
|
||||
isrc_candidates.append(("Tidal", pp["tidal_isrc"]))
|
||||
if pp["qobuz_isrc"] and _tag_enabled("qobuz.tags.isrc"):
|
||||
isrc_candidates.append(("Qobuz", pp["qobuz_isrc"]))
|
||||
if isrc_candidates:
|
||||
isrc_source, final_isrc = isrc_candidates[0]
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TSRC(encoding=3, text=[final_isrc]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["ISRC"] = [final_isrc]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:ISRC"] = [symbols.MP4FreeForm(final_isrc.encode("utf-8"))]
|
||||
logger_.info("ISRC (%s): %s", isrc_source, final_isrc)
|
||||
|
||||
copyright_candidates = []
|
||||
if pp["tidal_copyright"] and _tag_enabled("tidal.tags.copyright"):
|
||||
copyright_candidates.append(("Tidal", pp["tidal_copyright"]))
|
||||
if pp["qobuz_copyright"] and _tag_enabled("qobuz.tags.copyright"):
|
||||
copyright_candidates.append(("Qobuz", pp["qobuz_copyright"]))
|
||||
if copyright_candidates:
|
||||
copyright_source, final_copyright = copyright_candidates[0]
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TCOP(encoding=3, text=[final_copyright]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["COPYRIGHT"] = [final_copyright]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["cprt"] = [final_copyright]
|
||||
logger_.info("Copyright (%s): %s", copyright_source, final_copyright[:60])
|
||||
|
||||
if _tag_enabled("qobuz.tags.label") and pp["qobuz_label"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TPUB(encoding=3, text=[pp["qobuz_label"]]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["LABEL"] = [pp["qobuz_label"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:LABEL"] = [symbols.MP4FreeForm(pp["qobuz_label"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled("lastfm.tags.url") and pp["lastfm_url"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="LASTFM_URL", text=[pp["lastfm_url"]]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["LASTFM_URL"] = [pp["lastfm_url"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:LASTFM_URL"] = [symbols.MP4FreeForm(pp["lastfm_url"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled("genius.tags.url") and pp["genius_url"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="GENIUS_URL", text=[pp["genius_url"]]))
|
||||
elif _is_vorbis_like(audio_file, symbols):
|
||||
audio_file["GENIUS_URL"] = [pp["genius_url"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:GENIUS_URL"] = [symbols.MP4FreeForm(pp["genius_url"].encode("utf-8"))]
|
||||
|
||||
release_id = pp["release_mbid"]
|
||||
if release_id:
|
||||
metadata["musicbrainz_release_id"] = release_id
|
||||
if db is not None:
|
||||
try:
|
||||
album_name_for_db = metadata.get("album", "")
|
||||
album_artist_for_db = metadata.get("album_artist", "") or metadata.get("artist", "")
|
||||
if album_name_for_db and album_artist_for_db:
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE albums SET year = ?
|
||||
WHERE (year IS NULL OR year = 0)
|
||||
AND id IN (
|
||||
SELECT al.id FROM albums al
|
||||
JOIN artists ar ON ar.id = al.artist_id
|
||||
WHERE LOWER(al.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
|
||||
)
|
||||
""",
|
||||
(int(release_year), album_name_for_db, album_artist_for_db),
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
conn.commit()
|
||||
logger_.info("Updated album year to %s in database", release_year)
|
||||
else:
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger_.error("Could not update album year in DB: %s", exc)
|
||||
|
||||
except Exception as exc:
|
||||
logger_.error("Error embedding source IDs (non-fatal): %s", exc)
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import logging
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from core import metadata_enrichment as me
|
||||
from core import metadata_artwork as ma
|
||||
from core import metadata_source as ms
|
||||
|
||||
|
||||
class _Config:
|
||||
|
|
@ -110,20 +109,9 @@ def _fake_symbols(audio):
|
|||
)
|
||||
|
||||
|
||||
def test_extract_source_metadata_keeps_neutral_fields_and_skips_itunes_fallback_for_non_itunes_sources():
|
||||
class _ItunesClient:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def resolve_primary_artist(self, artist_id):
|
||||
self.called = True
|
||||
raise AssertionError("itunes fallback should not run for non-itunes sources")
|
||||
|
||||
runtime = types.SimpleNamespace(
|
||||
logger=logging.getLogger("test.metadata_enrichment"),
|
||||
config_manager=_Config({"file_organization.collab_artist_mode": "first"}),
|
||||
itunes_enrichment_worker=types.SimpleNamespace(client=_ItunesClient()),
|
||||
)
|
||||
def test_extract_source_metadata_keeps_neutral_fields_and_skips_itunes_fallback_for_non_itunes_sources(monkeypatch):
|
||||
monkeypatch.setattr(ms, "_get_config_manager", lambda: _Config({"file_organization.collab_artist_mode": "first"}))
|
||||
monkeypatch.setattr(ms, "_get_itunes_client", lambda: (_ for _ in ()).throw(AssertionError("itunes fallback should not run for non-itunes sources")))
|
||||
|
||||
context = {
|
||||
"source": "spotify",
|
||||
|
|
@ -156,7 +144,6 @@ def test_extract_source_metadata_keeps_neutral_fields_and_skips_itunes_fallback_
|
|||
context,
|
||||
context["artist"],
|
||||
{"is_album": True, "album_name": "Album One", "track_number": 3, "disc_number": 2, "album_image_url": "https://img.example/album.jpg"},
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert metadata["source"] == "spotify"
|
||||
|
|
@ -168,27 +155,14 @@ def test_extract_source_metadata_keeps_neutral_fields_and_skips_itunes_fallback_
|
|||
assert metadata["total_tracks"] == 12
|
||||
assert metadata["disc_number"] == 2
|
||||
assert metadata["album_art_url"] == "https://img.example/album.jpg"
|
||||
assert runtime.itunes_enrichment_worker.client.called is False
|
||||
|
||||
|
||||
def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatch):
|
||||
runtime = types.SimpleNamespace(
|
||||
logger=logging.getLogger("test.metadata_enrichment"),
|
||||
config_manager=_Config(),
|
||||
mb_worker=None,
|
||||
deezer_worker=None,
|
||||
audiodb_worker=None,
|
||||
tidal_client=None,
|
||||
qobuz_enrichment_worker=None,
|
||||
lastfm_worker=None,
|
||||
genius_worker=None,
|
||||
itunes_enrichment_worker=None,
|
||||
get_database=lambda: None,
|
||||
)
|
||||
|
||||
audio = _FakeAudio()
|
||||
symbols = _fake_symbols(audio)
|
||||
monkeypatch.setattr(me, "_get_mutagen_symbols", lambda runtime=None: symbols)
|
||||
monkeypatch.setattr(ms, "_get_config_manager", lambda: _Config())
|
||||
monkeypatch.setattr(ms, "_get_mutagen_symbols", lambda: symbols)
|
||||
monkeypatch.setattr(ms, "_get_database", lambda: None)
|
||||
|
||||
current_metadata = {
|
||||
"source": "deezer",
|
||||
|
|
@ -200,7 +174,7 @@ def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatc
|
|||
"album_artist": "Artist One",
|
||||
"album": "Album One",
|
||||
}
|
||||
me.embed_source_ids(audio, current_metadata, context={"track_info": {}, "original_search_result": {}}, runtime=runtime)
|
||||
me.embed_source_ids(audio, current_metadata, context={"track_info": {}, "original_search_result": {}})
|
||||
|
||||
current_descs = [frame.kwargs.get("desc") for frame in audio.tags.added if frame.kind == "TXXX"]
|
||||
assert "DEEZER_TRACK_ID" in current_descs
|
||||
|
|
@ -208,7 +182,7 @@ def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatc
|
|||
|
||||
audio = _FakeAudio()
|
||||
symbols = _fake_symbols(audio)
|
||||
monkeypatch.setattr(me, "_get_mutagen_symbols", lambda runtime=None: symbols)
|
||||
monkeypatch.setattr(ms, "_get_mutagen_symbols", lambda: symbols)
|
||||
|
||||
legacy_metadata = {
|
||||
"source": "",
|
||||
|
|
@ -223,7 +197,7 @@ def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatc
|
|||
"album_artist": "Artist One",
|
||||
"album": "Album One",
|
||||
}
|
||||
me.embed_source_ids(audio, legacy_metadata, context={"track_info": {}, "original_search_result": {}}, runtime=runtime)
|
||||
me.embed_source_ids(audio, legacy_metadata, context={"track_info": {}, "original_search_result": {}})
|
||||
|
||||
legacy_descs = [frame.kwargs.get("desc") for frame in audio.tags.added if frame.kind == "TXXX"]
|
||||
assert "SPOTIFY_TRACK_ID" in legacy_descs
|
||||
|
|
@ -237,35 +211,25 @@ def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatc
|
|||
def test_enhance_file_metadata_writes_tags_and_propagates_release_id(monkeypatch):
|
||||
audio = _FakeAudio()
|
||||
symbols = _fake_symbols(audio)
|
||||
runtime = types.SimpleNamespace(
|
||||
logger=logging.getLogger("test.metadata_enrichment"),
|
||||
config_manager=_Config(
|
||||
{
|
||||
"metadata_enhancement.enabled": True,
|
||||
"metadata_enhancement.embed_album_art": False,
|
||||
"metadata_enhancement.tags.write_multi_artist": False,
|
||||
}
|
||||
),
|
||||
mb_worker=None,
|
||||
deezer_worker=None,
|
||||
audiodb_worker=None,
|
||||
tidal_client=None,
|
||||
qobuz_enrichment_worker=None,
|
||||
lastfm_worker=None,
|
||||
genius_worker=None,
|
||||
itunes_enrichment_worker=None,
|
||||
get_database=lambda: None,
|
||||
)
|
||||
|
||||
strip_calls = []
|
||||
verify_calls = []
|
||||
|
||||
monkeypatch.setattr(me, "_get_mutagen_symbols", lambda runtime=None: symbols)
|
||||
monkeypatch.setattr(me, "_strip_all_non_audio_tags", lambda file_path, runtime=None: strip_calls.append(file_path) or {"apev2_stripped": False, "apev2_tag_count": 0})
|
||||
monkeypatch.setattr(me, "_get_config_manager", lambda: _Config(
|
||||
{
|
||||
"metadata_enhancement.enabled": True,
|
||||
"metadata_enhancement.embed_album_art": False,
|
||||
"metadata_enhancement.tags.write_multi_artist": False,
|
||||
}
|
||||
))
|
||||
monkeypatch.setattr(ms, "_get_config_manager", lambda: _Config())
|
||||
monkeypatch.setattr(me, "_get_mutagen_symbols", lambda: symbols)
|
||||
monkeypatch.setattr(ms, "_get_mutagen_symbols", lambda: symbols)
|
||||
monkeypatch.setattr(ms, "_get_database", lambda: None)
|
||||
monkeypatch.setattr(me, "_strip_all_non_audio_tags", lambda file_path: strip_calls.append(file_path) or {"apev2_stripped": False, "apev2_tag_count": 0})
|
||||
monkeypatch.setattr(
|
||||
me,
|
||||
"extract_source_metadata",
|
||||
lambda context, artist, album_info, runtime=None: {
|
||||
lambda context, artist, album_info: {
|
||||
"source": "deezer",
|
||||
"source_track_id": "dz-track",
|
||||
"source_artist_id": "dz-artist",
|
||||
|
|
@ -283,7 +247,7 @@ def test_enhance_file_metadata_writes_tags_and_propagates_release_id(monkeypatch
|
|||
},
|
||||
)
|
||||
monkeypatch.setattr(me, "embed_album_art_metadata", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(me, "_verify_metadata_written", lambda file_path, runtime=None: verify_calls.append(file_path) or True)
|
||||
monkeypatch.setattr(me, "_verify_metadata_written", lambda file_path: verify_calls.append(file_path) or True)
|
||||
|
||||
album_info = {}
|
||||
result = me.enhance_file_metadata(
|
||||
|
|
@ -291,7 +255,6 @@ def test_enhance_file_metadata_writes_tags_and_propagates_release_id(monkeypatch
|
|||
{"_audio_quality": ""},
|
||||
{"name": "Artist One"},
|
||||
album_info,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
|
@ -306,17 +269,14 @@ def test_enhance_file_metadata_writes_tags_and_propagates_release_id(monkeypatch
|
|||
|
||||
|
||||
def test_download_cover_art_uses_album_context_image_url(tmp_path, monkeypatch):
|
||||
runtime = types.SimpleNamespace(
|
||||
logger=logging.getLogger("test.metadata_enrichment"),
|
||||
config_manager=_Config(
|
||||
{
|
||||
"metadata_enhancement.cover_art_download": True,
|
||||
"metadata_enhancement.prefer_caa_art": False,
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(ma, "_get_config_manager", lambda: _Config(
|
||||
{
|
||||
"metadata_enhancement.cover_art_download": True,
|
||||
"metadata_enhancement.prefer_caa_art": False,
|
||||
}
|
||||
))
|
||||
|
||||
monkeypatch.setattr(me.urllib.request, "urlopen", lambda *args, **kwargs: _FakeResponse(b"cover-bytes"))
|
||||
monkeypatch.setattr(ma.urllib.request, "urlopen", lambda *args, **kwargs: _FakeResponse(b"cover-bytes"))
|
||||
|
||||
target_dir = tmp_path / "Album One"
|
||||
target_dir.mkdir()
|
||||
|
|
@ -325,7 +285,6 @@ def test_download_cover_art_uses_album_context_image_url(tmp_path, monkeypatch):
|
|||
{},
|
||||
str(target_dir),
|
||||
{"album": {"image_url": "https://img.example/album.jpg"}},
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
cover_path = target_dir / "cover.jpg"
|
||||
|
|
|
|||
1656
web_server.py
1656
web_server.py
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue