soulsync/core/metadata/artist_resolution.py
Broque Thomas 0769fcd5cc Fix Soulseek downloads losing collab artist tags
Soulseek matched-download contexts populate `original_search_result`
with `artist` (singular string) and no `artists` list — the full
multi-artist array lives on `track_info` (the matched Spotify track
object). `extract_source_metadata` only read `original_search.artists`,
so the Soulseek path always fell through to the single-artist branch
and TPE1 ended up with the primary artist only. Deezer-direct
downloads were unaffected because their context populates
`original_search.artists` as a proper list.

Lifted artist resolution into a pure helper
`core/metadata/artist_resolution.py:resolve_track_artists` that walks
`original_search.artists` → `track_info.artists` → `artist_dict.name`
fallback chain. Normalizes mixed list-item shapes (Spotify-style
dicts, bare strings, anything else stringified) and drops empty
entries.

13 new tests pin the resolution order, fallback chain, mixed-shape
normalization, whitespace stripping, and empty/none handling. The
existing `_artists_list` no-fall-through test in
`test_multi_artist_tag_settings.py` was updated to reflect the new
contract (always populated; multi-value write still gated on
`len > 1`) plus a new regression test for the Soulseek shape.

Composes with the existing Deezer per-track upgrade (still fires when
single-artist + track_id available) and feat_in_title /
artist_separator settings (still drive the joined ARTIST string
downstream).
2026-05-13 22:11:20 -07:00

74 lines
2.6 KiB
Python

"""Pure artist-list resolution for tag-write paths.
Single source of truth for "what is the canonical multi-value artists
list for this track?" Different download paths populate `context` with
different keys — Deezer-direct downloads stamp `original_search.artists`
as a proper list, but Soulseek matched downloads only carry `artist`
(singular string) in `original_search_result` while the full list lives
on `track_info` (the full Spotify track object).
Resolution order:
1. `context.original_search_result.artists` (preferred — already-
curated by the source path that constructed the context)
2. `context.track_info.artists` (Spotify/Deezer/Tidal full track
object — always carries the artists array when matched)
3. `[artist_dict.name]` as a single-element fallback when neither
carries a list (primary-artist-only)
Each list item may be a dict with a `name` key (Spotify shape), a bare
string, or any other object — the helper normalizes all three to
strings and drops empty entries.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _normalize_artists_iterable(items: Any) -> List[str]:
if not isinstance(items, list):
return []
result: List[str] = []
for item in items:
if isinstance(item, dict):
name = item.get("name")
if isinstance(name, str) and name.strip():
result.append(name.strip())
elif isinstance(item, str):
stripped = item.strip()
if stripped:
result.append(stripped)
elif item is not None:
text = str(item).strip()
if text:
result.append(text)
return result
def resolve_track_artists(
original_search: Optional[Dict[str, Any]],
track_info: Optional[Dict[str, Any]],
artist_dict: Optional[Dict[str, Any]],
) -> List[str]:
"""Return the canonical multi-value artists list for tag-write.
Falls through preferred → track_info → primary-artist fallback. Each
candidate is normalized to a list of stripped non-empty strings.
Empty list returned only when every candidate is empty/invalid.
"""
if isinstance(original_search, dict):
primary = _normalize_artists_iterable(original_search.get("artists"))
if primary:
return primary
if isinstance(track_info, dict):
secondary = _normalize_artists_iterable(track_info.get("artists"))
if secondary:
return secondary
if isinstance(artist_dict, dict):
name = artist_dict.get("name")
if isinstance(name, str) and name.strip():
return [name.strip()]
return []