soulsync/tests/search/test_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

248 lines
8.8 KiB
Python

"""Tests for core/search/sources.py — per-source-kind + multi-kind executor."""
from __future__ import annotations
from core.search import sources
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _Artist:
def __init__(self, id_, name, image_url=None, external_urls=None):
self.id = id_
self.name = name
self.image_url = image_url
self.external_urls = external_urls
class _Album:
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
total_tracks=10, album_type='album', external_urls=None, format=None,
country=None, status=None, label=None, disambiguation=None,
release_group_id=None):
self.id = id_
self.name = name
self.artists = artists or []
self.image_url = image_url
self.release_date = release_date
self.total_tracks = total_tracks
self.album_type = album_type
self.external_urls = external_urls
self.format = format
self.country = country
self.status = status
self.label = label
self.disambiguation = disambiguation
self.release_group_id = release_group_id
class _Track:
def __init__(self, id_, name, artists=None, album=None, duration_ms=180000,
image_url=None, release_date=None, external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.album = album
self.duration_ms = duration_ms
self.image_url = image_url
self.release_date = release_date
self.external_urls = external_urls
class _Client:
def __init__(self, artists=None, albums=None, tracks=None, fail=None):
self._artists = artists or []
self._albums = albums or []
self._tracks = tracks or []
self._fail = fail or set()
def search_artists(self, q, limit=10):
if 'artists' in self._fail:
raise RuntimeError("artists boom")
return self._artists
def search_albums(self, q, limit=10):
if 'albums' in self._fail:
raise RuntimeError("albums boom")
return self._albums
def search_tracks(self, q, limit=10):
if 'tracks' in self._fail:
raise RuntimeError("tracks boom")
return self._tracks
# ---------------------------------------------------------------------------
# search_kind
# ---------------------------------------------------------------------------
def test_search_kind_artists_returns_normalized_dicts():
client = _Client(artists=[_Artist('id1', 'Pink Floyd', 'thumb.jpg', {'spotify': 'url'})])
result = sources.search_kind(client, 'pink', 'artists', 'spotify')
assert result == [{
'id': 'id1',
'name': 'Pink Floyd',
'source': 'spotify',
'image_url': 'thumb.jpg',
'external_urls': {'spotify': 'url'},
}]
def test_search_kind_artists_handles_none_external_urls():
client = _Client(artists=[_Artist('id1', 'X', None, None)])
result = sources.search_kind(client, 'x', 'artists')
assert result[0]['external_urls'] == {}
def test_search_kind_albums_joins_multiple_artists():
client = _Client(albums=[_Album('a1', 'DSOTM', artists=['Pink Floyd', 'Roger'])])
result = sources.search_kind(client, 'd', 'albums')
assert result[0]['artist'] == 'Pink Floyd, Roger'
def test_search_kind_albums_handles_no_artists():
client = _Client(albums=[_Album('a1', 'Mystery', artists=[])])
result = sources.search_kind(client, 'm', 'albums')
assert result[0]['artist'] == 'Unknown Artist'
def test_search_kind_albums_passthrough_release_metadata():
client = _Client(albums=[_Album(
'a1',
'Variant',
artists=['Artist'],
format='CD',
country='US',
status='Official',
label='Fixture Records',
disambiguation='clean',
release_group_id='rg-1',
)])
result = sources.search_kind(client, 'v', 'albums')
assert result[0]['format'] == 'CD'
assert result[0]['country'] == 'US'
assert result[0]['status'] == 'Official'
assert result[0]['label'] == 'Fixture Records'
assert result[0]['disambiguation'] == 'clean'
assert result[0]['release_group_id'] == 'rg-1'
def test_search_kind_tracks_returns_full_shape():
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
duration_ms=383000, image_url='m.jpg',
release_date='1973-03-01', external_urls={'a': 'b'})])
result = sources.search_kind(client, 'money', 'tracks')
assert result == [{
'id': 't1',
'name': 'Money',
'artist': 'Pink Floyd',
'artists': ['Pink Floyd'],
'source': '',
'album': 'DSOTM',
'duration_ms': 383000,
'image_url': 'm.jpg',
'release_date': '1973-03-01',
'external_urls': {'a': 'b'},
}]
def test_search_kind_swallows_artist_errors():
client = _Client(fail={'artists'})
assert sources.search_kind(client, 'q', 'artists') == []
def test_search_kind_swallows_album_errors():
client = _Client(fail={'albums'})
assert sources.search_kind(client, 'q', 'albums') == []
def test_search_kind_swallows_track_errors():
client = _Client(fail={'tracks'})
assert sources.search_kind(client, 'q', 'tracks') == []
def test_search_kind_unknown_kind_raises():
import pytest
with pytest.raises(ValueError):
sources.search_kind(_Client(), 'q', 'movies')
# ---------------------------------------------------------------------------
# search_source — multi-kind executor
# ---------------------------------------------------------------------------
def test_search_source_returns_all_three_kinds():
client = _Client(
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B', artists=['A'])],
tracks=[_Track('c', 'C', artists=['A'], album='B')],
)
result = sources.search_source('q', client, 'spotify')
assert result['available'] is True
assert len(result['artists']) == 1
assert len(result['albums']) == 1
assert len(result['tracks']) == 1
def test_search_source_partial_failure_does_not_break_others():
client = _Client(
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B')],
tracks=[_Track('c', 'C')],
fail={'albums'},
)
result = sources.search_source('q', client, 'spotify')
assert result['available'] is True
assert result['artists'] != []
assert result['albums'] == []
assert result['tracks'] != []
def test_search_source_all_fail_returns_empty_lists():
client = _Client(fail={'artists', 'albums', 'tracks'})
result = sources.search_source('q', client, 'spotify')
assert result == {'artists': [], 'albums': [], 'tracks': [], 'available': True}
# ── source field on serialized results (Netti93 multi-artist fix) ───────────
# Search results must carry which metadata source they came from: the payload
# travels Download Now → download task → import context, where the Deezer
# contributors upgrade (multi-artist tags) is gated on source == 'deezer'.
# Without it get_import_source() resolved '' and collab tracks were tagged
# with only the primary artist until a Retag.
def _full_client():
return _Client(
artists=[_Artist('id1', 'A')],
albums=[_Album('a1', 'Album', artists=['A'])],
tracks=[_Track('t1', 'T', artists=['A'], album='Album')],
)
def test_serialized_tracks_carry_source():
out = sources.search_kind(_full_client(), "q", "tracks", source_name="deezer")
assert out and all(t["source"] == "deezer" for t in out)
def test_serialized_albums_and_artists_carry_source():
albums = sources.search_kind(_full_client(), "q", "albums", source_name="deezer")
artists = sources.search_kind(_full_client(), "q", "artists", source_name="deezer")
assert albums and all(a["source"] == "deezer" for a in albums)
assert artists and all(a["source"] == "deezer" for a in artists)
def test_serialized_source_empty_when_unnamed():
# hydrabase path calls without a source_name — emit '' not the class name.
out = sources.search_kind(_full_client(), "q", "tracks")
assert out and all(t["source"] == "" for t in out)
def test_serialized_tracks_carry_real_artists_list():
"""Collabs must survive as a LIST — the joined "A, B" display string made
downloads tag a single combined artist (Marcus's report)."""
client = _Client(tracks=[_Track('t1', 'Collab', artists=['Artist A', 'Artist B'], album='X')])
out = sources.search_kind(client, 'q', 'tracks', source_name='spotify')
assert out[0]['artists'] == ['Artist A', 'Artist B']
assert out[0]['artist'] == 'Artist A, Artist B' # display string unchanged