Direct ID lookup in Enhance Quality, like Download Discography
Followup on the previous Enhance refactor. Multi-source parallel text
search closed the worst case (users with no Spotify/Deezer getting
"unknown artist - unknown album - unknown track" wishlist entries),
but text search itself is still fragile against messy library tags:
"Title (Live)", featured artists in the artist field, etc. Download
Discography never had this problem because it resolves albums by stable
ID, not by name.
Enhance now does the same thing for tracks: for every metadata source
the user has configured, if the library track has the corresponding
stored ID (spotify_track_id / deezer_id / itunes_track_id / soul_id),
call client.get_track_details(stored_id) directly and convert to the
wishlist payload. First success wins. The user's configured primary
source is tried first so a Deezer-primary user gets Deezer payloads on
the wishlist entry (correct cover art / album shape) even when other
sources also have stored IDs for the same track.
Multi-source parallel text search stays as the fallback for tracks
with no stored IDs (e.g. manually imported, never enriched). Empty-
field rejection still gates the wishlist add.
Implementation:
- _STORED_ID_COLUMNS: source name → DB column mapping
(Discogs intentionally omitted — release-based, no per-track IDs)
- _enhanced_to_wishlist_payload: converts the get_track_details
intermediate "enhanced" shape (artists as [str]) to wishlist shape
(artists as [{'name': str}]). Spotify's raw_data is already in
wishlist shape, returned as-is when detected (preserves full
album.images that the enhanced top-level fields drop)
- _try_direct_lookup_all_sources: iterates sources preferred-first,
calls get_track_details on each that has both a stored ID and a
configured client, returns first complete-metadata payload
- spotify_client field removed from ArtistQualityDeps (no longer
used — Spotify direct lookup now flows through the generic
per-source loop using the entry from search_sources)
- _try_upgrade_to_rich_payload removed (was Spotify-only with broken
shape semantics for non-Spotify sources; search-fallback now uses
_build_payload_from_track consistently)
- get_primary_source() consulted to set the per-call preferred source
for direct-lookup priority
Also fixed a stale UI string: the Enhance modal toast read "Matching
tracks to Spotify and adding to wishlist..." regardless of which
sources were actually configured. Now reads "Matching tracks across
metadata sources...".
Tests:
- _build_deps mirrors web_server._resolve_search_sources: passing
spotify=spotify_obj auto-prepends ('spotify', spotify_obj) to
search_sources (Spotify is always added when configured in prod)
- 5 new tests pin the direct-lookup behavior:
- test_direct_lookup_via_deezer_id_skips_text_search
- test_direct_lookup_via_itunes_id_skips_text_search
- test_direct_lookup_prefers_user_primary_source
- test_direct_lookup_falls_through_to_text_search_when_no_stored_ids
- test_direct_lookup_failure_falls_through_to_text_search
- Reframed enhanced-format and search-fallback tests for the new
payload-build path (no album-image side call, search-fallback uses
_build_payload_from_track consistently)
- 22/22 quality tests green, 2133/2133 full suite green.
This commit is contained in:
parent
7316646b01
commit
3befe9349c
5 changed files with 473 additions and 157 deletions
|
|
@ -6,19 +6,24 @@ the user's selected tracks, finds the best metadata match against the
|
|||
configured primary source, and queues high-quality re-downloads on the
|
||||
wishlist with `source_type='enhance'`.
|
||||
|
||||
Per-track flow (source-agnostic):
|
||||
Per-track flow:
|
||||
|
||||
1. Resolve the existing track via the artist's full detail map (built up
|
||||
front from `database.get_artist_full_detail`).
|
||||
2. Read current quality tier from the file extension.
|
||||
3. Build `matched_track_data` for the wishlist entry, in priority order:
|
||||
- Direct Spotify lookup via stored `spotify_track_id` (only when
|
||||
Spotify is the active primary source — Spotify exposes
|
||||
`get_track_details(id)` returning rich raw data; other sources
|
||||
don't have an equivalent stored-ID-to-track API today).
|
||||
- Search match against the primary metadata source (Spotify /
|
||||
iTunes / Deezer / Discogs / Hydrabase — whichever the user has
|
||||
configured as their primary). Confidence threshold is 0.7.
|
||||
- **Direct lookup using stored source IDs** — for every source the
|
||||
user has configured, if the library track has the corresponding
|
||||
stored ID (`spotify_track_id` / `deezer_id` / `itunes_track_id` /
|
||||
`soul_id`), call `client.get_track_details(stored_id)` and convert
|
||||
the result to the wishlist payload. First success wins; the user's
|
||||
configured primary source is tried first. Mirrors what Download
|
||||
Discography does — stable IDs straight to the source's API, no
|
||||
fuzzy text matching.
|
||||
- **Multi-source parallel text search fallback** — if no stored ID
|
||||
resolved, run the shared `core.metadata.multi_source_search`
|
||||
against every configured source in parallel and pick the best
|
||||
cross-source match (auto-accept threshold 0.7).
|
||||
4. Validate the match has non-empty title, album, and artists. Reject
|
||||
matches with empty fields — those propagated as
|
||||
"unknown artist - unknown album - unknown track" wishlist entries
|
||||
|
|
@ -29,17 +34,21 @@ Per-track flow (source-agnostic):
|
|||
original file path, format tier, bitrate, and artist name.
|
||||
6. Tally `enhanced_count` / `failed_count` / per-track failure reasons.
|
||||
|
||||
The flow originally had Spotify-only logic for steps 1 and 2 with iTunes
|
||||
hardcoded as the only fallback. That broke for users with neither
|
||||
Spotify nor Deezer connected — iTunes returned sparse / no matches and
|
||||
the failure mode was silent. The Track Redownload modal had been doing
|
||||
parallel multi-source search across every configured source the whole
|
||||
time; enhance now uses the same shared module
|
||||
(``core.metadata.multi_source_search``) so both endpoints dispatch
|
||||
identically. Spotify keeps its direct-lookup fast-path optimization
|
||||
(only Spotify exposes ``get_track_details(id)`` returning a rich
|
||||
wishlist-shaped payload); when that doesn't fire, the multi-source
|
||||
parallel search picks the cross-source best match.
|
||||
The flow originally had Spotify-only logic with an iTunes search-only
|
||||
fallback. Two failure modes drove the rewrite:
|
||||
|
||||
- Users with neither Spotify nor Deezer connected got silent failures
|
||||
("unknown artist - unknown album - unknown track" wishlist entries)
|
||||
because iTunes's text search returned junk matches with empty fields
|
||||
that cleared the 0.7 confidence threshold.
|
||||
- Library tracks with messy tags ("Title (Live)", featured artists in
|
||||
the artist field, etc.) failed fuzzy text search even when a perfect
|
||||
stored ID was available — Download Discography had no such problem
|
||||
because it resolves albums by stable ID.
|
||||
|
||||
Direct-lookup-via-stored-ID matches the Download Discography contract
|
||||
for every source where we have an ID column. Text search is only the
|
||||
fallback now.
|
||||
|
||||
Returns `(payload_dict, http_status_code)` so the route wrapper can
|
||||
`jsonify()` and return.
|
||||
|
|
@ -58,17 +67,17 @@ logger = logging.getLogger(__name__)
|
|||
@dataclass
|
||||
class ArtistQualityDeps:
|
||||
"""Bundle of cross-cutting deps the artist quality enhancement needs."""
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
get_database: Callable[[], Any]
|
||||
get_wishlist_service: Callable[[], Any]
|
||||
get_current_profile_id: Callable[[], int]
|
||||
get_quality_tier_from_extension: Callable
|
||||
# Returns ``[(source_name, client), ...]`` for every metadata source
|
||||
# the user has configured (not just the active primary). The Track
|
||||
# Redownload modal already searches multi-source in parallel —
|
||||
# Enhance Quality now matches that contract via the shared
|
||||
# ``core.metadata.multi_source_search`` module.
|
||||
# the user has configured. Powers both the direct-lookup fast path
|
||||
# (resolves stored source IDs straight from each source's API,
|
||||
# like Download Discography) and the multi-source parallel text
|
||||
# search fallback (shared with Track Redownload via
|
||||
# ``core.metadata.multi_source_search``).
|
||||
get_metadata_search_sources: Callable[[], list]
|
||||
|
||||
|
||||
|
|
@ -142,100 +151,189 @@ def _build_payload_from_track(track_obj) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _spotify_direct_lookup(spotify_client, spotify_tid: str,
|
||||
fallback_artist_name: str,
|
||||
fallback_album_name: str,
|
||||
fallback_title: str) -> Optional[dict]:
|
||||
"""Spotify-only direct-lookup optimization. Spotify's
|
||||
``get_track_details(id)`` returns rich `raw_data` already in the
|
||||
wishlist payload shape; other sources don't have an equivalent
|
||||
stored-ID-to-track API today, so we fall through to search for
|
||||
them.
|
||||
# Map metadata source name → DB column on the ``tracks`` table that
|
||||
# stores that source's native track ID. Used to drive the direct-lookup
|
||||
# fast path: when a library track has a stored ID for source X and the
|
||||
# user has source X configured, skip fuzzy text search and resolve
|
||||
# straight from X's API. Mirrors what Download Discography does — stable
|
||||
# IDs all the way, no fuzzy text matching.
|
||||
#
|
||||
# Discogs is release-based and has no per-track ID column; not listed
|
||||
# here, so direct lookup never tries Discogs (search-fallback still
|
||||
# runs for Discogs as one of the parallel sources).
|
||||
_STORED_ID_COLUMNS = {
|
||||
'spotify': 'spotify_track_id',
|
||||
'deezer': 'deezer_id',
|
||||
'itunes': 'itunes_track_id',
|
||||
'hydrabase': 'soul_id',
|
||||
}
|
||||
|
||||
|
||||
def _enhanced_to_wishlist_payload(enhanced: dict,
|
||||
fallback_title: str,
|
||||
fallback_artist: str,
|
||||
fallback_album: str) -> Optional[dict]:
|
||||
"""Convert a ``get_track_details`` enhanced-shape dict to the
|
||||
Spotify-shape wishlist payload.
|
||||
|
||||
Every metadata source's ``get_track_details`` returns the same
|
||||
"enhanced" intermediate shape (top-level ``id``, ``name``,
|
||||
``artists`` as a list of strings, ``album.artists`` as strings),
|
||||
documented and pinned across spotify_client / itunes_client /
|
||||
deezer_client / hydrabase_client. The wishlist downstream expects
|
||||
Spotify's native shape (``artists`` as ``[{'name': ...}]``), so
|
||||
this helper does the conversion in one place.
|
||||
|
||||
Spotify's ``raw_data`` field is already in wishlist shape (the
|
||||
raw Spotify API response), so we return it as-is when detected,
|
||||
preserving full ``album.images`` and ``external_urls`` that the
|
||||
enhanced top-level fields drop. Other sources' ``raw_data`` is
|
||||
in source-native shape and gets ignored.
|
||||
"""
|
||||
try:
|
||||
track_details = spotify_client.get_track_details(spotify_tid)
|
||||
if not track_details:
|
||||
return None
|
||||
if track_details.get('raw_data'):
|
||||
return track_details['raw_data']
|
||||
# Enhanced format — rebuild with images
|
||||
album_data = track_details.get('album', {})
|
||||
album_images = []
|
||||
if album_data.get('id'):
|
||||
try:
|
||||
full_album = spotify_client.get_album(album_data['id'])
|
||||
if full_album and full_album.get('images'):
|
||||
album_images = full_album['images']
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'id': spotify_tid,
|
||||
'name': track_details.get('name', fallback_title),
|
||||
'artists': [
|
||||
{'name': a}
|
||||
for a in track_details.get('artists', [fallback_artist_name])
|
||||
],
|
||||
'album': {
|
||||
'id': album_data.get('id', ''),
|
||||
'name': album_data.get('name', fallback_album_name),
|
||||
'album_type': album_data.get('album_type', 'album'),
|
||||
'release_date': album_data.get('release_date', ''),
|
||||
'total_tracks': album_data.get('total_tracks', 1),
|
||||
'artists': [
|
||||
{'name': a}
|
||||
for a in album_data.get('artists', [fallback_artist_name])
|
||||
],
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': track_details.get('duration_ms', 0),
|
||||
'track_number': track_details.get('track_number', 1),
|
||||
'disc_number': track_details.get('disc_number', 1),
|
||||
'popularity': 0,
|
||||
'preview_url': None,
|
||||
'external_urls': {},
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(f"[Enhance] Spotify direct lookup failed for {spotify_tid}: {exc}")
|
||||
if not enhanced:
|
||||
return None
|
||||
raw = enhanced.get('raw_data')
|
||||
if isinstance(raw, dict):
|
||||
raw_artists = raw.get('artists')
|
||||
if (isinstance(raw_artists, list) and raw_artists
|
||||
and isinstance(raw_artists[0], dict)):
|
||||
return raw
|
||||
|
||||
artists = enhanced.get('artists') or [fallback_artist]
|
||||
album_data = enhanced.get('album') or {}
|
||||
album_artists = album_data.get('artists') or artists
|
||||
|
||||
def _to_dict_artists(seq):
|
||||
return [a if isinstance(a, dict) else {'name': a} for a in seq]
|
||||
|
||||
image_url = enhanced.get('image_url') or ''
|
||||
album_images_field = album_data.get('images')
|
||||
if isinstance(album_images_field, list) and album_images_field:
|
||||
album_images = album_images_field
|
||||
elif image_url:
|
||||
album_images = [{'url': image_url, 'height': 600, 'width': 600}]
|
||||
else:
|
||||
album_images = []
|
||||
|
||||
return {
|
||||
'id': str(enhanced.get('id', '')),
|
||||
'name': enhanced.get('name') or fallback_title,
|
||||
'artists': _to_dict_artists(artists),
|
||||
'album': {
|
||||
'id': str(album_data.get('id', '')),
|
||||
'name': album_data.get('name') or fallback_album,
|
||||
'album_type': album_data.get('album_type', 'album'),
|
||||
'release_date': album_data.get('release_date', ''),
|
||||
'total_tracks': album_data.get('total_tracks', 1),
|
||||
'artists': _to_dict_artists(album_artists),
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': enhanced.get('duration_ms', 0),
|
||||
'track_number': enhanced.get('track_number', 1),
|
||||
'disc_number': enhanced.get('disc_number', 1),
|
||||
'popularity': enhanced.get('popularity', 0),
|
||||
'preview_url': enhanced.get('preview_url'),
|
||||
'external_urls': enhanced.get('external_urls', {}),
|
||||
}
|
||||
|
||||
|
||||
# Minimum match-score threshold for accepting a match without user
|
||||
# confirmation. Mirrors the legacy single-source threshold the enhance
|
||||
def _try_direct_lookup_all_sources(track: dict,
|
||||
sources: list,
|
||||
preferred_source: Optional[str],
|
||||
title: str,
|
||||
artist_name: str,
|
||||
album_title: str
|
||||
) -> tuple:
|
||||
"""Try direct ID-based lookup on every source where the library
|
||||
track has a stored ID. Returns ``(payload, source_name)`` on first
|
||||
success, or ``(None, None)`` if no source has a stored ID with a
|
||||
successful lookup.
|
||||
|
||||
Mirrors what Download Discography does — stable IDs straight to the
|
||||
source's API, no fuzzy text matching. Avoids the failure mode where
|
||||
library text tags don't match the source's canonical title (the
|
||||
Discord report case: track tagged "Title (Live)" and source has
|
||||
"Title" → fuzzy search misses, but stored ID resolves directly).
|
||||
|
||||
Preferred source attempted first when present in ``sources``,
|
||||
typically the user's configured primary metadata source — so a
|
||||
Deezer-primary user gets Deezer art / album shape on the wishlist
|
||||
entry instead of whichever source happened to have a stored ID
|
||||
first in iteration order.
|
||||
"""
|
||||
def _priority(entry):
|
||||
name = entry[0]
|
||||
return 0 if name == preferred_source else 1
|
||||
ordered = sorted(sources, key=_priority)
|
||||
|
||||
for source_name, client in ordered:
|
||||
column = _STORED_ID_COLUMNS.get(source_name)
|
||||
if not column:
|
||||
continue
|
||||
stored_id = track.get(column)
|
||||
if not stored_id:
|
||||
continue
|
||||
if not hasattr(client, 'get_track_details'):
|
||||
continue
|
||||
try:
|
||||
enhanced = client.get_track_details(str(stored_id))
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"[Enhance] {source_name} direct lookup failed for "
|
||||
f"ID {stored_id}: {exc}"
|
||||
)
|
||||
continue
|
||||
if not enhanced:
|
||||
continue
|
||||
payload = _enhanced_to_wishlist_payload(
|
||||
enhanced, title, artist_name, album_title,
|
||||
)
|
||||
if _has_complete_metadata(payload):
|
||||
logger.info(
|
||||
f"[Enhance] Direct lookup matched: {source_name} "
|
||||
f"ID {stored_id} → '{payload.get('name')}'"
|
||||
)
|
||||
return payload, source_name
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# Minimum match-score threshold for accepting a search-fallback match
|
||||
# without user confirmation. Mirrors the legacy threshold the enhance
|
||||
# flow has always used.
|
||||
_AUTO_ACCEPT_SCORE_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def _try_upgrade_to_rich_payload(client: Any, track_id: str) -> Optional[dict]:
|
||||
"""Spotify-only optimization — ``get_track_details(id)`` returns
|
||||
rich raw_data already in the wishlist payload shape. Other sources
|
||||
don't expose this; caller falls back to ``_build_payload_from_track``.
|
||||
"""
|
||||
if not hasattr(client, 'get_track_details'):
|
||||
return None
|
||||
try:
|
||||
details = client.get_track_details(track_id)
|
||||
if details and details.get('raw_data'):
|
||||
return details['raw_data']
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
||||
"""Add selected tracks to wishlist for quality enhancement re-download.
|
||||
|
||||
Per-track flow now mirrors the Track Redownload modal: searches every
|
||||
configured metadata source in parallel via the shared
|
||||
``core.metadata.multi_source_search`` module, picks the best cross-source
|
||||
match if it clears the auto-accept threshold, validates the match
|
||||
has non-empty fields, and queues the wishlist payload.
|
||||
Per-track flow:
|
||||
|
||||
Pre-refactor this was Spotify-only with an iTunes fallback —
|
||||
Discogs / Hydrabase / Deezer-primary users got far worse coverage
|
||||
than redownload despite both flows asking the same question
|
||||
("find me the metadata for this library track").
|
||||
1. **Direct lookup using stored source IDs** (mirrors what Download
|
||||
Discography does — stable IDs straight to the source's API, no
|
||||
fuzzy text matching). For each source the user has configured,
|
||||
if the library track has the corresponding stored ID
|
||||
(``spotify_track_id`` / ``deezer_id`` / ``itunes_track_id`` /
|
||||
``soul_id``), call ``client.get_track_details(stored_id)`` and
|
||||
convert to wishlist payload. First success wins; preferred
|
||||
source (user's configured primary) tried first.
|
||||
|
||||
2. **Multi-source parallel text search fallback** (via the shared
|
||||
``core.metadata.multi_source_search`` module — same code path
|
||||
Track Redownload uses) for tracks with no stored IDs / lookup
|
||||
misses.
|
||||
|
||||
3. **Validation**: reject matches with empty title / album / artists
|
||||
so the user sees a clear failure instead of an "unknown artist"
|
||||
wishlist entry.
|
||||
|
||||
Pre-refactor: only Spotify had a direct-lookup fast path; everything
|
||||
else went through fuzzy text search. Discogs / Hydrabase / Deezer-
|
||||
primary users got far worse coverage than Download Discography
|
||||
despite both flows asking the same question.
|
||||
"""
|
||||
from core.metadata.multi_source_search import TrackQuery, search_all_sources
|
||||
from core.metadata.registry import get_primary_source
|
||||
|
||||
try:
|
||||
if not track_ids:
|
||||
|
|
@ -262,10 +360,18 @@ def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
|||
track['_album_id'] = album.get('id')
|
||||
track_lookup[tid] = track
|
||||
|
||||
# Resolve every configured metadata source up front — the same
|
||||
# parallel multi-source search the Track Redownload modal uses.
|
||||
# Resolve every configured metadata source up front.
|
||||
search_sources = deps.get_metadata_search_sources()
|
||||
|
||||
# User's configured primary source — direct-lookup tries this
|
||||
# first so Deezer-primary users get Deezer payloads on the
|
||||
# wishlist entry (correct cover art / album shape) even when
|
||||
# other sources also have stored IDs for the same track.
|
||||
try:
|
||||
preferred_source = get_primary_source()
|
||||
except Exception:
|
||||
preferred_source = None
|
||||
|
||||
enhanced_count = 0
|
||||
failed_count = 0
|
||||
failed_tracks = []
|
||||
|
|
@ -293,23 +399,16 @@ def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
|||
matched_track_data = None
|
||||
chosen_source = None
|
||||
|
||||
# 1. Spotify-only direct-lookup optimization. If Spotify is
|
||||
# configured AND we have a stored Spotify ID, fast path
|
||||
# straight to the rich raw payload (no search required).
|
||||
if deps.spotify_client:
|
||||
spotify_tid = track.get('spotify_track_id')
|
||||
if spotify_tid:
|
||||
matched_track_data = _spotify_direct_lookup(
|
||||
deps.spotify_client, spotify_tid,
|
||||
artist_name, album_title, title,
|
||||
)
|
||||
if matched_track_data:
|
||||
chosen_source = 'spotify'
|
||||
# 1. Direct lookup via every stored source ID — like Download
|
||||
# Discography. Stable IDs, no fuzzy text matching.
|
||||
if search_sources:
|
||||
matched_track_data, chosen_source = _try_direct_lookup_all_sources(
|
||||
track, search_sources, preferred_source,
|
||||
title, artist_name, album_title,
|
||||
)
|
||||
|
||||
# 2. Multi-source parallel search across every configured
|
||||
# metadata source. Picks the highest-scoring match across
|
||||
# all sources (Spotify / iTunes / Deezer / Discogs /
|
||||
# Hydrabase — whichever the user has configured).
|
||||
# 2. Multi-source parallel text search fallback — for tracks
|
||||
# with no stored IDs / lookup misses.
|
||||
if not matched_track_data and search_sources:
|
||||
try:
|
||||
track_query = TrackQuery(
|
||||
|
|
@ -325,18 +424,7 @@ def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
|||
chosen_source = multi_result.best_match['source']
|
||||
best_track_obj = multi_result.best_track()
|
||||
if best_track_obj:
|
||||
# Try Spotify's rich-payload upgrade first; fall
|
||||
# back to building from the source-native Track.
|
||||
client = next(
|
||||
(c for n, c in search_sources if n == chosen_source),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
matched_track_data = _try_upgrade_to_rich_payload(
|
||||
client, str(best_track_obj.id),
|
||||
)
|
||||
if not matched_track_data:
|
||||
matched_track_data = _build_payload_from_track(best_track_obj)
|
||||
matched_track_data = _build_payload_from_track(best_track_obj)
|
||||
except Exception as exc:
|
||||
logger.error(f"[Enhance] Multi-source search failed for {title}: {exc}")
|
||||
|
||||
|
|
|
|||
|
|
@ -114,8 +114,14 @@ def _build_deps(
|
|||
search_sources = (
|
||||
[(fallback_source, fallback_client)] if fallback_client else []
|
||||
)
|
||||
# Mirror web_server._resolve_search_sources: Spotify is always
|
||||
# added to the source list when a Spotify client is configured,
|
||||
# alongside whatever else the user has connected. Tests passing
|
||||
# ``spotify=`` get Spotify in the source list automatically so
|
||||
# the direct-lookup fast path can find it.
|
||||
if spotify is not None and not any(name == 'spotify' for name, _ in search_sources):
|
||||
search_sources = [('spotify', spotify)] + list(search_sources)
|
||||
deps = aq.ArtistQualityDeps(
|
||||
spotify_client=spotify,
|
||||
matching_engine=matching_engine or _FakeMatchingEngine(),
|
||||
get_database=lambda: _FakeDatabase(artist_detail=artist_detail),
|
||||
get_wishlist_service=lambda: wishlist or _FakeWishlist(),
|
||||
|
|
@ -126,7 +132,9 @@ def _build_deps(
|
|||
return deps
|
||||
|
||||
|
||||
def _artist_with_track(*, track_id='t1', file_path='/file.mp3', spotify_tid=None):
|
||||
def _artist_with_track(*, track_id='t1', file_path='/file.mp3',
|
||||
spotify_tid=None, deezer_tid=None, itunes_tid=None,
|
||||
soul_id=None):
|
||||
return {
|
||||
'success': True,
|
||||
'artist': {'name': 'Artist Name'},
|
||||
|
|
@ -138,6 +146,9 @@ def _artist_with_track(*, track_id='t1', file_path='/file.mp3', spotify_tid=None
|
|||
'title': 'Track One',
|
||||
'file_path': file_path,
|
||||
'spotify_track_id': spotify_tid,
|
||||
'deezer_id': deezer_tid,
|
||||
'itunes_track_id': itunes_tid,
|
||||
'soul_id': soul_id,
|
||||
'track_number': 1,
|
||||
'duration': 180000,
|
||||
'bitrate': 320,
|
||||
|
|
@ -188,12 +199,17 @@ def test_spotify_direct_lookup_via_track_id_uses_raw_data():
|
|||
|
||||
|
||||
def test_spotify_direct_lookup_enhanced_format_rebuilds_payload():
|
||||
"""Track details without raw_data → rebuild payload with album images via get_album."""
|
||||
enhanced = {'name': 'Track One', 'artists': ['Artist Name'],
|
||||
"""Track details without raw_data → rebuild wishlist-shape payload
|
||||
from the enhanced top-level fields. Spotify enhanced shape returns
|
||||
``artists`` as a list of strings; the converter normalizes to
|
||||
Spotify's wishlist shape (``[{'name': ...}]``). Album images stay
|
||||
empty when the source doesn't surface them on the enhanced dict —
|
||||
the wishlist re-download fetches art at download time."""
|
||||
enhanced = {'id': 'sp-stored', 'name': 'Track One',
|
||||
'artists': ['Artist Name'],
|
||||
'album': {'id': 'alb-id', 'name': 'Album X'},
|
||||
'duration_ms': 180000, 'track_number': 1, 'disc_number': 1}
|
||||
full_album = {'images': [{'url': 'http://art'}]}
|
||||
spotify = _FakeSpotify(track_details=enhanced, album=full_album)
|
||||
spotify = _FakeSpotify(track_details=enhanced)
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=spotify,
|
||||
|
|
@ -205,7 +221,12 @@ def test_spotify_direct_lookup_enhanced_format_rebuilds_payload():
|
|||
|
||||
assert payload['enhanced_count'] == 1
|
||||
md = wishlist.added[0]['spotify_track_data']
|
||||
assert md['album']['images'] == [{'url': 'http://art'}]
|
||||
assert md['id'] == 'sp-stored'
|
||||
assert md['name'] == 'Track One'
|
||||
# Enhanced format: artists normalized from [str] → [{'name': str}].
|
||||
assert md['artists'] == [{'name': 'Artist Name'}]
|
||||
assert md['album']['name'] == 'Album X'
|
||||
assert md['album']['artists'] == [{'name': 'Artist Name'}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -213,11 +234,15 @@ def test_spotify_direct_lookup_enhanced_format_rebuilds_payload():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_spotify_search_fallback_when_no_stored_id():
|
||||
"""No spotify_track_id → search via matching_engine, pick best match."""
|
||||
track = _SpotifyTrack(name='Track One', artists=['Artist Name'])
|
||||
raw = {'id': 'sp-search', 'name': 'Track One', 'artists': [{'name': 'Artist Name'}],
|
||||
'album': {'name': 'Album X'}}
|
||||
spotify = _FakeSpotify(track_details={'raw_data': raw}, search_results=[track])
|
||||
"""No spotify_track_id → multi-source text search runs, builds
|
||||
wishlist payload from the source-native Track object via
|
||||
``_build_payload_from_track`` (not ``get_track_details`` — search
|
||||
results already carry enough Track-shape fields, no extra API call
|
||||
needed)."""
|
||||
track = _SpotifyTrack(id='sp-search', name='Track One',
|
||||
artists=['Artist Name'])
|
||||
track.album = 'Album X'
|
||||
spotify = _FakeSpotify(search_results=[track])
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=spotify,
|
||||
|
|
@ -228,7 +253,11 @@ def test_spotify_search_fallback_when_no_stored_id():
|
|||
payload, _ = aq.enhance_artist_quality('artist-1', ['t1'], deps)
|
||||
|
||||
assert payload['enhanced_count'] == 1
|
||||
assert wishlist.added[0]['spotify_track_data'] == raw
|
||||
md = wishlist.added[0]['spotify_track_data']
|
||||
assert md['id'] == 'sp-search'
|
||||
assert md['name'] == 'Track One'
|
||||
assert md['artists'] == [{'name': 'Artist Name'}]
|
||||
assert md['album']['name'] == 'Album X'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -347,6 +376,204 @@ def test_spotify_direct_lookup_runs_as_fast_path_then_falls_back():
|
|||
assert wishlist.added[0]['spotify_track_data']['id'] == 'dc-1'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct-lookup-by-stored-ID (priority 1) — applies to every source
|
||||
# with a stored ID column, not just Spotify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_direct_lookup_via_deezer_id_skips_text_search():
|
||||
"""Library track has stored deezer_id + Deezer is configured →
|
||||
enhance fast-paths via deezer_client.get_track_details(id) and skips
|
||||
fuzzy text search entirely. Mirrors what Download Discography does
|
||||
(stable IDs, no fuzzy matching). Pre-fix Deezer-primary users went
|
||||
through text search even when the deezer_id was already on the row.
|
||||
"""
|
||||
deezer_calls = []
|
||||
enhanced_dict = {
|
||||
'id': '12345', 'name': 'Track One',
|
||||
'artists': ['Artist Name'],
|
||||
'album': {'id': 'alb-1', 'name': 'Album X'},
|
||||
'duration_ms': 180000, 'track_number': 1, 'disc_number': 1,
|
||||
}
|
||||
|
||||
class _DeezerStub:
|
||||
def get_track_details(self, tid):
|
||||
deezer_calls.append(('details', tid))
|
||||
return enhanced_dict
|
||||
def search_tracks(self, q, limit=10):
|
||||
deezer_calls.append(('search', q))
|
||||
return []
|
||||
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=None,
|
||||
artist_detail=_artist_with_track(deezer_tid='12345'),
|
||||
wishlist=wishlist,
|
||||
fallback_client=_DeezerStub(),
|
||||
fallback_source='deezer',
|
||||
)
|
||||
|
||||
payload, _ = aq.enhance_artist_quality('artist-1', ['t1'], deps)
|
||||
|
||||
assert payload['enhanced_count'] == 1
|
||||
# Direct-lookup ran, text search did NOT (fast path skipped it).
|
||||
assert ('details', '12345') in deezer_calls
|
||||
assert not any(call[0] == 'search' for call in deezer_calls)
|
||||
md = wishlist.added[0]['spotify_track_data']
|
||||
assert md['id'] == '12345'
|
||||
assert md['artists'] == [{'name': 'Artist Name'}]
|
||||
|
||||
|
||||
def test_direct_lookup_via_itunes_id_skips_text_search():
|
||||
"""Stored itunes_track_id triggers iTunes direct lookup. Same
|
||||
contract as Spotify / Deezer — get_track_details called, search
|
||||
not."""
|
||||
itunes_calls = []
|
||||
enhanced_dict = {
|
||||
'id': 'it-9001', 'name': 'Track One',
|
||||
'artists': ['Artist Name'],
|
||||
'album': {'id': 'alb-it', 'name': 'Album X'},
|
||||
'duration_ms': 180000,
|
||||
}
|
||||
|
||||
class _ItunesStub:
|
||||
def get_track_details(self, tid):
|
||||
itunes_calls.append(('details', tid))
|
||||
return enhanced_dict
|
||||
def search_tracks(self, q, limit=10):
|
||||
itunes_calls.append(('search', q))
|
||||
return []
|
||||
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=None,
|
||||
artist_detail=_artist_with_track(itunes_tid='it-9001'),
|
||||
wishlist=wishlist,
|
||||
fallback_client=_ItunesStub(),
|
||||
fallback_source='itunes',
|
||||
)
|
||||
|
||||
payload, _ = aq.enhance_artist_quality('artist-1', ['t1'], deps)
|
||||
|
||||
assert payload['enhanced_count'] == 1
|
||||
assert ('details', 'it-9001') in itunes_calls
|
||||
assert not any(call[0] == 'search' for call in itunes_calls)
|
||||
|
||||
|
||||
def test_direct_lookup_prefers_user_primary_source():
|
||||
"""Track has stored IDs on multiple sources; user's configured
|
||||
primary source is tried first. Pin: a Deezer-primary user with
|
||||
both spotify_track_id and deezer_id stored gets the Deezer payload
|
||||
(correct cover art / album shape for their setup), not whichever
|
||||
source happened to come first in registry order.
|
||||
"""
|
||||
spotify_calls = []
|
||||
deezer_calls = []
|
||||
|
||||
spotify_enhanced = {
|
||||
'id': 'sp-1', 'name': 'Track One',
|
||||
'artists': ['Artist Name'],
|
||||
'album': {'id': 'sp-alb', 'name': 'Album X'},
|
||||
}
|
||||
deezer_enhanced = {
|
||||
'id': 'dz-1', 'name': 'Track One',
|
||||
'artists': ['Artist Name'],
|
||||
'album': {'id': 'dz-alb', 'name': 'Album X'},
|
||||
}
|
||||
|
||||
class _SpotifyStub:
|
||||
def get_track_details(self, tid):
|
||||
spotify_calls.append(tid)
|
||||
return spotify_enhanced
|
||||
|
||||
class _DeezerStub:
|
||||
def get_track_details(self, tid):
|
||||
deezer_calls.append(tid)
|
||||
return deezer_enhanced
|
||||
|
||||
# Patch get_primary_source to return 'deezer' so we don't depend
|
||||
# on the test-runner's actual config.
|
||||
import core.metadata.registry as registry_mod
|
||||
original = registry_mod.get_primary_source
|
||||
registry_mod.get_primary_source = lambda **kw: 'deezer'
|
||||
try:
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=_SpotifyStub(),
|
||||
artist_detail=_artist_with_track(
|
||||
spotify_tid='sp-stored', deezer_tid='dz-stored',
|
||||
),
|
||||
wishlist=wishlist,
|
||||
fallback_client=_DeezerStub(),
|
||||
fallback_source='deezer',
|
||||
)
|
||||
payload, _ = aq.enhance_artist_quality('artist-1', ['t1'], deps)
|
||||
finally:
|
||||
registry_mod.get_primary_source = original
|
||||
|
||||
assert payload['enhanced_count'] == 1
|
||||
# Deezer (primary) tried first and won — Spotify never queried.
|
||||
assert deezer_calls == ['dz-stored']
|
||||
assert spotify_calls == []
|
||||
md = wishlist.added[0]['spotify_track_data']
|
||||
assert md['id'] == 'dz-1'
|
||||
|
||||
|
||||
def test_direct_lookup_falls_through_to_text_search_when_no_stored_ids():
|
||||
"""Track has ZERO stored source IDs → direct lookup yields nothing
|
||||
→ falls through to multi-source text search. Pin the contract:
|
||||
direct-lookup is best-effort, not required."""
|
||||
text_search_track = _SpotifyTrack(id='dz-search', name='Track One',
|
||||
artists=['Artist Name'])
|
||||
text_search_track.album = 'Album X'
|
||||
|
||||
class _DeezerStub:
|
||||
def get_track_details(self, tid):
|
||||
raise AssertionError("should not be called — no stored ID")
|
||||
def search_tracks(self, q, limit=10):
|
||||
return [text_search_track]
|
||||
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=None,
|
||||
artist_detail=_artist_with_track(), # no stored IDs
|
||||
wishlist=wishlist,
|
||||
fallback_client=_DeezerStub(),
|
||||
fallback_source='deezer',
|
||||
)
|
||||
payload, _ = aq.enhance_artist_quality('artist-1', ['t1'], deps)
|
||||
assert payload['enhanced_count'] == 1
|
||||
assert wishlist.added[0]['spotify_track_data']['id'] == 'dz-search'
|
||||
|
||||
|
||||
def test_direct_lookup_failure_falls_through_to_text_search():
|
||||
"""Stored ID exists but direct lookup returns None (network blip,
|
||||
catalog removal, etc.) → flow falls through to text search rather
|
||||
than hard-failing. Pin: direct-lookup miss is non-fatal."""
|
||||
text_search_track = _SpotifyTrack(id='dz-search', name='Track One',
|
||||
artists=['Artist Name'])
|
||||
text_search_track.album = 'Album X'
|
||||
|
||||
class _DeezerStub:
|
||||
def get_track_details(self, tid):
|
||||
return None # direct lookup miss
|
||||
def search_tracks(self, q, limit=10):
|
||||
return [text_search_track]
|
||||
|
||||
wishlist = _FakeWishlist()
|
||||
deps = _build_deps(
|
||||
spotify=None,
|
||||
artist_detail=_artist_with_track(deezer_tid='9999'),
|
||||
wishlist=wishlist,
|
||||
fallback_client=_DeezerStub(),
|
||||
fallback_source='deezer',
|
||||
)
|
||||
payload, _ = aq.enhance_artist_quality('artist-1', ['t1'], deps)
|
||||
assert payload['enhanced_count'] == 1
|
||||
# Text-search winner used.
|
||||
assert wishlist.added[0]['spotify_track_data']['id'] == 'dz-search'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9435,7 +9435,6 @@ def _build_artist_quality_deps():
|
|||
return sources
|
||||
|
||||
return _artists_quality.ArtistQualityDeps(
|
||||
spotify_client=spotify_client,
|
||||
matching_engine=matching_engine,
|
||||
get_database=get_database,
|
||||
get_wishlist_service=_get_ws,
|
||||
|
|
|
|||
|
|
@ -3432,7 +3432,7 @@ const WHATS_NEW = {
|
|||
'2.4.2': [
|
||||
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
|
||||
{ date: 'Unreleased — 2.4.2 dev cycle' },
|
||||
{ title: 'Enhance Quality: Multi-Source Search Like Redownload', desc: 'discord report: enhance quality on an artist with neither spotify nor deezer connected added tracks as "unknown artist - unknown album - unknown track". enhance was running a single-source itunes fallback chain that returned junk matches with empty fields, while track redownload had been doing parallel multi-source search the whole time. extracted that search into `core/metadata/multi_source_search.py` and pointed both flows at it — enhance now searches spotify / itunes / deezer / discogs / hydrabase in parallel, picks the best match across all of them, and rejects empty-field matches before they hit the wishlist. users without spotify get the same coverage as users with it. failure message lists the sources that were searched so it\'s obvious what to connect if nothing matched.', page: 'library' },
|
||||
{ title: 'Enhance Quality: Direct ID Lookup Like Download Discography', desc: 'two related fixes. (1) discord report: enhance quality on an artist with neither spotify nor deezer connected added tracks as "unknown artist - unknown album - unknown track". enhance was running a single-source itunes fallback chain that returned junk matches with empty fields, while track redownload had been doing parallel multi-source search the whole time. extracted that search into `core/metadata/multi_source_search.py` and pointed both flows at it. (2) followup: enhance was still using fuzzy text search even when the library track had a stored source ID (spotify_track_id / deezer_id / itunes_track_id / soul_id) on the row, which meant tracks with messy tags ("Title (Live)", featured artists in the artist field, etc.) failed to match even though a perfect ID was sitting right there. download discography never had this problem because it resolves albums by stable ID, not by name. enhance now does the same: for every source you have configured, if the track has a stored ID for that source, it calls `get_track_details(id)` directly — no fuzzy matching. preferred source (your configured primary) is tried first so a deezer-primary user gets deezer payloads on the wishlist entry. text search is only the fallback now (kicks in for tracks with no stored IDs). also fixed the modal toast that lied "matching tracks to spotify..." regardless of which sources were actually being queried.', page: 'library' },
|
||||
{ title: 'Internal: Media Server Engine Cin/JohnBaumb Pass', desc: 'internal — applied the same architectural cleanups the download engine PR went through to the media server engine PR before review. (1) every server client (Plex / Jellyfin / Navidrome / SoulSync) now explicitly inherits `MediaServerClient` instead of relying on structural typing — drift in any class fails at the conformance test boundary. (2) generic accessors on the engine: `configured_clients()` (replaces per-server `if X and X.is_connected(): clients[name] = X` chains in web_server.py) and `reload_config(name=None)` (generic dispatch instead of per-client reload calls). (3) singleton factory: `get_media_server_engine()` / `set_media_server_engine()` matching the metadata + download engine shape. web_server.py boots via `set_media_server_engine(...)` so factory + global handle share state. (4) ~70 direct `plex_client.X` / `jellyfin_client.X` / `navidrome_client.X` / `soulsync_library_client.X` attribute reaches in web_server.py migrated to `media_server_engine.client(\'<name>\').X`. ~60 standalone refs (truthy checks, media_client assignments, source-name tuples) also routed through the engine. (5) the per-server `plex_client` / `jellyfin_client` / `navidrome_client` / `soulsync_library_client` globals in web_server.py are gone entirely — engine owns the client instances now, every caller reaches via `media_server_engine.client(\'<name>\')`. four multi-client consumers (`PlaylistSyncService`, `ListeningStatsWorker`, `WebScanManager`, discovery `SyncDeps`) refactored to take the engine instead of separate per-server kwargs. (6) `TrackInfo` and `PlaylistInfo` lifted out of `core/plex_client.py` / `jellyfin_client.py` / `navidrome_client.py` (each was defining a near-identical copy) into the neutral `core/media_server/types.py` module — same lift Cin caught on the download `TrackResult`/`AlbumResult`/`DownloadStatus` situation. consumers (matching engine, sync service) get one import. zero behavior change.' },
|
||||
{ title: 'Internal: Media Server Engine Foundation', desc: 'internal — companion to the download engine refactor. introduces a media-server engine + plugin contract on top of the four server clients (plex / jellyfin / navidrome / soulsync standalone). pre-refactor web_server.py held four separate per-server client globals that every dispatch site reached individually. new `core/media_server/` package provides `MediaServerEngine` that owns the per-server clients + a small set of generic accessors (`client(name)`, `active_client()`, `configured_clients()`, `reload_config(name)`) so call sites use one canonical lookup pattern. plugin contract requires the four methods every server actually implements (is_connected, ensure_connection, get_all_artists, get_all_album_ids) — methods that exist on most-but-not-all servers (search_tracks, trigger_library_scan, get_library_stats, etc.) are listed in `KNOWN_PER_SERVER_METHODS` for discoverability and reached directly via `engine.client(name).<method>` since there\'s no uniform safe-default that fits every method. honest scope: the four uniform-shape `is_connected` dispatches were lifted into `engine.is_connected()` (the one cross-server wrapper kept on the engine); the ~18 server-specific chains that do genuinely different per-server work (playlist track replace, metadata sync, scan strategies) stay explicit at the call site per the "lift what\'s truly shared" standard, but reach the per-server client through the engine. 42 tests pin: per-server observable behavior (4 server pinning files, 20 tests), engine surface + accessor contracts (15 tests), structural conformance + explicit-inheritance (9 tests). zero behavior change for users.' },
|
||||
{ title: 'Drop SoundCloud Preview Snippets at Search Time', desc: 'soundcloud serves a ~30s preview clip for tracks that are gated behind go+ / login (very common for major-label uploads — official content basically doesn\'t exist on soundcloud, so what shows up is bootlegs, fan uploads, type beats, and these 30s previews). yt-dlp accepts the preview as the download payload, the post-download integrity check catches the duration mismatch and quarantines the file, but the user just sees "all candidates failed" with no explanation. previews also showed up in the candidate-review modal where clicking one bypassed validation and downloaded the same broken file. now `filter_soundcloud_previews` drops these candidates at every entry point — auto-search scoring, modal-cache fallback, AND the not-found raw-results path — so previews never reach the matcher OR the user. drops candidates < 35s or below half the expected duration, gated on expected being non-trivially long (>60s) so genuine short tracks still pass. also fixed a silent regression in the hybrid-fallback path where the per-source attribute removal left `getattr(orch, \'youtube\', None)` returning None for every source — fallback never fired. now resolves through the orchestrator\'s `client(name)` accessor.', page: 'downloads' },
|
||||
|
|
@ -3776,15 +3776,17 @@ const WHATS_NEW = {
|
|||
// usage_note?: 'optional hint shown at the bottom' }
|
||||
const VERSION_MODAL_SECTIONS = [
|
||||
{
|
||||
title: "Enhance Quality Now Searches Every Source",
|
||||
description: "discord report — clicking enhance quality on an artist with no spotify/deezer was adding tracks as \"unknown artist - unknown album - unknown track\".",
|
||||
title: "Enhance Quality Now Behaves Like Download Discography",
|
||||
description: "discord report — clicking enhance quality on an artist with no spotify/deezer was adding tracks as \"unknown artist - unknown album - unknown track\". root issue ran deeper than that single edge case.",
|
||||
features: [
|
||||
"• enhance was running a single-source itunes fallback chain that returned junk matches with empty fields",
|
||||
"• track redownload had been doing parallel multi-source search the whole time — same question, different answer",
|
||||
"• extracted that search into a shared module; both flows now hit spotify / itunes / deezer / discogs / hydrabase in parallel and pick the cross-source best match",
|
||||
"• empty-field matches rejected before the wishlist add",
|
||||
"• failure message lists the sources that were searched so it\'s obvious what to connect when nothing matched",
|
||||
"• enhance used to fuzzy text search the configured primary source only — a single-source itunes fallback returning junk matches with empty fields, while track redownload had been doing parallel multi-source search the whole time",
|
||||
"• extracted that search into a shared module; both enhance and redownload now hit every configured source in parallel and pick the cross-source best match",
|
||||
"• bigger fix: enhance now uses stored source IDs (spotify_track_id / deezer_id / itunes_track_id / soul_id) the same way download discography uses album IDs — direct lookup against each source's API, no fuzzy text matching, no failures from messy tags like \"Title (Live)\" or featured artists in the artist field",
|
||||
"• preferred source (your configured primary) tried first so a deezer-primary user gets deezer payloads on the wishlist entry",
|
||||
"• text search is only the fallback now — kicks in only when no stored IDs exist for the track",
|
||||
"• modal toast no longer lies \"matching tracks to spotify\" regardless of which sources are actually configured",
|
||||
],
|
||||
usage_note: "no settings to change — applies on next click of enhance quality",
|
||||
},
|
||||
{
|
||||
title: "Watchlist No Longer Re-Downloads Compilations",
|
||||
|
|
|
|||
|
|
@ -7669,7 +7669,7 @@ async function submitEnhanceQuality() {
|
|||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="enhance-spinner"></span>Processing...';
|
||||
}
|
||||
if (footerInfo) footerInfo.textContent = 'Matching tracks to Spotify and adding to wishlist...';
|
||||
if (footerInfo) footerInfo.textContent = 'Matching tracks across metadata sources and adding to wishlist...';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue