soulsync/core/search/sources.py
BoulderBadgeDad bcd69c8baa Multi-artist tags: Search → Download Now finally knows its metadata source (Netti93)
Third round of the multi-artist report. The earlier fixes (Deezer contributors
upgrade, _artists_list, feat_in_title/artist_separator) were all in place and
correct — but gated on source == 'deezer', and on the real Search → Download
Now path NOTHING carried the source: core/search/sources.py serialized tracks
with no source field, search.js's enrichedTrack didn't add one, so
get_import_source() resolved '' and the whole Deezer-specific block silently
skipped. Files were tagged with only the primary artist until a Retag (which
rebuilds context with the source set — exactly why retagging always fixed it).
The earlier tests passed because they set context['source'] directly — the one
field the real flow never had (same mock-drift as the #823 append tests).

Reproduced with Netti93's exact track (deezer 3966840171) through the real
extract_source_metadata: before — source '', artists ['August Burns Red'];
after — source 'deezer', contributors fetched, artists ['August Burns Red',
'Polaris'], title 'Sonic Salvation (feat. Polaris)' per feat_in_title.

Fix, three layers:
- core/search/sources.py: serialized tracks/albums/artists carry "source"
  (the canonical name the orchestrator already passes; '' when unnamed).
- core/imports/context.py get_import_source: also reads '_source' from the
  nested dicts (track_info/original_search/album/artist) — additionally fixes
  the discography/wishlist flows, which always passed '_source' that nothing
  read.
- search.js: enrichedTrack + the album-download path carry source through to
  the download task.

Tests: real-payload staging-shaped contexts (source in track_info, '_source'
shape, and the pre-fix sourceless shape staying safe — mocked Deezer client),
serializer source-field tests, resolver fallback tests; exact-shape serializer
tests updated for the new key. 1977 import/metadata/search tests pass (the
only 2 failures are the known soundcloud ones).
2026-06-09 17:20:16 -07:00

136 lines
5.9 KiB
Python

"""Per-source metadata search.
Two public functions:
- `search_kind(client, query, kind, source_name=None)` — search a single
result type (artists | albums | tracks) on one client and normalize the
result to a list of plain dicts.
- `search_source(query, client, source_name=None)` — fan three
search_kind calls out across a thread pool and return the merged dict.
Both swallow per-kind exceptions — search reliability matters more than
strict error propagation, and the route layer cannot do anything useful
with a single-kind failure.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Optional
logger = logging.getLogger(__name__)
def search_kind(client, query: str, kind: str, source_name: Optional[str] = None) -> list:
"""Search one result type from a metadata source and normalize it."""
source_label = source_name or type(client).__name__
if kind == "artists":
artists = []
try:
artist_objs = client.search_artists(query, limit=10)
for artist in artist_objs:
artists.append({
"id": artist.id,
"name": artist.name,
"source": source_name or "",
"image_url": artist.image_url,
"external_urls": artist.external_urls or {},
})
except Exception as e:
logger.debug(f"Artist search failed for {source_label}: {e}")
return artists
if kind == "albums":
albums = []
try:
album_objs = client.search_albums(query, limit=10)
for album in album_objs:
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"source": source_name or "",
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type,
"format": getattr(album, "format", None),
"country": getattr(album, "country", None),
"status": getattr(album, "status", None),
"label": getattr(album, "label", None),
"disambiguation": getattr(album, "disambiguation", None),
"release_group_id": getattr(album, "release_group_id", None),
"external_urls": album.external_urls or {},
})
except Exception as e:
logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True)
return albums
if kind == "tracks":
tracks = []
try:
track_objs = client.search_tracks(query, limit=10)
for track in track_objs:
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
# The REAL artist list, not the joined display string above.
# Spotify/Tidal/iTunes searches return collabs as a list;
# collapsing them to one "A, B" string made the import
# pipeline tag downloads with a single combined artist
# (resolve_track_artists saw one value). The frontend keeps
# using "artist" for display.
"artists": list(track.artists or []),
# Which metadata source this result came from. Travels with
# the payload through Download Now -> download task ->
# import context, where extract_source_metadata needs it to
# run source-specific logic (the Deezer contributors
# upgrade for multi-artist tags — Netti93's report: without
# it get_import_source() resolved '' and collab tracks
# were tagged with only the primary artist until a Retag).
"source": source_name or "",
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date,
"external_urls": track.external_urls or {},
})
except Exception as e:
logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True)
return tracks
raise ValueError(f"Unknown metadata search kind: {kind}")
def search_source(query: str, client, source_name: Optional[str] = None) -> dict:
"""Run all three search-kinds against a single client in parallel."""
results: dict[str, Any] = {"artists": [], "albums": [], "tracks": []}
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(search_kind, client, query, "artists", source_name): "artists",
executor.submit(search_kind, client, query, "albums", source_name): "albums",
executor.submit(search_kind, client, query, "tracks", source_name): "tracks",
}
for future in as_completed(futures):
kind = futures[future]
try:
results[kind] = future.result()
except Exception as e:
logger.warning(
f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}",
exc_info=True,
)
results[kind] = []
return {
"artists": results["artists"],
"albums": results["albums"],
"tracks": results["tracks"],
"available": True,
}