Merge pull request #617 from Nezreka/feature/amazon-music-metadata
Feature/amazon music metadata
This commit is contained in:
commit
46f27583f5
24 changed files with 1447 additions and 85 deletions
|
|
@ -19,6 +19,7 @@ Config keys (all optional — fall back to public defaults):
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -33,6 +34,28 @@ from utils.logging_config import get_logger
|
|||
|
||||
logger = get_logger("amazon_client")
|
||||
|
||||
# Strips featuring credits like "Artist feat. X", "Artist ft. Y" so artist
|
||||
# deduplication works on the primary artist name only.
|
||||
_FEAT_RE = re.compile(r'\s+(?:feat(?:uring)?\.?|ft\.?)\s+.*', re.IGNORECASE)
|
||||
|
||||
# Strips the Explicit marker — explicit is treated as the default version.
|
||||
# Clean/Edited/Censored stay in the name so users can distinguish them.
|
||||
_EDITION_RE = re.compile(r'\s*[\[\(]explicit[\]\)]', re.IGNORECASE)
|
||||
|
||||
|
||||
def _primary_artist(name: str) -> str:
|
||||
return _FEAT_RE.sub('', name).strip()
|
||||
|
||||
|
||||
def _strip_edition(name: str) -> str:
|
||||
return _EDITION_RE.sub('', name).strip()
|
||||
|
||||
|
||||
def _unslugify(name: str) -> str:
|
||||
"""Convert a slug-form artist ID (e.g. 'kendrick_lamar') to a search name."""
|
||||
return name.replace('_', ' ')
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "https://t2tunes.site"
|
||||
DEFAULT_COUNTRY = "US"
|
||||
DEFAULT_CODEC = "flac"
|
||||
|
|
@ -41,6 +64,10 @@ MIN_API_INTERVAL = 0.5 # seconds — T2Tunes has no published rate limit
|
|||
_last_api_call: float = 0.0
|
||||
_api_call_lock = threading.Lock()
|
||||
|
||||
_META_CACHE_TTL = 300 # seconds
|
||||
_meta_cache: Dict[str, tuple] = {} # asin -> (fetched_at, meta_dict)
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
class AmazonClientError(RuntimeError):
|
||||
"""Raised on unrecoverable T2Tunes API errors."""
|
||||
|
|
@ -146,7 +173,7 @@ class Album:
|
|||
id=str(album_meta.get("asin") or asin or ""),
|
||||
name=str(album_meta.get("title") or ""),
|
||||
artists=[str(album_meta.get("artistName") or "Unknown Artist")],
|
||||
release_date=str(album_meta.get("release_date") or ""),
|
||||
release_date=str(album_meta.get("release_date") or album_meta.get("releaseDate") or ""),
|
||||
total_tracks=int(album_meta.get("trackCount") or 0),
|
||||
album_type="album",
|
||||
image_url=album_meta.get("image"),
|
||||
|
|
@ -300,55 +327,95 @@ class AmazonClient:
|
|||
|
||||
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||
_rate_limit()
|
||||
items = self.search_raw(query, types="track")
|
||||
tracks: List[Track] = []
|
||||
items = self.search_raw(query, types="track,album")
|
||||
track_pairs: List[tuple] = [] # (Track, album_asin)
|
||||
seen_album_asins: List[str] = []
|
||||
for item in items:
|
||||
if not item.is_track:
|
||||
continue
|
||||
tracks.append(Track.from_search_hit({
|
||||
track = Track.from_search_hit({
|
||||
"asin": item.asin,
|
||||
"title": item.title,
|
||||
"artistName": item.artist_name,
|
||||
"albumName": item.album_name,
|
||||
"title": _strip_edition(item.title),
|
||||
"artistName": _primary_artist(item.artist_name),
|
||||
"albumName": _strip_edition(item.album_name),
|
||||
"albumAsin": item.album_asin,
|
||||
"duration": item.duration_seconds,
|
||||
"isrc": item.isrc,
|
||||
}))
|
||||
if len(tracks) >= limit:
|
||||
})
|
||||
track_pairs.append((track, item.album_asin))
|
||||
if item.album_asin and item.album_asin not in seen_album_asins:
|
||||
seen_album_asins.append(item.album_asin)
|
||||
if len(track_pairs) >= limit:
|
||||
break
|
||||
album_metas = self._fetch_album_metas(seen_album_asins[:5])
|
||||
tracks: List[Track] = []
|
||||
for track, album_asin in track_pairs:
|
||||
if album_asin and album_asin in album_metas:
|
||||
meta = album_metas[album_asin]
|
||||
track.image_url = meta.get("image")
|
||||
track.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
||||
track.total_tracks = meta.get("trackCount")
|
||||
tracks.append(track)
|
||||
return tracks
|
||||
|
||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||
_rate_limit()
|
||||
items = self.search_raw(query, types="track")
|
||||
items = self.search_raw(query, types="track,album")
|
||||
seen: Dict[str, Artist] = {}
|
||||
artist_album_asin: Dict[str, str] = {} # artist name → first album ASIN seen
|
||||
for item in items:
|
||||
name = item.artist_name
|
||||
if name and name not in seen:
|
||||
name = _primary_artist(item.artist_name)
|
||||
if not name:
|
||||
continue
|
||||
if name not in seen:
|
||||
seen[name] = Artist.from_name(name)
|
||||
if name not in artist_album_asin and item.album_asin:
|
||||
artist_album_asin[name] = item.album_asin
|
||||
if len(seen) >= limit:
|
||||
break
|
||||
# T2Tunes has no artist images — use an album cover as stand-in.
|
||||
unique_asins = list({v for v in artist_album_asin.values()})[:5]
|
||||
album_metas = self._fetch_album_metas(unique_asins)
|
||||
for name, artist in seen.items():
|
||||
asin = artist_album_asin.get(name)
|
||||
if asin and asin in album_metas:
|
||||
artist.image_url = album_metas[asin].get("image")
|
||||
return list(seen.values())
|
||||
|
||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||
_rate_limit()
|
||||
items = self.search_raw(query, types="album")
|
||||
albums: List[Album] = []
|
||||
seen_asins: set = set()
|
||||
items = self.search_raw(query, types="track,album")
|
||||
album_candidates: List[tuple] = [] # (Album, asin)
|
||||
seen_keys: set = set()
|
||||
for item in items:
|
||||
if not item.is_album:
|
||||
continue
|
||||
album_asin = item.album_asin or item.asin
|
||||
if album_asin in seen_asins:
|
||||
raw_name = item.album_name or item.title
|
||||
display_name = _strip_edition(raw_name)
|
||||
artist = _primary_artist(item.artist_name)
|
||||
# Collapse Explicit/Clean variants: same normalised name + artist = same album
|
||||
dedup_key = (display_name.lower(), artist.lower())
|
||||
if dedup_key in seen_keys:
|
||||
continue
|
||||
seen_asins.add(album_asin)
|
||||
albums.append(Album.from_search_hit({
|
||||
seen_keys.add(dedup_key)
|
||||
album = Album.from_search_hit({
|
||||
"albumAsin": album_asin,
|
||||
"albumName": item.album_name or item.title,
|
||||
"artistName": item.artist_name,
|
||||
}))
|
||||
if len(albums) >= limit:
|
||||
"albumName": display_name,
|
||||
"artistName": artist,
|
||||
})
|
||||
album_candidates.append((album, album_asin))
|
||||
if len(album_candidates) >= limit:
|
||||
break
|
||||
album_metas = self._fetch_album_metas([a for _, a in album_candidates[:10]])
|
||||
albums: List[Album] = []
|
||||
for album, asin in album_candidates:
|
||||
if asin in album_metas:
|
||||
meta = album_metas[asin]
|
||||
album.image_url = meta.get("image")
|
||||
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
||||
album.total_tracks = int(meta.get("trackCount") or 0)
|
||||
albums.append(album)
|
||||
return albums
|
||||
|
||||
def get_track_details(self, asin: str) -> Optional[Dict[str, Any]]:
|
||||
|
|
@ -373,20 +440,20 @@ class AmazonClient:
|
|||
|
||||
return {
|
||||
"id": s.asin,
|
||||
"name": s.title,
|
||||
"artists": [{"name": s.artist, "id": ""}],
|
||||
"name": _strip_edition(s.title),
|
||||
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
|
||||
"album": {
|
||||
"id": album_data.get("asin", ""),
|
||||
"name": s.album,
|
||||
"name": _strip_edition(s.album),
|
||||
"images": [{"url": album_data["image"]}] if album_data.get("image") else [],
|
||||
"release_date": album_data.get("release_date", ""),
|
||||
"release_date": album_data.get("release_date") or album_data.get("releaseDate") or s.date or "",
|
||||
"total_tracks": album_data.get("trackCount", 0),
|
||||
},
|
||||
"duration_ms": 0,
|
||||
"popularity": 0,
|
||||
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
|
||||
"track_number": None,
|
||||
"disc_number": None,
|
||||
"track_number": s.track_number,
|
||||
"disc_number": s.disc_number,
|
||||
"isrc": s.isrc,
|
||||
"is_album_track": True,
|
||||
"raw_data": {
|
||||
|
|
@ -413,9 +480,9 @@ class AmazonClient:
|
|||
|
||||
result: Dict[str, Any] = {
|
||||
"id": asin,
|
||||
"name": album.get("title", ""),
|
||||
"artists": [{"name": album.get("artistName", ""), "id": ""}],
|
||||
"release_date": album.get("release_date", ""),
|
||||
"name": _strip_edition(album.get("title", "")),
|
||||
"artists": [{"name": _primary_artist(album.get("artistName", "")), "id": ""}],
|
||||
"release_date": album.get("release_date") or album.get("releaseDate") or "",
|
||||
"total_tracks": album.get("trackCount", 0),
|
||||
"album_type": "album",
|
||||
"images": [{"url": album["image"]}] if album.get("image") else [],
|
||||
|
|
@ -423,12 +490,18 @@ class AmazonClient:
|
|||
"label": album.get("label", ""),
|
||||
}
|
||||
if include_tracks:
|
||||
result["tracks"] = self.get_album_tracks(asin) or {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"limit": 50,
|
||||
"next": None,
|
||||
tracks_data = self.get_album_tracks(asin) or {
|
||||
"items": [], "total": 0, "limit": 50, "next": None,
|
||||
}
|
||||
result["tracks"] = tracks_data
|
||||
# Backfill release_date from stream tags when album metadata lacks it.
|
||||
if not result["release_date"]:
|
||||
items = tracks_data.get("items") or []
|
||||
for item in items:
|
||||
rd = item.get("release_date") or ""
|
||||
if rd and len(rd) >= 4:
|
||||
result["release_date"] = rd
|
||||
break
|
||||
return result
|
||||
|
||||
def get_album_tracks(self, asin: str) -> Optional[Dict[str, Any]]:
|
||||
|
|
@ -438,14 +511,31 @@ class AmazonClient:
|
|||
streams = self.media_from_asin(asin)
|
||||
except AmazonClientError:
|
||||
return None
|
||||
|
||||
# media_from_asin has no duration — enrich from search results which do.
|
||||
duration_map: Dict[str, int] = {} # track asin → duration_ms
|
||||
if streams:
|
||||
album_name = _strip_edition(streams[0].album)
|
||||
artist_name = _primary_artist(streams[0].artist)
|
||||
try:
|
||||
search_items = self.search_raw(
|
||||
f"{album_name} {artist_name}", types="track,album"
|
||||
)
|
||||
for item in search_items:
|
||||
if item.album_asin == asin and item.duration_seconds:
|
||||
duration_map[item.asin] = item.duration_seconds * 1000
|
||||
except Exception as exc:
|
||||
logger.debug("Duration backfill failed for ASIN %s: %s", asin, exc)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": s.asin,
|
||||
"name": s.title,
|
||||
"artists": [{"name": s.artist, "id": ""}],
|
||||
"duration_ms": 0,
|
||||
"track_number": None,
|
||||
"disc_number": None,
|
||||
"name": _strip_edition(s.title),
|
||||
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
|
||||
"duration_ms": duration_map.get(s.asin, 0),
|
||||
"track_number": s.track_number,
|
||||
"disc_number": s.disc_number,
|
||||
"release_date": s.date or "",
|
||||
"isrc": s.isrc,
|
||||
}
|
||||
for s in streams
|
||||
|
|
@ -455,14 +545,15 @@ class AmazonClient:
|
|||
def get_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a Spotify-compatible artist dict inferred from search results."""
|
||||
_rate_limit()
|
||||
search_name = _unslugify(artist_name)
|
||||
try:
|
||||
items = self.search_raw(artist_name, types="track")
|
||||
items = self.search_raw(search_name, types="track,album")
|
||||
except AmazonClientError:
|
||||
return None
|
||||
name_lower = artist_name.lower()
|
||||
name_lower = search_name.lower()
|
||||
match = next(
|
||||
(i for i in items if i.artist_name.lower() == name_lower),
|
||||
next((i for i in items if name_lower in i.artist_name.lower()), None),
|
||||
(i for i in items if _primary_artist(i.artist_name).lower() == name_lower),
|
||||
next((i for i in items if name_lower in _primary_artist(i.artist_name).lower()), None),
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
|
|
@ -484,37 +575,115 @@ class AmazonClient:
|
|||
) -> List[Album]:
|
||||
"""Return albums for an artist inferred from search results."""
|
||||
_rate_limit()
|
||||
search_name = _unslugify(artist_name)
|
||||
try:
|
||||
items = self.search_raw(f"{artist_name} album", types="album")
|
||||
items = self.search_raw(f"{search_name} album", types="track,album")
|
||||
except AmazonClientError:
|
||||
return []
|
||||
albums: List[Album] = []
|
||||
album_candidates: List[tuple] = [] # (Album, asin)
|
||||
seen_asins: set = set()
|
||||
name_lower = artist_name.lower()
|
||||
name_lower = search_name.lower()
|
||||
for item in items:
|
||||
if item.artist_name.lower() != name_lower:
|
||||
if not item.is_album:
|
||||
continue
|
||||
if _primary_artist(item.artist_name).lower() != name_lower:
|
||||
continue
|
||||
album_asin = item.album_asin or item.asin
|
||||
if album_asin in seen_asins:
|
||||
continue
|
||||
seen_asins.add(album_asin)
|
||||
albums.append(Album.from_search_hit({
|
||||
album = Album.from_search_hit({
|
||||
"albumAsin": album_asin,
|
||||
"albumName": item.album_name or item.title,
|
||||
"artistName": item.artist_name,
|
||||
}))
|
||||
if len(albums) >= limit:
|
||||
"albumName": _strip_edition(item.album_name or item.title),
|
||||
"artistName": _primary_artist(item.artist_name),
|
||||
})
|
||||
album_candidates.append((album, album_asin))
|
||||
if len(album_candidates) >= limit:
|
||||
break
|
||||
|
||||
# Fetch metadata for art, release_date, track_count, and type inference.
|
||||
# No explicit cap here — search_raw naturally bounds results to ~20 items,
|
||||
# so album_candidates is already small.
|
||||
asins_to_fetch = [a for _, a in album_candidates]
|
||||
metas = self._fetch_album_metas(asins_to_fetch)
|
||||
|
||||
albums: List[Album] = []
|
||||
for album, asin in album_candidates:
|
||||
meta = metas.get(asin, {})
|
||||
if meta:
|
||||
album.image_url = meta.get("image")
|
||||
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
||||
total = int(meta.get("trackCount") or 0)
|
||||
album.total_tracks = total
|
||||
# T2Tunes doesn't expose release type — infer from track count.
|
||||
# 1-track releases are singles; keep default "album" otherwise.
|
||||
if total == 1:
|
||||
album.album_type = "single"
|
||||
albums.append(album)
|
||||
return albums
|
||||
|
||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Not available from Amazon Music — returns None for compatibility."""
|
||||
return None
|
||||
|
||||
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
|
||||
"""Return an album cover as artist image stand-in (T2Tunes has no artist images)."""
|
||||
search_name = _unslugify(artist_id)
|
||||
try:
|
||||
items = self.search_raw(search_name, types="track,album")
|
||||
except AmazonClientError:
|
||||
return None
|
||||
name_lower = search_name.lower()
|
||||
for item in items:
|
||||
if _primary_artist(item.artist_name).lower() != name_lower:
|
||||
continue
|
||||
asin = item.album_asin or item.asin
|
||||
if not asin:
|
||||
continue
|
||||
metas = self._fetch_album_metas([asin])
|
||||
if asin in metas and metas[asin].get("image"):
|
||||
return metas[asin]["image"]
|
||||
return None
|
||||
|
||||
# ==================== Interface Aliases (match DeezerClient method names) ====================
|
||||
get_album_metadata = get_album
|
||||
get_artist_info = get_artist
|
||||
get_artist_albums_list = get_artist_albums
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fetch_album_metas(self, asins: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Parallel-fetch album metadata for up to N ASINs. Returns {asin: albumList[0]}."""
|
||||
if not asins:
|
||||
return {}
|
||||
metas: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _fetch(asin: str) -> None:
|
||||
now = time.monotonic()
|
||||
with _meta_cache_lock:
|
||||
entry = _meta_cache.get(asin)
|
||||
if entry and (now - entry[0]) < _META_CACHE_TTL:
|
||||
metas[asin] = entry[1]
|
||||
return
|
||||
_rate_limit()
|
||||
try:
|
||||
raw = self.album_metadata(asin)
|
||||
lst = raw.get("albumList")
|
||||
if isinstance(lst, list) and lst and isinstance(lst[0], dict):
|
||||
meta = lst[0]
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[asin] = (time.monotonic(), meta)
|
||||
metas[asin] = meta
|
||||
except Exception as exc:
|
||||
logger.debug("Album metadata fetch failed for ASIN %s: %s", asin, exc)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=min(len(asins), 5)) as pool:
|
||||
list(pool.map(_fetch, asins))
|
||||
return metas
|
||||
|
||||
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
try:
|
||||
|
|
|
|||
566
core/amazon_worker.py
Normal file
566
core/amazon_worker.py
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
import re
|
||||
import threading
|
||||
import time
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.amazon_client import AmazonClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("amazon_worker")
|
||||
|
||||
|
||||
class AmazonWorker:
|
||||
"""Background worker for enriching library artists, albums, and tracks with Amazon Music metadata."""
|
||||
|
||||
def __init__(self, database: MusicDatabase):
|
||||
self.db = database
|
||||
self.client = AmazonClient()
|
||||
|
||||
self.running = False
|
||||
self.paused = False
|
||||
self.should_stop = False
|
||||
self.thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self.current_item = None
|
||||
|
||||
self.stats = {
|
||||
'matched': 0,
|
||||
'not_found': 0,
|
||||
'pending': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
|
||||
self.retry_days = 30
|
||||
self.name_similarity_threshold = 0.80
|
||||
|
||||
logger.info("Amazon background worker initialized")
|
||||
|
||||
def start(self):
|
||||
if self.running:
|
||||
logger.warning("Worker already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.should_stop = False
|
||||
self._stop_event.clear()
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
logger.info("Amazon background worker started")
|
||||
|
||||
def stop(self):
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Amazon worker...")
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1)
|
||||
|
||||
logger.info("Amazon worker stopped")
|
||||
|
||||
def pause(self):
|
||||
if not self.running:
|
||||
logger.warning("Worker not running, cannot pause")
|
||||
return
|
||||
self.paused = True
|
||||
logger.info("Amazon worker paused")
|
||||
|
||||
def resume(self):
|
||||
if not self.running:
|
||||
logger.warning("Worker not running, start it first")
|
||||
return
|
||||
self.paused = False
|
||||
logger.info("Amazon worker resumed")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
self.stats['pending'] = self._count_pending_items()
|
||||
progress = self._get_progress_breakdown()
|
||||
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
|
||||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress,
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
logger.info("Amazon worker thread started")
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
interruptible_sleep(self._stop_event, 1)
|
||||
continue
|
||||
|
||||
self.current_item = None
|
||||
item = self._get_next_item()
|
||||
|
||||
if not item:
|
||||
logger.debug("No pending items, sleeping...")
|
||||
interruptible_sleep(self._stop_event, 10)
|
||||
continue
|
||||
|
||||
self.current_item = item
|
||||
item_id = item.get('id') or item.get('artist_id') or item.get('album_id')
|
||||
if item_id is None:
|
||||
logger.warning(f"Skipping {item.get('type', 'unknown')} with NULL id: {item.get('name', '?')}")
|
||||
continue
|
||||
|
||||
self._process_item(item)
|
||||
interruptible_sleep(self._stop_event, 2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
interruptible_sleep(self._stop_event, 5)
|
||||
|
||||
logger.info("Amazon worker thread finished")
|
||||
|
||||
def _get_next_item(self) -> Optional[Dict[str, Any]]:
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name FROM artists
|
||||
WHERE amazon_match_status IS NULL AND id IS NOT NULL
|
||||
ORDER BY id ASC LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 2: Unattempted albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.amazon_match_status IS NULL AND a.id IS NOT NULL
|
||||
ORDER BY a.id ASC LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
# Priority 3: Unattempted tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.amazon_match_status IS NULL AND t.id IS NOT NULL
|
||||
ORDER BY t.id ASC LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
||||
|
||||
# Priority 4: Retry not_found artists
|
||||
cursor.execute("""
|
||||
SELECT id, name FROM artists
|
||||
WHERE amazon_match_status = 'not_found' AND amazon_last_attempted < ?
|
||||
ORDER BY amazon_last_attempted ASC LIMIT 1
|
||||
""", (not_found_cutoff,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 5: Retry not_found albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.amazon_match_status = 'not_found' AND a.amazon_last_attempted < ?
|
||||
ORDER BY a.amazon_last_attempted ASC LIMIT 1
|
||||
""", (not_found_cutoff,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
# Priority 6: Retry not_found tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.amazon_match_status = 'not_found' AND t.amazon_last_attempted < ?
|
||||
ORDER BY t.amazon_last_attempted ASC LIMIT 1
|
||||
""", (not_found_cutoff,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting next item: {e}")
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _normalize_name(self, name: str) -> str:
|
||||
name = name.lower().strip()
|
||||
name = re.sub(r'\s+[-–—]\s+.*$', '', name)
|
||||
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
|
||||
name = re.sub(r'[^\w\s]', '', name)
|
||||
name = re.sub(r'\s+', ' ', name).strip()
|
||||
return name
|
||||
|
||||
def _name_matches(self, query_name: str, result_name: str) -> bool:
|
||||
norm_query = self._normalize_name(query_name)
|
||||
norm_result = self._normalize_name(result_name)
|
||||
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
|
||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _process_item(self, item: Dict[str, Any]):
|
||||
try:
|
||||
item_type = item['type']
|
||||
item_id = item['id']
|
||||
item_name = item['name']
|
||||
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
|
||||
|
||||
if item_type == 'artist':
|
||||
self._process_artist(item_id, item_name)
|
||||
elif item_type == 'album':
|
||||
self._process_album(item_id, item_name, item.get('artist', ''), item)
|
||||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
try:
|
||||
self._mark_status(item['type'], item['id'], 'error')
|
||||
except Exception as e2:
|
||||
logger.error(f"Error updating item status: {e2}")
|
||||
|
||||
def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]:
|
||||
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
|
||||
table = table_map.get(entity_type)
|
||||
if not table:
|
||||
return None
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT amazon_id FROM {table} WHERE id = ?", (entity_id,))
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row and row[0] else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _process_artist(self, artist_id: int, artist_name: str):
|
||||
existing_id = self._get_existing_id('artist', artist_id)
|
||||
if existing_id:
|
||||
logger.debug(f"Preserving existing Amazon ID for artist '{artist_name}': {existing_id}")
|
||||
return
|
||||
|
||||
results = self.client.search_artists(artist_name, limit=5)
|
||||
if results:
|
||||
result = results[0]
|
||||
if self._name_matches(artist_name, result.name):
|
||||
self._update_artist(artist_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Amazon ID: {result.id}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result.name}')")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for artist '{artist_name}'")
|
||||
|
||||
def _refresh_album_via_stored_id(self, album_id, stored_id, api_data):
|
||||
self._update_album(album_id, api_data, stored_id)
|
||||
|
||||
def _refresh_track_via_stored_id(self, track_id, stored_id, api_data):
|
||||
self._update_track(track_id, api_data, stored_id)
|
||||
|
||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='albums', entity_id=album_id,
|
||||
id_column='amazon_id',
|
||||
client_fetch_fn=lambda asin: self.client.get_album(asin, include_tracks=False),
|
||||
on_match_fn=self._refresh_album_via_stored_id,
|
||||
log_prefix='Amazon',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
query = f"{artist_name} {album_name}"
|
||||
results = self.client.search_albums(query, limit=10)
|
||||
if results:
|
||||
result = results[0]
|
||||
if self._name_matches(album_name, result.name):
|
||||
full_album = None
|
||||
if result.id:
|
||||
try:
|
||||
full_album = self.client.get_album(result.id, include_tracks=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full album '{album_name}' (ASIN: {result.id}): {e}")
|
||||
|
||||
if full_album is None:
|
||||
self._mark_status('album', album_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Album '{album_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_album(album_id, full_album, result.id)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched album '{album_name}' -> Amazon ASIN: {result.id}")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for album '{album_name}' (got '{result.name}')")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for album '{album_name}'")
|
||||
|
||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='tracks', entity_id=track_id,
|
||||
id_column='amazon_id',
|
||||
client_fetch_fn=self.client.get_track_details,
|
||||
on_match_fn=self._refresh_track_via_stored_id,
|
||||
log_prefix='Amazon',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
query = f"{artist_name} {track_name}"
|
||||
results = self.client.search_tracks(query, limit=10)
|
||||
if results:
|
||||
result = results[0]
|
||||
if self._name_matches(track_name, result.name):
|
||||
full_track = None
|
||||
if result.id:
|
||||
try:
|
||||
full_track = self.client.get_track_details(result.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full track '{track_name}' (ASIN: {result.id}): {e}")
|
||||
|
||||
if full_track is None:
|
||||
self._mark_status('track', track_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_track(track_id, full_track, result.id)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched track '{track_name}' -> Amazon ASIN: {result.id}")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for track '{track_name}' (got '{result.name}')")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for track '{track_name}'")
|
||||
|
||||
def _update_artist(self, artist_id: int, result):
|
||||
"""Store Amazon metadata for an artist. ``result`` is an Artist dataclass."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
amazon_id = ?,
|
||||
amazon_match_status = 'matched',
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (str(result.id), artist_id))
|
||||
|
||||
# Backfill thumb_url from album cover stand-in when artist has no image
|
||||
image_url = result.image_url
|
||||
if not image_url:
|
||||
try:
|
||||
image_url = self.client._get_artist_image_from_albums(result.id)
|
||||
except Exception as exc:
|
||||
logger.debug("Artist image from albums failed for %s: %s", result.id, exc)
|
||||
if image_url:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (image_url, artist_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating artist #{artist_id} with Amazon data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_album(self, album_id: int, full_data: Dict[str, Any], asin: str):
|
||||
"""Store Amazon metadata for an album. ``full_data`` is a get_album() dict."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE albums SET
|
||||
amazon_id = ?,
|
||||
amazon_match_status = 'matched',
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (asin, album_id))
|
||||
|
||||
# Backfill label when missing
|
||||
label = full_data.get('label')
|
||||
if label:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET label = ?
|
||||
WHERE id = ? AND (label IS NULL OR label = '')
|
||||
""", (label, album_id))
|
||||
|
||||
# Backfill thumb_url
|
||||
images = full_data.get('images') or []
|
||||
thumb_url = images[0].get('url') if images else None
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, album_id))
|
||||
|
||||
# Cache authoritative track count for completeness repair
|
||||
total_tracks = full_data.get('total_tracks') or (
|
||||
full_data.get('tracks', {}).get('total') if isinstance(full_data.get('tracks'), dict) else None
|
||||
)
|
||||
set_album_api_track_count(cursor, album_id, total_tracks)
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating album #{album_id} with Amazon data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_track(self, track_id: int, full_data: Dict[str, Any], asin: str):
|
||||
"""Store Amazon metadata for a track. ``full_data`` is a get_track_details() dict."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET
|
||||
amazon_id = ?,
|
||||
amazon_match_status = 'matched',
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (asin, track_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating track #{track_id} with Amazon data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _mark_status(self, entity_type: str, entity_id: int, status: str):
|
||||
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
|
||||
table = table_map.get(entity_type)
|
||||
if not table:
|
||||
logger.error(f"Unknown entity type: {entity_type}")
|
||||
return
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"""
|
||||
UPDATE {table} SET
|
||||
amazon_match_status = ?,
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (status, entity_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking {entity_type} #{entity_id} status: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _count_pending_items(self) -> int:
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM artists WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
|
||||
(SELECT COUNT(*) FROM albums WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
|
||||
(SELECT COUNT(*) FROM tracks WHERE amazon_match_status IS NULL AND id IS NOT NULL)
|
||||
AS pending
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting pending items: {e}")
|
||||
return 0
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]:
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
progress = {}
|
||||
|
||||
for table in ('artists', 'albums', 'tracks'):
|
||||
cursor.execute(f"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN amazon_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
|
||||
FROM {table}
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
total, processed = row[0], row[1] or 0
|
||||
progress[table] = {
|
||||
'matched': processed,
|
||||
'total': total,
|
||||
'percent': int((processed / total * 100) if total > 0 else 0),
|
||||
}
|
||||
|
||||
return progress
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting progress breakdown: {e}")
|
||||
return {}
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
|
@ -43,6 +43,7 @@ def build_source_only_artist_detail(
|
|||
deezer_client: Optional[Any] = None,
|
||||
itunes_client: Optional[Any] = None,
|
||||
discogs_client: Optional[Any] = None,
|
||||
amazon_client: Optional[Any] = None,
|
||||
lastfm_api_key: Optional[str] = None,
|
||||
) -> Tuple[Dict[str, Any], int]:
|
||||
"""Build the artist-detail payload for a source-only artist.
|
||||
|
|
@ -84,6 +85,12 @@ def build_source_only_artist_detail(
|
|||
dc_artist = discogs_client.get_artist(artist_id)
|
||||
if dc_artist:
|
||||
source_genres = dc_artist.get("genres") or []
|
||||
elif source == "amazon" and amazon_client is not None:
|
||||
az_artist = amazon_client.get_artist(resolved_name or artist_id)
|
||||
if az_artist:
|
||||
source_genres = az_artist.get("genres") or []
|
||||
if not image_url and az_artist.get("images"):
|
||||
image_url = az_artist["images"][0].get("url")
|
||||
except Exception as e:
|
||||
logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ logger = logging.getLogger("artist_source_lookup")
|
|||
|
||||
|
||||
SOURCE_ONLY_ARTIST_SOURCES = frozenset({
|
||||
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz",
|
||||
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", "amazon",
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ SOURCE_ID_FIELD = {
|
|||
"discogs": "discogs_id",
|
||||
"hydrabase": "soul_id",
|
||||
"musicbrainz": "musicbrainz_id",
|
||||
"amazon": "amazon_id",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
|
||||
# 1. Try track_info (from frontend, has album track data)
|
||||
tn = track_info.get('track_number', 0) if isinstance(track_info, dict) else 0
|
||||
dn = track_info.get('disc_number', 1) if isinstance(track_info, dict) else 1
|
||||
dn = (track_info.get('disc_number') or 1) if isinstance(track_info, dict) else 1
|
||||
if tn and tn > 0:
|
||||
enhanced_payload['track_number'] = tn
|
||||
enhanced_payload['disc_number'] = dn
|
||||
|
|
@ -255,7 +255,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
detailed_track = deps.spotify_client.get_track_details(track.id)
|
||||
if detailed_track and detailed_track.get('track_number'):
|
||||
enhanced_payload['track_number'] = detailed_track['track_number']
|
||||
enhanced_payload['disc_number'] = detailed_track.get('disc_number', 1)
|
||||
enhanced_payload['disc_number'] = detailed_track.get('disc_number') or 1
|
||||
got_track_number = True
|
||||
logger.info(f"[Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
|
||||
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# Use ALL tracks (tracks_json), not just missing ones, to correctly detect multi-disc
|
||||
# even when only one disc has missing tracks
|
||||
if batch_is_album and batch_album_context:
|
||||
total_discs = max((t.get('disc_number', 1) for t in tracks_json), default=1)
|
||||
total_discs = max((t.get('disc_number') or 1 for t in tracks_json), default=1)
|
||||
batch_album_context['total_discs'] = total_discs
|
||||
if total_discs > 1:
|
||||
logger.info(f"[Multi-Disc] Detected {total_discs} discs for album '{batch_album_context.get('name')}'")
|
||||
|
|
@ -507,7 +507,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# Fallback album key: use album name when ID is missing (e.g. mirrored playlist tracks)
|
||||
if not album_id and isinstance(album_val, dict) and album_val.get('name'):
|
||||
album_id = f"_name_{album_val['name'].lower().strip()}"
|
||||
disc_num = sp_data.get('disc_number', t.get('disc_number', 1))
|
||||
disc_num = sp_data.get('disc_number') or t.get('disc_number') or 1
|
||||
if album_id:
|
||||
wishlist_album_disc_counts[album_id] = max(
|
||||
wishlist_album_disc_counts.get(album_id, 1), disc_num
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ tidal_enrichment_worker = None
|
|||
qobuz_enrichment_worker = None
|
||||
discogs_worker = None
|
||||
audiodb_worker = None
|
||||
amazon_worker = None
|
||||
|
||||
|
||||
def init(
|
||||
|
|
@ -31,11 +32,12 @@ def init(
|
|||
qobuz_worker=None,
|
||||
discogs_worker_obj=None,
|
||||
audiodb_worker_obj=None,
|
||||
amazon_worker_obj=None,
|
||||
):
|
||||
"""Bind enrichment worker handles so the lifted bodies can use them."""
|
||||
global spotify_enrichment_worker, itunes_enrichment_worker, mb_worker
|
||||
global lastfm_worker, genius_worker, tidal_enrichment_worker
|
||||
global qobuz_enrichment_worker, discogs_worker, audiodb_worker
|
||||
global qobuz_enrichment_worker, discogs_worker, audiodb_worker, amazon_worker
|
||||
spotify_enrichment_worker = spotify_worker
|
||||
itunes_enrichment_worker = itunes_worker
|
||||
mb_worker = musicbrainz_worker
|
||||
|
|
@ -45,6 +47,7 @@ def init(
|
|||
qobuz_enrichment_worker = qobuz_worker
|
||||
discogs_worker = discogs_worker_obj
|
||||
audiodb_worker = audiodb_worker_obj
|
||||
amazon_worker = amazon_worker_obj
|
||||
|
||||
|
||||
def _detect_provider(items, client):
|
||||
|
|
@ -293,4 +296,22 @@ def _search_service(service, entity_type, query):
|
|||
'image': None, 'extra': f"{result.get('strArtist', '')} · {result.get('strAlbum', '')}"}]
|
||||
return []
|
||||
|
||||
elif service == 'amazon':
|
||||
if not amazon_worker or not amazon_worker.client:
|
||||
raise ValueError("Amazon worker not initialized")
|
||||
client = amazon_worker.client
|
||||
if entity_type == 'artist':
|
||||
items = client.search_artists(query, limit=8)
|
||||
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
|
||||
'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items]
|
||||
elif entity_type == 'album':
|
||||
items = client.search_albums(query, limit=8)
|
||||
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
|
||||
'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items]
|
||||
elif entity_type == 'track':
|
||||
items = client.search_tracks(query, limit=8)
|
||||
return [{'id': str(t.id), 'name': t.name, 'image': t.image_url,
|
||||
'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
|
||||
return []
|
||||
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -140,6 +140,14 @@ def _get_discogs_factory(client_factory: Optional[MetadataClientFactory]) -> Met
|
|||
return DiscogsClient
|
||||
|
||||
|
||||
def _get_amazon_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
from core.amazon_client import AmazonClient
|
||||
|
||||
return AmazonClient
|
||||
|
||||
|
||||
def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get shared Spotify client.
|
||||
|
||||
|
|
@ -260,6 +268,18 @@ def get_discogs_client(
|
|||
return client
|
||||
|
||||
|
||||
def get_amazon_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get cached Amazon Music client."""
|
||||
cache_key = "amazon"
|
||||
factory = _get_amazon_factory(client_factory)
|
||||
with _client_cache_lock:
|
||||
client = _client_cache.get(cache_key)
|
||||
if client is None:
|
||||
client = factory()
|
||||
_client_cache[cache_key] = client
|
||||
return client
|
||||
|
||||
|
||||
def is_hydrabase_enabled() -> bool:
|
||||
"""Return True when Hydrabase is connected and app-enabled."""
|
||||
try:
|
||||
|
|
@ -331,6 +351,7 @@ def get_primary_client(
|
|||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
amazon_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return client for configured primary source."""
|
||||
return get_client_for_source(
|
||||
|
|
@ -339,6 +360,7 @@ def get_primary_client(
|
|||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
amazon_client_factory=amazon_client_factory,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -348,6 +370,7 @@ def get_primary_source_status(
|
|||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
amazon_client_factory: Optional[MetadataClientFactory] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a generic status snapshot for the active primary metadata source."""
|
||||
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
|
||||
|
|
@ -361,6 +384,7 @@ def get_primary_source_status(
|
|||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
amazon_client_factory=amazon_client_factory,
|
||||
)
|
||||
if source == "spotify":
|
||||
connected = bool(client and client.is_spotify_authenticated())
|
||||
|
|
@ -387,6 +411,7 @@ def get_client_for_source(
|
|||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
amazon_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return exact client for a source, or None if unavailable."""
|
||||
if source == "spotify":
|
||||
|
|
@ -410,4 +435,7 @@ def get_client_for_source(
|
|||
if source == "itunes":
|
||||
return get_itunes_client(client_factory=itunes_client_factory)
|
||||
|
||||
if source == "amazon":
|
||||
return get_amazon_client(client_factory=amazon_client_factory)
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from core.metadata.registry import (
|
|||
clear_cached_metadata_client,
|
||||
clear_cached_metadata_clients,
|
||||
clear_cached_profile_spotify_client,
|
||||
get_amazon_client,
|
||||
get_client_for_source,
|
||||
get_deezer_client,
|
||||
get_discogs_client,
|
||||
|
|
@ -75,6 +76,7 @@ except Exception: # pragma: no cover - optional dependency fallback
|
|||
|
||||
__all__ = [
|
||||
"METADATA_SOURCE_PRIORITY",
|
||||
"get_amazon_client",
|
||||
"MetadataCache",
|
||||
"MetadataLookupOptions",
|
||||
"MetadataProvider",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from . import sources
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_SOURCES = (
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', 'amazon',
|
||||
)
|
||||
|
||||
VALID_STREAM_SOURCES = VALID_SOURCES + ('youtube_videos',)
|
||||
|
|
@ -88,6 +88,13 @@ def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
|
|||
except Exception as e:
|
||||
logger.warning(f"MusicBrainz search client init failed: {e}")
|
||||
return None, False
|
||||
if source_name == 'amazon':
|
||||
try:
|
||||
from core.metadata.registry import get_amazon_client
|
||||
return get_amazon_client(), True
|
||||
except Exception as e:
|
||||
logger.warning(f"Amazon Music client init failed: {e}")
|
||||
return None, False
|
||||
return None, False
|
||||
|
||||
|
||||
|
|
@ -183,6 +190,8 @@ def _alternate_sources(primary_source: str, deps: SearchDeps) -> list[str]:
|
|||
alts.append('discogs')
|
||||
if primary_source != 'hydrabase' and hydrabase_available:
|
||||
alts.append('hydrabase')
|
||||
if primary_source != 'amazon':
|
||||
alts.append('amazon') # always available (T2Tunes, no auth)
|
||||
alts.append('youtube_videos') # always available (yt-dlp, no auth)
|
||||
alts.append('musicbrainz') # always available (public API)
|
||||
return alts
|
||||
|
|
|
|||
|
|
@ -407,6 +407,9 @@ class MusicDatabase:
|
|||
# Add Discogs enrichment columns (migration)
|
||||
self._add_discogs_columns(cursor)
|
||||
|
||||
# Add Amazon artist ID column (migration)
|
||||
self._add_amazon_columns(cursor)
|
||||
|
||||
# Backfill match_status for rows that already have an external ID but
|
||||
# NULL status. Prevents enrichment workers from re-processing these
|
||||
# rows forever. Must run AFTER all *_match_status columns have been
|
||||
|
|
@ -1631,6 +1634,10 @@ class MusicDatabase:
|
|||
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN discogs_artist_id TEXT")
|
||||
logger.info("Added discogs_artist_id column to watchlist_artists table for cross-provider support")
|
||||
|
||||
if 'amazon_artist_id' not in columns:
|
||||
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN amazon_artist_id TEXT")
|
||||
logger.info("Added amazon_artist_id column to watchlist_artists table for Amazon Music support")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
|
@ -2035,6 +2042,55 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error adding Discogs columns: {e}")
|
||||
|
||||
def _add_amazon_columns(self, cursor):
|
||||
"""Add Amazon enrichment tracking columns to artists, albums, and tracks."""
|
||||
try:
|
||||
# --- Artists ---
|
||||
cursor.execute("PRAGMA table_info(artists)")
|
||||
artists_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'amazon_id' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN amazon_id TEXT")
|
||||
if 'amazon_match_status' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN amazon_match_status TEXT")
|
||||
if 'amazon_last_attempted' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN amazon_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_status ON artists (amazon_match_status)")
|
||||
|
||||
# --- Albums ---
|
||||
cursor.execute("PRAGMA table_info(albums)")
|
||||
albums_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'amazon_id' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN amazon_id TEXT")
|
||||
if 'amazon_match_status' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN amazon_match_status TEXT")
|
||||
if 'amazon_last_attempted' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN amazon_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_id ON albums (amazon_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_status ON albums (amazon_match_status)")
|
||||
|
||||
# --- Tracks ---
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
tracks_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'amazon_id' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_id TEXT")
|
||||
if 'amazon_match_status' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_match_status TEXT")
|
||||
if 'amazon_last_attempted' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_id ON tracks (amazon_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_status ON tracks (amazon_match_status)")
|
||||
|
||||
logger.info("Amazon columns added/verified successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding Amazon columns: {e}")
|
||||
|
||||
def _backfill_match_status_for_existing_ids(self, cursor):
|
||||
"""Set `<provider>_match_status = 'matched'` for rows that already have a
|
||||
populated external ID but NULL match_status.
|
||||
|
|
@ -8903,6 +8959,7 @@ class MusicDatabase:
|
|||
a.tidal_id,
|
||||
a.qobuz_id,
|
||||
a.soul_id,
|
||||
a.amazon_id,
|
||||
a.server_source
|
||||
FROM artists a
|
||||
WHERE {where_clause}
|
||||
|
|
@ -9002,6 +9059,7 @@ class MusicDatabase:
|
|||
'tidal_id': row['tidal_id'],
|
||||
'qobuz_id': row['qobuz_id'],
|
||||
'soul_id': row['soul_id'],
|
||||
'amazon_id': row['amazon_id'],
|
||||
'album_count': counts_map.get(row['id'], (0, 0))[0],
|
||||
'track_count': counts_map.get(row['id'], (0, 0))[1],
|
||||
'is_watched': bool(is_watched)
|
||||
|
|
@ -9060,7 +9118,7 @@ class MusicDatabase:
|
|||
id, name, thumb_url, genres, server_source,
|
||||
musicbrainz_id, deezer_id, audiodb_id, discogs_id,
|
||||
spotify_artist_id, itunes_artist_id, lastfm_url, genius_url,
|
||||
tidal_id, qobuz_id, soul_id,
|
||||
tidal_id, qobuz_id, soul_id, amazon_id,
|
||||
lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio
|
||||
FROM artists
|
||||
WHERE id = ?
|
||||
|
|
@ -9218,6 +9276,7 @@ class MusicDatabase:
|
|||
'tidal_id': artist_row['tidal_id'],
|
||||
'qobuz_id': artist_row['qobuz_id'],
|
||||
'soul_id': artist_row['soul_id'],
|
||||
'amazon_id': artist_row['amazon_id'],
|
||||
'lastfm_listeners': artist_row['lastfm_listeners'],
|
||||
'lastfm_playcount': artist_row['lastfm_playcount'],
|
||||
'lastfm_tags': artist_row['lastfm_tags'],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ EXPECTED_SOURCE_ID_FIELD = {
|
|||
"discogs": "discogs_id",
|
||||
"hydrabase": "soul_id",
|
||||
"musicbrainz": "musicbrainz_id",
|
||||
"amazon": "amazon_id",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ MEDIA_RESPONSE_FLAC = {
|
|||
"artist": "Kendrick Lamar",
|
||||
"album": "GNX",
|
||||
"isrc": "USRC12345678",
|
||||
"trackNumber": "3",
|
||||
"discNumber": "1",
|
||||
"date": "2024-11-22",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -536,6 +539,42 @@ class TestSearchArtists:
|
|||
artists = client.search_artists("Kendrick")
|
||||
assert isinstance(artists[0], Artist)
|
||||
|
||||
def test_artist_image_from_album(self):
|
||||
resp = {
|
||||
"results": [{"hits": [
|
||||
{"document": {"asin": "A1", "title": "T1", "artistName": "Kendrick Lamar",
|
||||
"__type": "track", "albumAsin": "B0ABCDE123"}},
|
||||
]}]
|
||||
}
|
||||
client = _make_client({
|
||||
"amazon-music/search": resp,
|
||||
"amazon-music/metadata": ALBUM_METADATA_RESPONSE,
|
||||
})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
artists = client.search_artists("Kendrick")
|
||||
assert artists[0].image_url == "https://example.com/cover.jpg"
|
||||
|
||||
def test_deduplicates_feat_credits(self):
|
||||
resp = {
|
||||
"results": [
|
||||
{
|
||||
"hits": [
|
||||
{"document": {"asin": "A1", "title": "T1", "artistName": "Kendrick Lamar", "__type": "track"}},
|
||||
{"document": {"asin": "A2", "title": "T2", "artistName": "Kendrick Lamar feat. SZA", "__type": "track"}},
|
||||
{"document": {"asin": "A3", "title": "T3", "artistName": "Kendrick Lamar ft. Drake", "__type": "track"}},
|
||||
{"document": {"asin": "A4", "title": "T4", "artistName": "SZA featuring Kendrick Lamar", "__type": "track"}},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
client = _make_client({"amazon-music/search": resp})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
artists = client.search_artists("Kendrick")
|
||||
names = [a.name for a in artists]
|
||||
assert "Kendrick Lamar" in names
|
||||
assert "SZA" in names
|
||||
assert len(artists) == 2
|
||||
|
||||
def test_respects_limit(self):
|
||||
resp = {
|
||||
"results": [
|
||||
|
|
@ -591,6 +630,43 @@ class TestSearchAlbums:
|
|||
albums = client.search_albums("Kendrick")
|
||||
assert albums == []
|
||||
|
||||
def test_strips_explicit_from_album_name(self):
|
||||
resp = {
|
||||
"results": [{"hits": [
|
||||
{"document": {**ALBUM_DOC, "albumName": "GNX (Explicit)", "title": "GNX (Explicit)"}},
|
||||
]}]
|
||||
}
|
||||
client = _make_client({"amazon-music/search": resp})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
albums = client.search_albums("GNX")
|
||||
assert albums[0].name == "GNX"
|
||||
|
||||
def test_keeps_clean_suffix(self):
|
||||
resp = {
|
||||
"results": [{"hits": [
|
||||
{"document": {**ALBUM_DOC, "albumName": "GNX [Clean]", "title": "GNX [Clean]"}},
|
||||
]}]
|
||||
}
|
||||
client = _make_client({"amazon-music/search": resp})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
albums = client.search_albums("GNX")
|
||||
assert albums[0].name == "GNX [Clean]"
|
||||
|
||||
def test_deduplicates_explicit_clean_as_separate(self):
|
||||
resp = {
|
||||
"results": [{"hits": [
|
||||
{"document": {**ALBUM_DOC, "asin": "B1", "albumAsin": "B1", "albumName": "GNX (Explicit)", "title": "GNX (Explicit)"}},
|
||||
{"document": {**ALBUM_DOC, "asin": "B2", "albumAsin": "B2", "albumName": "GNX [Clean]", "title": "GNX [Clean]"}},
|
||||
]}]
|
||||
}
|
||||
client = _make_client({"amazon-music/search": resp})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
albums = client.search_albums("GNX")
|
||||
names = [a.name for a in albums]
|
||||
assert "GNX" in names # explicit stripped
|
||||
assert "GNX [Clean]" in names
|
||||
assert len(albums) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AmazonClient — album_metadata / media_from_asin
|
||||
|
|
@ -735,6 +811,13 @@ class TestGetAlbum:
|
|||
album = client.get_album("B0ABCDE123", include_tracks=False)
|
||||
assert "tracks" not in album
|
||||
|
||||
def test_release_date_backfilled_from_stream_tags(self):
|
||||
# ALBUM_METADATA_RESPONSE has no release_date — should fall back to track date tag
|
||||
client = self._client()
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
album = client.get_album("B0ABCDE123")
|
||||
assert album["release_date"] == "2024-11-22"
|
||||
|
||||
def test_returns_none_on_empty_albumlist(self):
|
||||
client = _make_client({"amazon-music/metadata": {"albumList": []}})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
|
|
@ -766,6 +849,32 @@ class TestGetAlbumTracks:
|
|||
assert item["id"] == "B09XYZ1234"
|
||||
assert item["name"] == "Not Like Us"
|
||||
assert item["isrc"] == "USRC12345678"
|
||||
assert item["track_number"] == 3
|
||||
assert item["disc_number"] == 1
|
||||
|
||||
def test_duration_enriched_from_search(self):
|
||||
search_resp = {
|
||||
"results": [{"hits": [
|
||||
{"document": {
|
||||
"asin": "B09XYZ1234", "__type": "track",
|
||||
"title": "Not Like Us", "artistName": "Kendrick Lamar",
|
||||
"albumAsin": "B09XYZ1234", "duration": 217,
|
||||
}},
|
||||
]}]
|
||||
}
|
||||
client = _make_client({
|
||||
"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC],
|
||||
"amazon-music/search": search_resp,
|
||||
})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
result = client.get_album_tracks("B09XYZ1234")
|
||||
assert result["items"][0]["duration_ms"] == 217_000
|
||||
|
||||
def test_duration_zero_when_search_fails(self):
|
||||
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
||||
with patch("core.amazon_client._rate_limit"):
|
||||
result = client.get_album_tracks("B09XYZ1234")
|
||||
assert result["items"][0]["duration_ms"] == 0
|
||||
|
||||
def test_returns_none_on_api_error(self):
|
||||
client = _make_client()
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ from core.genius_worker import GeniusWorker
|
|||
from core.tidal_worker import TidalWorker
|
||||
from core.qobuz_worker import QobuzWorker
|
||||
from core.hydrabase_worker import HydrabaseWorker
|
||||
from core.amazon_worker import AmazonWorker
|
||||
from core.hydrabase_client import HydrabaseClient
|
||||
from core.automation_engine import AutomationEngine
|
||||
|
||||
|
|
@ -1858,6 +1859,7 @@ SERVICE_CONFIG_REGISTRY = {
|
|||
'spotify': {'required': ['client_id', 'client_secret']},
|
||||
'itunes': {'always': True}, # default storefront works anon
|
||||
'deezer': {'always': True}, # anon search works, premium ARL is optional
|
||||
'amazon': {'always': True}, # T2Tunes proxy, no credentials required
|
||||
'discogs': {'required': ['token']},
|
||||
'tidal': {'custom': lambda _svc: _tidal_has_auth_token()},
|
||||
'qobuz': {'any_of': [['email', 'password'], ['token'], ['user_auth_token']]},
|
||||
|
|
@ -2003,6 +2005,7 @@ def _get_enrichment_status():
|
|||
('genius', 'Genius', lambda: genius_worker),
|
||||
('audiodb', 'AudioDB', lambda: audiodb_worker),
|
||||
('discogs', 'Discogs', lambda: discogs_worker),
|
||||
('amazon_enrichment', 'Amazon Music', lambda: amazon_worker),
|
||||
]
|
||||
|
||||
# Config-based "configured" checks for services that need API keys/credentials
|
||||
|
|
@ -7282,6 +7285,13 @@ def _build_source_only_artist_detail(artist_id, artist_name, source):
|
|||
except Exception as e:
|
||||
logger.debug(f"Discogs client resolution failed: {e}")
|
||||
|
||||
az = None
|
||||
try:
|
||||
from core.metadata.registry import get_amazon_client
|
||||
az = get_amazon_client()
|
||||
except Exception as e:
|
||||
logger.debug(f"Amazon client resolution failed: {e}")
|
||||
|
||||
try:
|
||||
lastfm_api_key = config_manager.get('lastfm.api_key', '') or None
|
||||
except Exception:
|
||||
|
|
@ -7295,6 +7305,7 @@ def _build_source_only_artist_detail(artist_id, artist_name, source):
|
|||
deezer_client=dz,
|
||||
itunes_client=it,
|
||||
discogs_client=dc,
|
||||
amazon_client=az,
|
||||
lastfm_api_key=lastfm_api_key,
|
||||
)
|
||||
return jsonify(payload), status
|
||||
|
|
@ -7400,6 +7411,7 @@ def get_artist_detail(artist_id):
|
|||
'itunes': artist_info.get('itunes_artist_id'),
|
||||
'discogs': artist_info.get('discogs_id'),
|
||||
'hydrabase': artist_info.get('soul_id'),
|
||||
'amazon': artist_info.get('amazon_id'),
|
||||
}
|
||||
|
||||
artist_detail_discography = _get_artist_detail_discography(
|
||||
|
|
@ -10643,6 +10655,7 @@ _SERVICE_ID_COLUMNS = {
|
|||
'genius': {'artist': 'genius_id', 'track': 'genius_id'},
|
||||
'tidal': {'artist': 'tidal_id', 'album': 'tidal_id', 'track': 'tidal_id'},
|
||||
'qobuz': {'artist': 'qobuz_id', 'album': 'qobuz_id', 'track': 'qobuz_id'},
|
||||
'amazon': {'artist': 'amazon_id', 'album': 'amazon_id', 'track': 'amazon_id'},
|
||||
}
|
||||
|
||||
@app.route('/api/library/manual-match', methods=['PUT'])
|
||||
|
|
@ -12004,7 +12017,7 @@ def _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_ar
|
|||
logger.info(f"Processing enhanced album download for '{spotify_album['name']}' with {len(enhanced_tracks)} matched tracks")
|
||||
|
||||
# Compute total_discs for multi-disc album subfolder support
|
||||
total_discs = max((t['spotify_track'].get('disc_number', 1) for t in enhanced_tracks), default=1)
|
||||
total_discs = max((t['spotify_track'].get('disc_number') or 1 for t in enhanced_tracks), default=1)
|
||||
spotify_album['total_discs'] = total_discs
|
||||
|
||||
started_count = 0
|
||||
|
|
@ -12146,7 +12159,7 @@ def _start_album_download_tasks(album_result, spotify_artist, spotify_album):
|
|||
|
||||
# Compute total_discs for multi-disc album subfolder support
|
||||
if official_spotify_tracks:
|
||||
total_discs = max((t.get('disc_number', 1) for t in official_spotify_tracks), default=1)
|
||||
total_discs = max((t.get('disc_number') or 1 for t in official_spotify_tracks), default=1)
|
||||
else:
|
||||
total_discs = 1
|
||||
spotify_album['total_discs'] = total_discs
|
||||
|
|
@ -14203,7 +14216,7 @@ def _pause_workers_for_scan():
|
|||
'mb': mb_worker, 'spotify': spotify_enrichment_worker, 'itunes': itunes_enrichment_worker,
|
||||
'deezer': deezer_worker, 'audiodb': audiodb_worker, 'discogs': discogs_worker, 'lastfm': lastfm_worker,
|
||||
'genius': genius_worker, 'tidal': tidal_enrichment_worker, 'qobuz': qobuz_enrichment_worker,
|
||||
'repair': repair_worker, 'soulid': soulid_worker,
|
||||
'amazon': amazon_worker, 'repair': repair_worker, 'soulid': soulid_worker,
|
||||
}
|
||||
for name, w in workers.items():
|
||||
if w and hasattr(w, 'pause') and not getattr(w, 'paused', True):
|
||||
|
|
@ -14219,7 +14232,7 @@ def _resume_workers_after_scan():
|
|||
'mb': mb_worker, 'spotify': spotify_enrichment_worker, 'itunes': itunes_enrichment_worker,
|
||||
'deezer': deezer_worker, 'audiodb': audiodb_worker, 'discogs': discogs_worker, 'lastfm': lastfm_worker,
|
||||
'genius': genius_worker, 'tidal': tidal_enrichment_worker, 'qobuz': qobuz_enrichment_worker,
|
||||
'repair': repair_worker, 'soulid': soulid_worker,
|
||||
'amazon': amazon_worker, 'repair': repair_worker, 'soulid': soulid_worker,
|
||||
}
|
||||
resumed = 0
|
||||
for name, w in workers.items():
|
||||
|
|
@ -18664,6 +18677,9 @@ def get_spotify_album_tracks(album_id):
|
|||
client = _get_deezer_client()
|
||||
elif source_override == 'discogs':
|
||||
client = _get_discogs_client()
|
||||
elif source_override == 'amazon':
|
||||
from core.metadata.registry import get_amazon_client
|
||||
client = get_amazon_client()
|
||||
elif source_override == 'musicbrainz':
|
||||
try:
|
||||
from core.musicbrainz_search import MusicBrainzSearchClient
|
||||
|
|
@ -24357,6 +24373,7 @@ def get_watchlist_artists():
|
|||
"itunes_artist_id": artist.itunes_artist_id, # For iTunes-only artists
|
||||
"deezer_artist_id": getattr(artist, 'deezer_artist_id', None),
|
||||
"discogs_artist_id": getattr(artist, 'discogs_artist_id', None),
|
||||
"amazon_artist_id": getattr(artist, 'amazon_artist_id', None),
|
||||
"include_albums": artist.include_albums,
|
||||
"include_eps": artist.include_eps,
|
||||
"include_singles": artist.include_singles,
|
||||
|
|
@ -25156,10 +25173,10 @@ def watchlist_artist_config(artist_id):
|
|||
include_live, include_remixes, include_acoustic, include_compilations,
|
||||
artist_name, image_url, spotify_artist_id, itunes_artist_id,
|
||||
last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id,
|
||||
lookback_days, discogs_artist_id, preferred_metadata_source
|
||||
lookback_days, discogs_artist_id, preferred_metadata_source, amazon_artist_id
|
||||
FROM watchlist_artists
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
|
||||
""", (artist_id, artist_id, artist_id, artist_id))
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ?
|
||||
""", (artist_id, artist_id, artist_id, artist_id, artist_id))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
|
|
@ -25168,10 +25185,11 @@ def watchlist_artist_config(artist_id):
|
|||
|
||||
# Determine if this is an iTunes or Spotify artist
|
||||
is_itunes_artist = artist_id.isdigit()
|
||||
spotify_id = result[9] # spotify_artist_id from query
|
||||
spotify_id = result[9] # spotify_artist_id from query
|
||||
itunes_id = result[10] # itunes_artist_id from query
|
||||
deezer_id = result[14] # deezer_artist_id from query
|
||||
discogs_id = result[16] # discogs_artist_id from query
|
||||
amazon_id = result[18] if len(result) > 18 else None # amazon_artist_id from query
|
||||
|
||||
# Get artist info from Spotify (only for Spotify artists)
|
||||
artist_info = None
|
||||
|
|
@ -25279,6 +25297,7 @@ def watchlist_artist_config(artist_id):
|
|||
"itunes_artist_id": itunes_id,
|
||||
"deezer_artist_id": deezer_id,
|
||||
"discogs_artist_id": discogs_id,
|
||||
"amazon_artist_id": amazon_id,
|
||||
"watchlist_name": result[7], # Original stored watchlist artist name
|
||||
"global_metadata_source": get_primary_source(),
|
||||
})
|
||||
|
|
@ -25379,7 +25398,7 @@ def watchlist_artist_link_provider(artist_id):
|
|||
new_provider_id = data.get('provider_id', '').strip()
|
||||
provider = data.get('provider', '').strip()
|
||||
|
||||
valid_providers = ('spotify', 'itunes', 'deezer', 'discogs')
|
||||
valid_providers = ('spotify', 'itunes', 'deezer', 'discogs', 'amazon')
|
||||
if provider not in valid_providers:
|
||||
return jsonify({"success": False, "error": f"Invalid provider. Must be one of: {', '.join(valid_providers)}"}), 400
|
||||
|
||||
|
|
@ -25393,8 +25412,8 @@ def watchlist_artist_link_provider(artist_id):
|
|||
cursor.execute("""
|
||||
SELECT id, artist_name, spotify_artist_id, itunes_artist_id
|
||||
FROM watchlist_artists
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
|
||||
""", (artist_id, artist_id, artist_id, artist_id))
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ?
|
||||
""", (artist_id, artist_id, artist_id, artist_id, artist_id))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
|
|
@ -25405,7 +25424,7 @@ def watchlist_artist_link_provider(artist_id):
|
|||
artist_name = row[1]
|
||||
|
||||
# Check for duplicate — another watchlist artist already has this provider ID
|
||||
col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}
|
||||
col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id', 'amazon': 'amazon_artist_id'}
|
||||
col = col_map[provider]
|
||||
|
||||
if not is_clear:
|
||||
|
|
@ -32123,6 +32142,20 @@ except Exception as e:
|
|||
# END DEEZER INTEGRATION
|
||||
# ================================================================================================
|
||||
|
||||
amazon_worker = None
|
||||
try:
|
||||
amazon_db = MusicDatabase()
|
||||
amazon_worker = AmazonWorker(database=amazon_db)
|
||||
amazon_worker.start()
|
||||
if config_manager.get('amazon_enrichment_paused', False):
|
||||
amazon_worker.pause()
|
||||
logger.info("Amazon enrichment worker initialized (paused — restored from config)")
|
||||
else:
|
||||
logger.info("Amazon enrichment worker initialized and started")
|
||||
except Exception as e:
|
||||
logger.error(f"Amazon worker initialization failed: {e}")
|
||||
amazon_worker = None
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# SPOTIFY ENRICHMENT INTEGRATION
|
||||
|
|
@ -32393,6 +32426,7 @@ _init_service_search(
|
|||
qobuz_worker=qobuz_enrichment_worker,
|
||||
discogs_worker_obj=discogs_worker,
|
||||
audiodb_worker_obj=audiodb_worker,
|
||||
amazon_worker_obj=amazon_worker,
|
||||
)
|
||||
|
||||
# Qobuz status / pause / resume routes are now served by the
|
||||
|
|
@ -33787,6 +33821,11 @@ _register_enrichment_services([
|
|||
config_paused_key='qobuz_enrichment_paused',
|
||||
extra_status_defaults={'authenticated': False},
|
||||
),
|
||||
_EnrichmentService(
|
||||
id='amazon', display_name='Amazon Music',
|
||||
worker_getter=lambda: amazon_worker,
|
||||
config_paused_key='amazon_enrichment_paused',
|
||||
),
|
||||
])
|
||||
|
||||
_configure_enrichment_api(
|
||||
|
|
@ -33806,7 +33845,7 @@ def _emit_rate_monitor_loop():
|
|||
'spotify': 'spotify_enrichment', 'itunes': 'itunes_enrichment',
|
||||
'deezer': 'deezer_enrichment', 'lastfm': 'lastfm', 'genius': 'genius',
|
||||
'musicbrainz': 'musicbrainz', 'audiodb': 'audiodb', 'discogs': 'discogs',
|
||||
'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment',
|
||||
'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment', 'amazon': 'amazon_enrichment',
|
||||
}
|
||||
|
||||
while not globals().get('IS_SHUTTING_DOWN', False):
|
||||
|
|
@ -33866,6 +33905,7 @@ def _emit_enrichment_status_loop():
|
|||
'genius-enrichment': lambda: genius_worker,
|
||||
'tidal-enrichment': lambda: tidal_enrichment_worker,
|
||||
'qobuz-enrichment': lambda: qobuz_enrichment_worker,
|
||||
'amazon-enrichment': lambda: amazon_worker,
|
||||
'hydrabase': lambda: hydrabase_worker,
|
||||
'soulid': lambda: soulid_worker,
|
||||
'listening-stats': lambda: listening_stats_worker,
|
||||
|
|
|
|||
|
|
@ -541,6 +541,29 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amazon Music Enrichment Status Icon -->
|
||||
<div class="amazon-enrich-button-container">
|
||||
<button class="amazon-enrich-button" id="amazon-enrich-button" title="Amazon Music Library Enrichment"
|
||||
onclick="toggleAmazonEnrichment()">
|
||||
<img src="/static/amazon.svg"
|
||||
alt="Amazon Music" class="amazon-enrich-logo">
|
||||
<div class="amazon-enrich-spinner"></div>
|
||||
</button>
|
||||
<div class="amazon-enrich-tooltip" id="amazon-enrich-tooltip">
|
||||
<div class="amazon-enrich-tooltip-content">
|
||||
<div class="amazon-enrich-tooltip-header">Amazon Music Enrichment</div>
|
||||
<div class="amazon-enrich-tooltip-body" id="amazon-enrich-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span
|
||||
id="amazon-enrich-tooltip-status">Idle</span>
|
||||
</div>
|
||||
<div class="tooltip-current" id="amazon-enrich-tooltip-current">No active matches
|
||||
</div>
|
||||
<div class="tooltip-progress" id="amazon-enrich-tooltip-progress">Progress: 0 / 0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hydrabase P2P Mirror Status Icon -->
|
||||
<div class="hydrabase-button-container" id="hydrabase-button-container" style="display: none;">
|
||||
<button class="hydrabase-button" id="hydrabase-button" title="Hydrabase P2P Mirror">
|
||||
|
|
|
|||
5
webui/static/amazon.svg
Normal file
5
webui/static/amazon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black">
|
||||
<text x="12" y="13" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="black">a</text>
|
||||
<path d="M4.5 17 Q12 21 19.5 17" stroke="black" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M17.2 15 L19.5 17 L17.2 19" fill="none" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
|
|
@ -4,17 +4,19 @@
|
|||
const _rateMonitorState = {};
|
||||
const _RATE_GAUGE_SERVICES = [
|
||||
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
|
||||
];
|
||||
const _RATE_GAUGE_LABELS = {
|
||||
spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer',
|
||||
lastfm: 'Last.fm', genius: 'Genius', musicbrainz: 'MusicBrainz',
|
||||
audiodb: 'AudioDB', tidal: 'Tidal', qobuz: 'Qobuz', discogs: 'Discogs',
|
||||
amazon: 'Amazon Music',
|
||||
};
|
||||
const _RATE_GAUGE_COLORS = {
|
||||
spotify: '#1DB954', itunes: '#FC3C44', deezer: '#A238FF',
|
||||
lastfm: '#D51007', genius: '#FFFF64', musicbrainz: '#BA478F',
|
||||
audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35', discogs: '#D4A574',
|
||||
amazon: '#FF9900',
|
||||
};
|
||||
|
||||
// SVG constants — 240° arc, gap at bottom
|
||||
|
|
@ -972,7 +974,8 @@ async function initializeWatchlistPage() {
|
|||
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
|
||||
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
|
||||
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id;
|
||||
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id;
|
||||
return `
|
||||
<div class="watchlist-artist-card"
|
||||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||||
|
|
@ -1764,7 +1767,8 @@ async function showWatchlistModal() {
|
|||
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
|
||||
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
|
||||
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id;
|
||||
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id;
|
||||
return `
|
||||
<div class="watchlist-artist-card"
|
||||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||||
|
|
@ -1920,7 +1924,7 @@ function closeWatchlistModal() {
|
|||
* Populate the linked provider section in the watchlist config modal.
|
||||
* Shows which Spotify/iTunes/Deezer artist is linked and allows changing it.
|
||||
*/
|
||||
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId) {
|
||||
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId) {
|
||||
const section = document.getElementById('watchlist-linked-provider-section');
|
||||
const content = document.getElementById('watchlist-linked-provider-content');
|
||||
if (!section || !content) return;
|
||||
|
|
@ -1932,6 +1936,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
|
|||
{ key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' },
|
||||
{ key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' },
|
||||
{ key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' },
|
||||
{ key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' },
|
||||
];
|
||||
|
||||
let html = '<div class="wl-linked-sources">';
|
||||
|
|
@ -1975,7 +1980,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
|
|||
function _openSourceSearch(sourceKey, artistId, artistName) {
|
||||
const panel = document.getElementById('wl-linked-search-panel');
|
||||
if (!panel) return;
|
||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs' };
|
||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music' };
|
||||
document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`;
|
||||
const input = document.getElementById('wl-linked-search-input');
|
||||
input.value = artistName;
|
||||
|
|
@ -2103,10 +2108,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
|
|||
return;
|
||||
}
|
||||
|
||||
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, watchlist_name } = data;
|
||||
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, watchlist_name } = data;
|
||||
|
||||
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
|
||||
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id);
|
||||
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id);
|
||||
|
||||
// Check if global override is active
|
||||
let globalOverrideActive = false;
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ function initializeWebSocket() {
|
|||
socket.on('enrichment:genius-enrichment', (data) => updateGeniusEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:amazon-enrichment', (data) => updateAmazonEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
|
||||
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
||||
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
|
||||
|
|
@ -675,6 +676,7 @@ const GENIUS_LOGO_URL = 'https://images.genius.com/8ed669cadd956443e29c70361ec4f
|
|||
const TIDAL_LOGO_URL = 'https://www.svgrepo.com/show/519734/tidal.svg';
|
||||
const QOBUZ_LOGO_URL = 'https://www.svgrepo.com/show/504778/qobuz.svg';
|
||||
const DISCOGS_LOGO_URL = 'https://www.svgrepo.com/show/305957/discogs.svg';
|
||||
const AMAZON_LOGO_URL = '/static/amazon.svg';
|
||||
function getAudioDBLogoURL() { const el = document.querySelector('img.audiodb-logo'); return el ? el.src : null; }
|
||||
|
||||
// --- Wishlist Modal Persistence State Management ---
|
||||
|
|
|
|||
|
|
@ -1205,6 +1205,112 @@ if (document.readyState === 'loading') {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// AMAZON MUSIC ENRICHMENT WORKER
|
||||
// ===================================================================
|
||||
|
||||
async function updateAmazonEnrichmentStatus() {
|
||||
if (socketConnected) return;
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/api/enrichment/amazon/status');
|
||||
if (!response.ok) { console.warn('Amazon enrichment status endpoint unavailable'); return; }
|
||||
const data = await response.json();
|
||||
updateAmazonEnrichmentStatusFromData(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating Amazon enrichment status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAmazonEnrichmentStatusFromData(data) {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete');
|
||||
if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
} else if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
} else if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
const tooltipStatus = document.getElementById('amazon-enrich-tooltip-status');
|
||||
const tooltipCurrent = document.getElementById('amazon-enrich-tooltip-current');
|
||||
const tooltipProgress = document.getElementById('amazon-enrich-tooltip-progress');
|
||||
|
||||
if (tooltipStatus) {
|
||||
if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; }
|
||||
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running) { tooltipStatus.textContent = 'Running'; }
|
||||
else { tooltipStatus.textContent = 'Idle'; }
|
||||
}
|
||||
|
||||
if (tooltipCurrent) {
|
||||
if (data.idle) {
|
||||
tooltipCurrent.textContent = 'All items processed';
|
||||
} else if (data.current_item && data.current_item.name) {
|
||||
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
|
||||
} else {
|
||||
tooltipCurrent.textContent = 'No active matches';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.progress && tooltipProgress) {
|
||||
const artists = data.progress.artists || {};
|
||||
const albums = data.progress.albums || {};
|
||||
const tracks = data.progress.tracks || {};
|
||||
const currentType = data.current_item?.type;
|
||||
let progressText = '';
|
||||
const artistsComplete = artists.matched >= artists.total;
|
||||
const albumsComplete = albums.matched >= albums.total;
|
||||
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
|
||||
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
|
||||
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
|
||||
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
|
||||
} else {
|
||||
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
|
||||
}
|
||||
tooltipProgress.textContent = progressText;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAmazonEnrichment() {
|
||||
try {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (!button) return;
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/enrichment/amazon/pause' : '/api/enrichment/amazon/resume';
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Amazon enrichment`);
|
||||
}
|
||||
await updateAmazonEnrichmentStatus();
|
||||
console.log(`Amazon enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||
} catch (error) {
|
||||
console.error('Error toggling Amazon enrichment:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleAmazonEnrichment);
|
||||
updateAmazonEnrichmentStatus();
|
||||
setInterval(updateAmazonEnrichmentStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleAmazonEnrichment);
|
||||
updateAmazonEnrichmentStatus();
|
||||
setInterval(updateAmazonEnrichmentStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// HYDRABASE P2P MIRROR WORKER
|
||||
// ===================================================================
|
||||
|
|
|
|||
|
|
@ -3416,6 +3416,10 @@ const WHATS_NEW = {
|
|||
'2.5.3': [
|
||||
{ unreleased: true },
|
||||
{ title: 'Amazon Music Download Source', desc: 'new download source backed by T2Tunes proxy. searches the Amazon Music catalog, downloads 24-bit/48kHz FLAC (or Opus 320kbps / Dolby Atmos EAC3 fallback). codec waterfall mirrors Tidal/Qobuz — best quality first, auto-fallback. selectable as a standalone or hybrid source from Settings.', page: 'settings' },
|
||||
{ title: 'Amazon Music Search Quality', desc: 'search results now show album art, artist images (album cover stand-in, same as iTunes), and correct track/disc numbers. feat. credits stripped from artist names so the same artist does not show as duplicates. [Explicit] stripped from album names so MusicBrainz matching works cleanly — Clean / Edited / Censored labels kept as-is. album clicks and artist detail pages now open instead of 404ing.', page: 'search' },
|
||||
{ title: 'Amazon Music Enrichment Worker', desc: 'background enrichment worker matches library artists, albums, and tracks to Amazon ASINs and backfills artist thumbnails from album covers. shows up in the enrichment panel with its own orb and rate-limit gauge. pauses automatically during library scans.', page: 'settings' },
|
||||
{ title: 'Amazon Music Library Badges', desc: 'Amazon Music badges now appear on artist cards, the artist hero section, and the enhanced library view alongside Spotify, Deezer, Tidal, etc. match status chips in the enhanced view show Amazon enrichment progress for artists and albums with click-to-rematch. album and track ASINs link out to music.amazon.com.', page: 'library' },
|
||||
{ title: 'Amazon Music Watchlist Linking', desc: 'watchlist linked provider section now includes Amazon Music. shows which Amazon Music artist slug is mapped to a watchlist entry, with match/fix/clear controls and live search backed by the T2Tunes catalog.', page: 'settings' },
|
||||
],
|
||||
'2.5.2': [
|
||||
// --- May 13, 2026 — 2.5.2 release ---
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ function buildLibraryArtistCardHTML(artist, index) {
|
|||
if (artist.tidal_id) badges.push({ logo: TIDAL_LOGO_URL, fb: 'TD', title: 'Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` });
|
||||
if (artist.qobuz_id) badges.push({ logo: QOBUZ_LOGO_URL, fb: 'Qz', title: 'Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` });
|
||||
if (artist.discogs_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs', url: `https://www.discogs.com/artist/${artist.discogs_id}` });
|
||||
if (artist.amazon_id) badges.push({ logo: AMAZON_LOGO_URL, fb: 'AMZ', title: 'Amazon Music', url: null });
|
||||
if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push({ logo: '/static/trans2.png', fb: 'SS', title: `SoulID: ${artist.soul_id}`, url: null });
|
||||
|
||||
// Watchlist badge
|
||||
|
|
@ -1128,6 +1129,7 @@ function updateArtistDetailPageHeaderWithData(artist) {
|
|||
if (artist.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${artist.tidal_id}`));
|
||||
if (artist.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${artist.qobuz_id}`));
|
||||
if (artist.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${artist.discogs_id}`));
|
||||
if (artist.amazon_id) badges.push(_hb(AMAZON_LOGO_URL, 'AMZ', 'Amazon Music', null));
|
||||
if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push(_hb('/static/trans2.png', 'SS', `SoulID: ${artist.soul_id}`, null));
|
||||
|
||||
badgesContainer.innerHTML = badges.join('');
|
||||
|
|
@ -2959,6 +2961,7 @@ function renderArtistMetaPanel(artist) {
|
|||
{ key: 'genius_url', label: 'Genius', svc: 'genius' },
|
||||
{ key: 'tidal_id', label: 'Tidal', svc: 'tidal' },
|
||||
{ key: 'qobuz_id', label: 'Qobuz', svc: 'qobuz' },
|
||||
{ key: 'amazon_id', label: 'Amazon Music', svc: 'amazon' },
|
||||
];
|
||||
idSources.forEach(src => {
|
||||
if (artist[src.key]) {
|
||||
|
|
@ -3094,6 +3097,7 @@ function renderArtistMetaPanel(artist) {
|
|||
{ key: 'genius_match_status', label: 'Genius', attempted: 'genius_last_attempted', svc: 'genius' },
|
||||
{ key: 'tidal_match_status', label: 'Tidal', attempted: 'tidal_last_attempted', svc: 'tidal' },
|
||||
{ key: 'qobuz_match_status', label: 'Qobuz', attempted: 'qobuz_last_attempted', svc: 'qobuz' },
|
||||
{ key: 'amazon_match_status', label: 'Amazon', attempted: 'amazon_last_attempted', svc: 'amazon' },
|
||||
];
|
||||
statusServices.forEach(s => {
|
||||
const status = artist[s.key];
|
||||
|
|
@ -3462,6 +3466,7 @@ function renderExpandedAlbumHeader(album) {
|
|||
{ key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' },
|
||||
{ key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' },
|
||||
{ key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' },
|
||||
{ key: 'amazon_match_status', label: 'Amazon', attempted: 'amazon_last_attempted', svc: 'amazon' },
|
||||
];
|
||||
statusSvcs.forEach(s => {
|
||||
const status = album[s.key];
|
||||
|
|
@ -5125,6 +5130,14 @@ function getServiceUrl(service, entityType, id) {
|
|||
album: `https://www.qobuz.com/album/${id}`,
|
||||
track: `https://www.qobuz.com/track/${id}`,
|
||||
},
|
||||
discogs: {
|
||||
artist: `https://www.discogs.com/artist/${id}`,
|
||||
album: `https://www.discogs.com/release/${id}`,
|
||||
},
|
||||
amazon: {
|
||||
album: `https://music.amazon.com/albums/${id}`,
|
||||
track: `https://music.amazon.com/tracks/${id}`,
|
||||
},
|
||||
};
|
||||
return urls[service] && urls[service][entityType] || null;
|
||||
}
|
||||
|
|
@ -5564,7 +5577,8 @@ function openManualMatchModal(entityType, entityId, service, defaultQuery, artis
|
|||
|
||||
const serviceLabels = {
|
||||
spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer',
|
||||
audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius'
|
||||
audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius',
|
||||
tidal: 'Tidal', qobuz: 'Qobuz', amazon: 'Amazon Music'
|
||||
};
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ const SOURCE_LABELS = {
|
|||
logo: '/static/hydrabase.png',
|
||||
tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase',
|
||||
},
|
||||
amazon: {
|
||||
text: 'Amazon Music', icon: '🛒',
|
||||
tabClass: 'enh-tab-amazon', badgeClass: 'enh-badge-amazon',
|
||||
},
|
||||
musicbrainz: {
|
||||
text: 'MusicBrainz', icon: '🧠',
|
||||
logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png',
|
||||
|
|
@ -82,7 +86,7 @@ const SOURCE_LABELS = {
|
|||
// Canonical display order for the source picker. Standard metadata sources
|
||||
// first, then YouTube Music Videos, then Soulseek (basic-file source).
|
||||
const SOURCE_ORDER = [
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'amazon', 'musicbrainz',
|
||||
'youtube_videos', 'soulseek',
|
||||
];
|
||||
|
||||
|
|
@ -91,7 +95,7 @@ const SOURCE_ORDER = [
|
|||
// Soulseek IS configurable (needs slskd URL), so it's intentionally not here:
|
||||
// /api/settings/config-status reports its real state and the picker dims it
|
||||
// when no slskd is set up, redirecting clicks to Settings → Downloads.
|
||||
const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']);
|
||||
const _ALWAYS_CONFIGURED_SOURCES = new Set(['amazon', 'musicbrainz', 'youtube_videos']);
|
||||
|
||||
// Fetch /api/settings/config-status and return a map { src -> bool }
|
||||
// covering every source in SOURCE_ORDER. Sources not present in the backend
|
||||
|
|
|
|||
|
|
@ -9648,6 +9648,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.tidal-enrich-tooltip,
|
||||
.qobuz-enrich-tooltip,
|
||||
.discogs-tooltip,
|
||||
.amazon-enrich-tooltip,
|
||||
.hydrabase-tooltip,
|
||||
.repair-tooltip,
|
||||
.soulid-tooltip {
|
||||
|
|
@ -16438,6 +16439,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
color: #D4A574;
|
||||
}
|
||||
|
||||
.watchlist-source-amazon {
|
||||
background: rgba(255, 153, 0, 0.15);
|
||||
color: #FF9900;
|
||||
}
|
||||
|
||||
.watchlist-card-pills {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
|
@ -34545,6 +34551,7 @@ div.artist-hero-badge {
|
|||
.enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; }
|
||||
.enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; }
|
||||
.enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; }
|
||||
.enh-source-tab.enh-tab-amazon.active { background: rgba(255, 153, 0, 0.2); color: #FF9900; }
|
||||
.enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; }
|
||||
.enh-source-tab.enh-tab-musicbrainz.active { background: rgba(186, 51, 88, 0.2); color: #BA3358; }
|
||||
|
||||
|
|
@ -36887,6 +36894,185 @@ div.artist-hero-badge {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AMAZON MUSIC ENRICHMENT WORKER BUTTON
|
||||
============================================================ */
|
||||
|
||||
.amazon-enrich-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.amazon-enrich-button {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 153, 0, 0.10) 0%,
|
||||
rgba(255, 120, 0, 0.16) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1.5px solid rgba(255, 153, 0, 0.22);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(255, 153, 0, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.amazon-enrich-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.6;
|
||||
filter: brightness(0) invert(1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.amazon-enrich-button:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: rgba(255, 153, 0, 0.45);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(255, 153, 0, 0.28),
|
||||
0 3px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.amazon-enrich-spinner {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: rgba(255, 153, 0, 0.85);
|
||||
border-right-color: rgba(255, 153, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes amazon-enrich-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.amazon-enrich-button.active .amazon-enrich-spinner {
|
||||
opacity: 1;
|
||||
animation: amazon-enrich-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.amazon-enrich-button.active {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 153, 0, 0.20) 0%,
|
||||
rgba(255, 100, 0, 0.26) 100%);
|
||||
border-color: rgba(255, 153, 0, 0.55);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(255, 153, 0, 0.35),
|
||||
0 3px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.amazon-enrich-button.active .amazon-enrich-logo {
|
||||
opacity: 1;
|
||||
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(255, 180, 50, 0.5));
|
||||
}
|
||||
|
||||
.amazon-enrich-button.complete {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(76, 175, 80, 0.15) 0%,
|
||||
rgba(56, 142, 60, 0.20) 100%);
|
||||
border-color: rgba(76, 175, 80, 0.35);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(76, 175, 80, 0.2),
|
||||
0 2px 8px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.amazon-enrich-button.complete .amazon-enrich-logo {
|
||||
opacity: 0.8;
|
||||
filter: brightness(0) invert(0.6) sepia(1) saturate(3) hue-rotate(80deg);
|
||||
}
|
||||
|
||||
.amazon-enrich-button.complete .amazon-enrich-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.amazon-enrich-button.paused {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 193, 7, 0.12) 0%,
|
||||
rgba(255, 152, 0, 0.18) 100%);
|
||||
border-color: rgba(255, 193, 7, 0.35);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(255, 193, 7, 0.2),
|
||||
0 2px 8px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.amazon-enrich-button.paused .amazon-enrich-logo {
|
||||
opacity: 0.5;
|
||||
filter: brightness(0) invert(0.6) sepia(1) saturate(5) hue-rotate(15deg);
|
||||
}
|
||||
|
||||
.amazon-enrich-button.paused .amazon-enrich-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.amazon-enrich-tooltip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(100% + 12px);
|
||||
transform: translateX(-50%) translateY(-5px);
|
||||
z-index: 5000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.amazon-enrich-button:hover + .amazon-enrich-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.amazon-enrich-tooltip-content {
|
||||
min-width: 260px;
|
||||
background: linear-gradient(135deg, rgba(30, 30, 30, 0.98) 0%, rgba(20, 20, 20, 0.99) 100%);
|
||||
backdrop-filter: blur(40px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.amazon-enrich-tooltip-header {
|
||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.amazon-enrich-tooltip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#amazon-enrich-tooltip-status {
|
||||
color: #FF9900;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deezer-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
{ container: '.tidal-enrich-button-container', color: [180, 180, 255] },
|
||||
{ container: '.qobuz-enrich-button-container', color: [1, 112, 239] },
|
||||
{ container: '.discogs-button-container', color: [180, 180, 180] },
|
||||
{ container: '.amazon-enrich-button-container', color: [255, 153, 0] },
|
||||
{ container: '.hydrabase-button-container', color: [200, 200, 200] },
|
||||
{ container: '.soulid-button-container', color: [29, 185, 84], rainbow: true },
|
||||
{ container: '.repair-button-container', color: [180, 130, 255], rainbow: true },
|
||||
|
|
|
|||
Loading…
Reference in a new issue