From 1f579cede84947bb67299e057ac75e4a6c67bfb8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 13:52:26 -0700 Subject: [PATCH 01/16] Add Amazon Music as a primary metadata source Wires AmazonClient into the metadata source registry following the exact same pattern as DeezerClient. No existing source paths touched. - Add get_album_metadata / get_artist_info / get_artist_albums_list aliases to AmazonClient (mirrors DeezerClient interface aliases) - Register amazon in METADATA_SOURCE_PRIORITY and METADATA_SOURCE_LABELS - Add _get_amazon_factory() + get_amazon_client() to registry.py - Add amazon branch to get_client_for_source(); thread amazon_client_factory kwarg through get_primary_client() and get_primary_source_status() - Re-export get_amazon_client from the core.metadata_service shim - Add Amazon Music option to Settings metadata source dropdown - 3530 tests pass --- core/amazon_client.py | 5 +++++ core/metadata/registry.py | 31 ++++++++++++++++++++++++++++++- core/metadata_service.py | 2 ++ webui/index.html | 3 ++- webui/static/helper.js | 1 + 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index 421781ae..9121fd0f 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -511,6 +511,11 @@ class AmazonClient: """Not available from Amazon Music — returns None for compatibility.""" 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 # ------------------------------------------------------------------ diff --git a/core/metadata/registry.py b/core/metadata/registry.py index d2dc86f7..76a8b212 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -18,13 +18,14 @@ logger = get_logger("metadata.registry") MetadataClientFactory = Callable[[], Any] -METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase") +METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase", "amazon") METADATA_SOURCE_LABELS = { "spotify": "Spotify", "itunes": "iTunes", "deezer": "Deezer", "discogs": "Discogs", "hydrabase": "Hydrabase", + "amazon": "Amazon Music", } _UNSET = object() @@ -140,6 +141,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 +269,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 +352,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 +361,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 +371,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 +385,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 +412,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 +436,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 diff --git a/core/metadata_service.py b/core/metadata_service.py index 5391a80b..748a9cf9 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -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", diff --git a/webui/index.html b/webui/index.html index af08e1c0..e0db6b88 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3632,10 +3632,11 @@ +
-
Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token.
+
Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. Amazon Music uses the T2Tunes proxy — no account needed.
diff --git a/webui/static/helper.js b/webui/static/helper.js index 40f4c803..5c2414ef 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.5.3': [ { unreleased: true }, + { title: 'Amazon Music Metadata Source', desc: 'Amazon Music is now a selectable primary metadata source alongside Spotify, iTunes, Deezer, and Discogs. backed by the same T2Tunes proxy as the download source — no account needed. covers track search, album lookup with cover art, and artist discography. select it under Settings → Connections → Metadata Source.', page: 'settings' }, { 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' }, ], '2.5.2': [ From d39679951bcceaa657a2c88c21a5c51cddc49d9e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 14:18:18 -0700 Subject: [PATCH 02/16] Wire Amazon Music into enhanced search and global search source picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'amazon' to VALID_SOURCES (and transitively VALID_STREAM_SOURCES) in core/search/orchestrator.py so the backend accepts it as a requested source without returning 400 - Add resolve_client('amazon') case — mirrors musicbrainz pattern, gets the cached AmazonClient from the metadata registry - Add 'amazon' to _alternate_sources() so it appears as a tab when another source is primary (always available, no credentials) - Add SERVICE_CONFIG_REGISTRY entry 'amazon': {'always': True} so /api/settings/config-status reports it as configured - Add SOURCE_LABELS['amazon'] and SOURCE_ORDER entry in shared-helpers.js so both enhanced search and global search show the Amazon Music tab - Add 'amazon' to _ALWAYS_CONFIGURED_SOURCES so the picker never dims the tab (no credentials required) - Add .enh-tab-amazon.active CSS (Amazon orange #FF9900) - 3530 tests pass --- core/search/orchestrator.py | 11 ++++++++++- web_server.py | 1 + webui/static/shared-helpers.js | 8 ++++++-- webui/static/style.css | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/core/search/orchestrator.py b/core/search/orchestrator.py index da934759..a1961424 100644 --- a/core/search/orchestrator.py +++ b/core/search/orchestrator.py @@ -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 diff --git a/web_server.py b/web_server.py index 24f7f773..c9a774f2 100644 --- a/web_server.py +++ b/web_server.py @@ -1858,6 +1858,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']]}, diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 6cf1da3d..82b86653 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -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 diff --git a/webui/static/style.css b/webui/static/style.css index 074a4479..bffb0361 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34545,6 +34545,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; } From 51e00d4ebf8c15fc59fbb9c112f19ab890b327cd Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 15:55:15 -0700 Subject: [PATCH 03/16] Fix Amazon Music search quality: images, dedup, explicit stripping, album/artist clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All search_raw calls switched from single-type to types="track,album" — T2Tunes only returns results when both types are requested together - _fetch_album_metas: parallel fetch (up to 5 workers) of album cover art via album_metadata(asin) — T2Tunes search results carry no image URLs - search_tracks: populates image_url, release_date, total_tracks from album meta - search_artists: strips feat. credits via _primary_artist() so "Artist feat. X" and "Artist ft. Y" collapse to one "Artist" entry; uses album cover as artist image stand-in (same approach as iTunes — T2Tunes has no artist images) - search_albums: name-based dedup (display_name + artist key) instead of ASIN-based; populates image_url, release_date, total_tracks from album meta (cap 10 ASIN fetches) - _strip_edition(): strips [Explicit]/(Explicit) from track/album names — explicit is the default version; Clean/Edited/Censored labels kept as-is so they stay distinct - get_album(): applies _strip_edition to name and _primary_artist to artist so MusicBrainz preflight matching doesn't fail on "[Explicit]" album names - get_album_tracks(): populates track_number and disc_number from T2TunesStreamInfo instead of hardcoding None — fixes track ordering in multi-track album downloads - get_artist() / get_artist_albums(): _unslugify() converts slug artist IDs back to search names; _primary_artist() in comparison handles feat-annotated results - SOURCE_ONLY_ARTIST_SOURCES: added "amazon" so artist detail page doesn't 404 - build_source_only_artist_detail: added amazon_client param + dispatch branch - web_server.py: resolve amazon_client in _build_source_only_artist_detail wrapper; add source_override=="amazon" branch in get_spotify_album_tracks endpoint - 77 tests covering all above paths; all pass --- core/amazon_client.py | 152 +++++++++++++++++++++++------- core/artist_source_detail.py | 7 ++ core/artist_source_lookup.py | 2 +- tests/tools/test_amazon_client.py | 77 +++++++++++++++ web_server.py | 11 +++ webui/static/helper.js | 1 + 6 files changed, 217 insertions(+), 33 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index 9121fd0f..bfc2c21c 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -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" @@ -300,55 +323,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 "") + 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 "") + 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]]: @@ -413,8 +476,8 @@ class AmazonClient: result: Dict[str, Any] = { "id": asin, - "name": album.get("title", ""), - "artists": [{"name": album.get("artistName", ""), "id": ""}], + "name": _strip_edition(album.get("title", "")), + "artists": [{"name": _primary_artist(album.get("artistName", "")), "id": ""}], "release_date": album.get("release_date", ""), "total_tracks": album.get("trackCount", 0), "album_type": "album", @@ -444,8 +507,8 @@ class AmazonClient: "name": s.title, "artists": [{"name": s.artist, "id": ""}], "duration_ms": 0, - "track_number": None, - "disc_number": None, + "track_number": s.track_number, + "disc_number": s.disc_number, "isrc": s.isrc, } for s in streams @@ -455,14 +518,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,15 +548,18 @@ 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] = [] 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: @@ -520,6 +587,27 @@ class AmazonClient: # 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: + _rate_limit() + try: + raw = self.album_metadata(asin) + lst = raw.get("albumList") + if isinstance(lst, list) and lst and isinstance(lst[0], dict): + metas[asin] = lst[0] + except Exception: + pass + + 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: diff --git a/core/artist_source_detail.py b/core/artist_source_detail.py index c376052e..35b3e4de 100644 --- a/core/artist_source_detail.py +++ b/core/artist_source_detail.py @@ -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}") diff --git a/core/artist_source_lookup.py b/core/artist_source_lookup.py index b965d4cf..d7497e1c 100644 --- a/core/artist_source_lookup.py +++ b/core/artist_source_lookup.py @@ -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", }) diff --git a/tests/tools/test_amazon_client.py b/tests/tools/test_amazon_client.py index bdceea74..d869c5d1 100644 --- a/tests/tools/test_amazon_client.py +++ b/tests/tools/test_amazon_client.py @@ -124,6 +124,8 @@ MEDIA_RESPONSE_FLAC = { "artist": "Kendrick Lamar", "album": "GNX", "isrc": "USRC12345678", + "trackNumber": "3", + "discNumber": "1", }, } @@ -536,6 +538,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 +629,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 @@ -766,6 +841,8 @@ 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_returns_none_on_api_error(self): client = _make_client() diff --git a/web_server.py b/web_server.py index c9a774f2..bf896aa2 100644 --- a/web_server.py +++ b/web_server.py @@ -7283,6 +7283,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: @@ -7296,6 +7303,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 @@ -18665,6 +18673,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 diff --git a/webui/static/helper.js b/webui/static/helper.js index 5c2414ef..cf6dfed8 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3417,6 +3417,7 @@ const WHATS_NEW = { { unreleased: true }, { title: 'Amazon Music Metadata Source', desc: 'Amazon Music is now a selectable primary metadata source alongside Spotify, iTunes, Deezer, and Discogs. backed by the same T2Tunes proxy as the download source — no account needed. covers track search, album lookup with cover art, and artist discography. select it under Settings → Connections → Metadata Source.', page: 'settings' }, { 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' }, ], '2.5.2': [ // --- May 13, 2026 — 2.5.2 release --- From 8a3bb886787099699b61f9f989c0118827a1a873 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 16:01:11 -0700 Subject: [PATCH 04/16] Fix AcoustID quarantine and disc_number crash on Amazon album downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AcoustID verification was quarantining every Amazon track because T2Tunes embeds [Explicit] and [feat. X] in stream tag titles/artists, but AcoustID returns bare titles — triggering version-mismatch rejection on every track. - get_track_details: apply _strip_edition to name/album, _primary_artist to artist; wire s.track_number / s.disc_number instead of hardcoded None - get_album_tracks: apply _strip_edition to name, _primary_artist to artist Also fix TypeError crash in album download paths when disc_number is None (present in dict but explicitly None, so .get('disc_number', 1) returns None): - master.py run_full_missing_tracks_process: or 1 guard on both max() and disc_num - candidates.py track_info extraction: or 1 guard on both disc_number reads - web_server.py enhanced + standard album download max() calls: or 1 guard --- core/amazon_client.py | 14 +++++++------- core/downloads/candidates.py | 4 ++-- core/downloads/master.py | 4 ++-- web_server.py | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index bfc2c21c..cf6ca95e 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -436,11 +436,11 @@ 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", ""), "total_tracks": album_data.get("trackCount", 0), @@ -448,8 +448,8 @@ class AmazonClient: "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": { @@ -504,8 +504,8 @@ class AmazonClient: items = [ { "id": s.asin, - "name": s.title, - "artists": [{"name": s.artist, "id": ""}], + "name": _strip_edition(s.title), + "artists": [{"name": _primary_artist(s.artist), "id": ""}], "duration_ms": 0, "track_number": s.track_number, "disc_number": s.disc_number, diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index d74cfa9d..edb1fdc1 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -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']}") diff --git a/core/downloads/master.py b/core/downloads/master.py index 40a29ab2..3e32cc65 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -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 diff --git a/web_server.py b/web_server.py index bf896aa2..4e7e62d9 100644 --- a/web_server.py +++ b/web_server.py @@ -12013,7 +12013,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 @@ -12155,7 +12155,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 From c2fcef45c2171695e7da9a6ae77ffa1b733c0ea1 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 16:11:57 -0700 Subject: [PATCH 05/16] Fix $year missing and disc_number crash on Amazon album downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release_date: T2Tunes album metadata may use camelCase releaseDate — try both keys at all read sites (get_album, get_track_details, Album.from_metadata, _fetch_album_metas consumers). Final fallback: s.date from stream tags, which T2Tunes always populates from embedded FLAC/MP4 date tag. Wire s.date into get_album_tracks items and get_track_details album.release_date so the $year path template resolves correctly. disc_number crash: .get('disc_number', 1) returns None when key is present but value is None (Amazon stream info has Optional[int] for disc_number). Switch all max() call sites and disc_num assignments to `or 1` guard: - master.py: run_full_missing_tracks_process max() and disc_num read - candidates.py: track_info and detailed_track disc_number reads - web_server.py: enhanced and standard album download max() calls --- core/amazon_client.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index cf6ca95e..9b000372 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -169,7 +169,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"), @@ -349,7 +349,7 @@ class AmazonClient: 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 "") + track.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "") track.total_tracks = meta.get("trackCount") tracks.append(track) return tracks @@ -409,7 +409,7 @@ class AmazonClient: if asin in album_metas: meta = album_metas[asin] album.image_url = meta.get("image") - album.release_date = str(meta.get("release_date") or "") + 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 @@ -442,7 +442,7 @@ class AmazonClient: "id": album_data.get("asin", ""), "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, @@ -478,7 +478,7 @@ class AmazonClient: "id": asin, "name": _strip_edition(album.get("title", "")), "artists": [{"name": _primary_artist(album.get("artistName", "")), "id": ""}], - "release_date": album.get("release_date", ""), + "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 [], @@ -509,6 +509,7 @@ class AmazonClient: "duration_ms": 0, "track_number": s.track_number, "disc_number": s.disc_number, + "release_date": s.date or "", "isrc": s.isrc, } for s in streams From 96a1c8b7b81e1a916d25d0a566b93d01edafa13f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 16:21:12 -0700 Subject: [PATCH 06/16] Enrich Amazon album track durations via search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media_from_asin returns no duration data. get_album_tracks now does one search_raw call using the album name + primary artist from stream tags, filters hits by albumAsin == requested asin, and builds a duration_map (track asin → duration_ms). Search failures are swallowed — duration_ms falls back to 0 so the existing behaviour is preserved on error. 2 new tests: duration populated when search returns matching hit; duration stays 0 when search endpoint returns an error. --- core/amazon_client.py | 18 +++++++++++++++++- tests/tools/test_amazon_client.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index 9b000372..c566f4c4 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -501,12 +501,28 @@ 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: + pass + items = [ { "id": s.asin, "name": _strip_edition(s.title), "artists": [{"name": _primary_artist(s.artist), "id": ""}], - "duration_ms": 0, + "duration_ms": duration_map.get(s.asin, 0), "track_number": s.track_number, "disc_number": s.disc_number, "release_date": s.date or "", diff --git a/tests/tools/test_amazon_client.py b/tests/tools/test_amazon_client.py index d869c5d1..cc758371 100644 --- a/tests/tools/test_amazon_client.py +++ b/tests/tools/test_amazon_client.py @@ -844,6 +844,30 @@ class TestGetAlbumTracks: 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() client.session.get.return_value = _mock_response({}, 500) From d944884ab4982d63b4aa31d5649abd02e552849d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 16:32:54 -0700 Subject: [PATCH 07/16] Backfill album release_date from stream tags when T2Tunes metadata omits it T2Tunes albumList entries may not include a release_date field, leaving the $year path template empty. get_album() now falls back to the first track's release_date (populated from the FLAC date tag via get_album_tracks) when album metadata has none. Also try camelCase releaseDate key at all albumList read sites (Album.from_metadata, get_album, _fetch_album_metas consumers). 1 new test: release_date backfilled from stream date tag when absent from album metadata. date tag "2024-11-22" added to MEDIA_RESPONSE_FLAC fixture. --- core/amazon_client.py | 16 +++++++++++----- tests/tools/test_amazon_client.py | 8 ++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index c566f4c4..278493c4 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -486,12 +486,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]]: diff --git a/tests/tools/test_amazon_client.py b/tests/tools/test_amazon_client.py index cc758371..a2e21938 100644 --- a/tests/tools/test_amazon_client.py +++ b/tests/tools/test_amazon_client.py @@ -126,6 +126,7 @@ MEDIA_RESPONSE_FLAC = { "isrc": "USRC12345678", "trackNumber": "3", "discNumber": "1", + "date": "2024-11-22", }, } @@ -810,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"): From 265fe5233e804ca3f6cac41c4a897a11562054f0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 17:01:37 -0700 Subject: [PATCH 08/16] Fix Amazon artist detail: library upgrade lookup and artist images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library upgrade: find_library_artist_for_source returned None immediately for Amazon because SOURCE_ID_FIELD has no 'amazon' entry (no DB column for Amazon artist IDs). The name-based fallback was unreachable. Fix: only skip the column query when column is None, not the whole function — name lookup now runs for any source when artist_name + active_server are provided. Artist images: add AmazonClient._get_artist_image_from_albums so the standard _get_artist_image_from_source path in metadata/artist_image.py can call it as a fallback (same hook iTunes/Deezer/Discogs expose). Searches by unslugified artist name, matches primary artist, fetches album cover from album_metadata. Test: updated test_source_only_set_matches_mapping_keys → _contains_all_mapped_ sources to assert subset (not equality) — SOURCE_ONLY_ARTIST_SOURCES intentionally includes sources without a DB column that rely on name-only lookup. --- core/amazon_client.py | 19 +++++++++++++++++++ core/artist_source_lookup.py | 17 ++++++++--------- tests/metadata/test_artist_source_lookup.py | 10 +++++----- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index 278493c4..3febed1e 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -601,6 +601,25 @@ class AmazonClient: """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 diff --git a/core/artist_source_lookup.py b/core/artist_source_lookup.py index d7497e1c..72888af2 100644 --- a/core/artist_source_lookup.py +++ b/core/artist_source_lookup.py @@ -58,19 +58,18 @@ def find_library_artist_for_source( Returns ``None`` on miss or on any database error. """ column = SOURCE_ID_FIELD.get(source) - if not column: - return None try: with database._get_connection() as conn: cursor = conn.cursor() - cursor.execute( - f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1", - (str(source_artist_id),), - ) - row = cursor.fetchone() - if row: - return row[0] + if column: + cursor.execute( + f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1", + (str(source_artist_id),), + ) + row = cursor.fetchone() + if row: + return row[0] if artist_name and active_server: cursor.execute( diff --git a/tests/metadata/test_artist_source_lookup.py b/tests/metadata/test_artist_source_lookup.py index 75ca4582..7e803d70 100644 --- a/tests/metadata/test_artist_source_lookup.py +++ b/tests/metadata/test_artist_source_lookup.py @@ -68,11 +68,11 @@ class TestSourceIdFieldMapping: "(and the test body) to match." ) - def test_source_only_set_matches_mapping_keys(self): - """Sources eligible for the source-only fallback must all have a - column to look them up by — otherwise the upgrade path silently - returns None.""" - assert SOURCE_ONLY_ARTIST_SOURCES == frozenset(SOURCE_ID_FIELD.keys()) + def test_source_only_set_contains_all_mapped_sources(self): + """Every source with a SOURCE_ID_FIELD column must also be in + SOURCE_ONLY_ARTIST_SOURCES. The reverse is not required — sources + without a DB column (e.g. amazon) use name-based lookup only.""" + assert frozenset(SOURCE_ID_FIELD.keys()).issubset(SOURCE_ONLY_ARTIST_SOURCES) def test_every_mapped_column_exists_on_artists_table(self, db): """Regression for the 2026-04 ``deezer_artist_id`` typo: every column From 121651da2c31335fb26ff449c66d34b5e319bd2a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 17:06:54 -0700 Subject: [PATCH 09/16] Add amazon_id column to artists table for full source parity Schema: ALTER TABLE artists ADD COLUMN amazon_id TEXT with index, added via _add_amazon_columns migration called after Discogs in _run_migrations. SOURCE_ID_FIELD: add "amazon" -> "amazon_id" entry. find_library_artist_for_ source now looks up Amazon artists by slug before falling back to name match, same as every other source. artist_source_detail already stamps artist_info [source_id_field] = artist_id so the amazon_id is set on source-only payloads. Tests: add "amazon": "amazon_id" to EXPECTED_SOURCE_ID_FIELD; revert test assertion back to strict equality (SOURCE_ONLY_ARTIST_SOURCES == SOURCE_ID_ FIELD.keys() holds again now that amazon has a column). --- core/artist_source_lookup.py | 18 ++++++++++-------- database/music_database.py | 15 +++++++++++++++ tests/metadata/test_artist_source_lookup.py | 11 ++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/core/artist_source_lookup.py b/core/artist_source_lookup.py index 72888af2..507d09e6 100644 --- a/core/artist_source_lookup.py +++ b/core/artist_source_lookup.py @@ -36,6 +36,7 @@ SOURCE_ID_FIELD = { "discogs": "discogs_id", "hydrabase": "soul_id", "musicbrainz": "musicbrainz_id", + "amazon": "amazon_id", } @@ -58,18 +59,19 @@ def find_library_artist_for_source( Returns ``None`` on miss or on any database error. """ column = SOURCE_ID_FIELD.get(source) + if not column: + return None try: with database._get_connection() as conn: cursor = conn.cursor() - if column: - cursor.execute( - f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1", - (str(source_artist_id),), - ) - row = cursor.fetchone() - if row: - return row[0] + cursor.execute( + f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1", + (str(source_artist_id),), + ) + row = cursor.fetchone() + if row: + return row[0] if artist_name and active_server: cursor.execute( diff --git a/database/music_database.py b/database/music_database.py index dc3b7a80..0ea632f3 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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 @@ -2035,6 +2038,18 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding Discogs columns: {e}") + def _add_amazon_columns(self, cursor): + """Add Amazon artist ID column to artists table.""" + try: + 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") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)") + 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 `_match_status = 'matched'` for rows that already have a populated external ID but NULL match_status. diff --git a/tests/metadata/test_artist_source_lookup.py b/tests/metadata/test_artist_source_lookup.py index 7e803d70..32c03cb6 100644 --- a/tests/metadata/test_artist_source_lookup.py +++ b/tests/metadata/test_artist_source_lookup.py @@ -31,6 +31,7 @@ EXPECTED_SOURCE_ID_FIELD = { "discogs": "discogs_id", "hydrabase": "soul_id", "musicbrainz": "musicbrainz_id", + "amazon": "amazon_id", } @@ -68,11 +69,11 @@ class TestSourceIdFieldMapping: "(and the test body) to match." ) - def test_source_only_set_contains_all_mapped_sources(self): - """Every source with a SOURCE_ID_FIELD column must also be in - SOURCE_ONLY_ARTIST_SOURCES. The reverse is not required — sources - without a DB column (e.g. amazon) use name-based lookup only.""" - assert frozenset(SOURCE_ID_FIELD.keys()).issubset(SOURCE_ONLY_ARTIST_SOURCES) + def test_source_only_set_matches_mapping_keys(self): + """Sources eligible for the source-only fallback must all have a + column to look them up by — otherwise the upgrade path silently + returns None.""" + assert SOURCE_ONLY_ARTIST_SOURCES == frozenset(SOURCE_ID_FIELD.keys()) def test_every_mapped_column_exists_on_artists_table(self, db): """Regression for the 2026-04 ``deezer_artist_id`` typo: every column From 4fce832ae144b086ab70b3f1cd24f2cf2641ea9f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 17:30:48 -0700 Subject: [PATCH 10/16] Add Amazon Music enrichment worker Background worker matching library artists/albums/tracks to Amazon ASINs via T2Tunes search. Follows same 6-tier priority queue as Deezer/iTunes/ Spotify/Qobuz/Tidal workers. Backfills artist thumbnails from album cover stand-ins (T2Tunes exposes no direct artist images). - core/amazon_worker.py: new AmazonWorker class with full parity - database/music_database.py: expand _add_amazon_columns to cover amazon_id/amazon_match_status/amazon_last_attempted on artists, albums, and tracks (was artists-only) - web_server.py: import, init, register in enrichment panel, add to scan pause/resume dicts and rate monitor key map - helper.js: WHATS_NEW 2.5.3 entry for enrichment worker --- core/amazon_worker.py | 566 +++++++++++++++++++++++++++++++++++++ database/music_database.py | 39 ++- web_server.py | 26 +- webui/static/helper.js | 1 + 4 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 core/amazon_worker.py diff --git a/core/amazon_worker.py b/core/amazon_worker.py new file mode 100644 index 00000000..5103932f --- /dev/null +++ b/core/amazon_worker.py @@ -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: + pass + 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() diff --git a/database/music_database.py b/database/music_database.py index 0ea632f3..1228ec47 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -2039,13 +2039,50 @@ class MusicDatabase: logger.error(f"Error adding Discogs columns: {e}") def _add_amazon_columns(self, cursor): - """Add Amazon artist ID column to artists table.""" + """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}") diff --git a/web_server.py b/web_server.py index 4e7e62d9..3b196fe5 100644 --- a/web_server.py +++ b/web_server.py @@ -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 @@ -14212,7 +14213,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): @@ -14228,7 +14229,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(): @@ -32135,6 +32136,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 @@ -33799,6 +33814,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( @@ -33818,7 +33838,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): diff --git a/webui/static/helper.js b/webui/static/helper.js index cf6dfed8..8fac9cd9 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3418,6 +3418,7 @@ const WHATS_NEW = { { title: 'Amazon Music Metadata Source', desc: 'Amazon Music is now a selectable primary metadata source alongside Spotify, iTunes, Deezer, and Discogs. backed by the same T2Tunes proxy as the download source — no account needed. covers track search, album lookup with cover art, and artist discography. select it under Settings → Connections → Metadata Source.', page: 'settings' }, { 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: 'new background enrichment worker for Amazon Music, same as Deezer, iTunes, Spotify, Qobuz, and Tidal. matches library artists, albums, and tracks to Amazon ASINs, backfills artist thumbnails from album covers. shows up in the enrichment panel and pauses automatically during library scans.', page: 'settings' }, ], '2.5.2': [ // --- May 13, 2026 — 2.5.2 release --- From 5450f4ac5ee95382cffe85fb66f3c5bc1a46a905 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 17:43:38 -0700 Subject: [PATCH 11/16] Wire Amazon Music enrichment worker into dashboard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full parity with Deezer/Qobuz/Tidal/Discogs in every dashboard UI layer — orb button, live tooltip, WebSocket push, rate speedometer. - webui/index.html: Amazon enrichment orb button after Discogs - webui/static/amazon.svg: local icon (a + smile, same pattern as hydrabase.png — avoids external URL dependency) - webui/static/style.css: Amazon button/spinner/tooltip CSS with FF9900 brand color; added to mobile tooltip suppress list - webui/static/worker-orbs.js: Amazon orb in WORKER_DEFS [255,153,0] - webui/static/api-monitor.js: Amazon in rate gauge services list, label, and color map - webui/static/enrichment.js: updateAmazonEnrichmentStatusFromData, toggleAmazonEnrichment, DOMContentLoaded init + 2s poll - webui/static/core.js: socket.on enrichment:amazon-enrichment listener - web_server.py: amazon-enrichment added to _emit_enrichment_status_loop workers dict so WebSocket pushes fire every 2s --- web_server.py | 1 + webui/index.html | 23 +++++ webui/static/amazon.svg | 5 + webui/static/api-monitor.js | 4 +- webui/static/core.js | 1 + webui/static/enrichment.js | 106 +++++++++++++++++++++ webui/static/style.css | 180 ++++++++++++++++++++++++++++++++++++ webui/static/worker-orbs.js | 1 + 8 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 webui/static/amazon.svg diff --git a/web_server.py b/web_server.py index 3b196fe5..b9a2a80c 100644 --- a/web_server.py +++ b/web_server.py @@ -33898,6 +33898,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, diff --git a/webui/index.html b/webui/index.html index e0db6b88..7ff1b3c9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -541,6 +541,29 @@ + +
+ +
+
+
Amazon Music Enrichment
+
+
Status: Idle +
+
No active matches +
+
Progress: 0 / 0 +
+
+
+
+
-
Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. Amazon Music uses the T2Tunes proxy — no account needed.
+
Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token.
diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 509c6f73..58561523 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -974,7 +974,8 @@ async function initializeWatchlistPage() { if (artist.itunes_artist_id) sourceBadges.push('iTunes'); if (artist.deezer_artist_id) sourceBadges.push('Deezer'); if (artist.discogs_artist_id) sourceBadges.push('Discogs'); - 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('Amazon'); + const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id; return `
iTunes'); if (artist.deezer_artist_id) sourceBadges.push('Deezer'); if (artist.discogs_artist_id) sourceBadges.push('Discogs'); - 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('Amazon'); + const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id; return `
{ 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'); diff --git a/webui/static/style.css b/webui/static/style.css index 5bc6ca56..feeae228 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -16439,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; From 35db1ac06d095b2086b06aa698e7d0db770ce6d9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 16 May 2026 22:58:59 -0700 Subject: [PATCH 16/16] fix(lint): log S110 bare except-pass in amazon client and worker Three ruff S110 violations replaced with logger.debug calls: - amazon_client.py:527 duration backfill ASIN search - amazon_client.py:679 album metadata fetch in _fetch_album_metas - amazon_worker.py:401 artist image backfill from albums --- core/amazon_client.py | 8 ++++---- core/amazon_worker.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/amazon_client.py b/core/amazon_client.py index 143f0711..8d85e3cd 100644 --- a/core/amazon_client.py +++ b/core/amazon_client.py @@ -524,8 +524,8 @@ class AmazonClient: for item in search_items: if item.album_asin == asin and item.duration_seconds: duration_map[item.asin] = item.duration_seconds * 1000 - except Exception: - pass + except Exception as exc: + logger.debug("Duration backfill failed for ASIN %s: %s", asin, exc) items = [ { @@ -676,8 +676,8 @@ class AmazonClient: with _meta_cache_lock: _meta_cache[asin] = (time.monotonic(), meta) metas[asin] = meta - except Exception: - pass + 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: diff --git a/core/amazon_worker.py b/core/amazon_worker.py index 5103932f..59eaccb9 100644 --- a/core/amazon_worker.py +++ b/core/amazon_worker.py @@ -398,8 +398,8 @@ class AmazonWorker: if not image_url: try: image_url = self.client._get_artist_image_from_albums(result.id) - except Exception: - pass + 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 = ?