Merge pull request #617 from Nezreka/feature/amazon-music-metadata

Feature/amazon music metadata
This commit is contained in:
BoulderBadgeDad 2026-05-16 23:03:43 -07:00 committed by GitHub
commit 46f27583f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1447 additions and 85 deletions

View file

@ -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
View 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()

View file

@ -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}")

View file

@ -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",
}

View file

@ -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']}")

View file

@ -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

View file

@ -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 []

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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'],

View file

@ -31,6 +31,7 @@ EXPECTED_SOURCE_ID_FIELD = {
"discogs": "discogs_id",
"hydrabase": "soul_id",
"musicbrainz": "musicbrainz_id",
"amazon": "amazon_id",
}

View file

@ -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()

View file

@ -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,

View file

@ -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
View 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

View file

@ -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, '&quot;')}"
@ -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, '&quot;')}"
@ -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;

View file

@ -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 ---

View file

@ -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
// ===================================================================

View file

@ -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 ---

View file

@ -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');

View file

@ -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

View file

@ -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;

View file

@ -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 },