soulsync/tests/imports/test_single_to_album.py
BoulderBadgeDad 58363ae510 Library: wire single->album resolution into import detection (gated, fail-safe)
detect_album_info_web gains a last-resort step: when a track matched a SINGLE
with no usable album context, look up the parent ALBUM that contains it (via
get_artist_albums_for_source + get_artist_album_tracks) and promote to it, so it
groups with its album-mates and gets the album's cover instead of the single's.

GATED behind metadata_enhancement.single_to_album (default OFF) — it's a
per-import metadata lookup, so it's opt-in, matching the canonical-version
pattern. Fully fail-safe: flag off, no source, or any client error/miss -> None,
so the track stays exactly as matched (never worse than today). The promoted
album name is forced past get_import_clean_album (which otherwise pins the
single's name) so grouping + tags use the album. 4 glue seam tests added
(promote-when-enabled, disabled-by-default, no-match, client-raises); 462
import-suite tests pass.
2026-06-18 09:30:11 -07:00

206 lines
7.7 KiB
Python

"""Seam tests for single -> parent-album resolution (Sokhi: single-matched track
splits from its album -> mixed cover art). The selector is pure; the resolver
takes injected fetchers, so neither needs a live metadata client.
"""
from __future__ import annotations
import pytest
from core.imports.single_to_album import (
select_parent_album,
resolve_single_to_album,
)
from core.imports.context import detect_album_info_web
# ── pure selector ─────────────────────────────────────────────────────────────
def _alb(name, tracks, album_type="album", **extra):
return {"name": name, "album_type": album_type, "tracks": tracks, **extra}
def test_picks_album_that_contains_the_track():
got = select_parent_album("Yellow", [
_alb("Parachutes", ["Don't Panic", "Shiver", "Yellow", "Trouble"]),
])
assert got and got["name"] == "Parachutes"
def test_returns_none_when_no_album_contains_the_track():
assert select_parent_album("Yellow", [
_alb("Some Other Album", ["Track A", "Track B"]),
]) is None
def test_never_promotes_onto_a_single_release():
# The single's own release (album_type 'single', name == track) must be ignored.
assert select_parent_album("Yellow", [
_alb("Yellow", ["Yellow"], album_type="single"),
]) is None
def test_ignores_ep_and_compilation_types():
assert select_parent_album("Yellow", [
_alb("Yellow EP", ["Yellow", "Yellow (Live)"], album_type="ep"),
_alb("Greatest Hits", ["Yellow", "Clocks"], album_type="compilation"),
]) is None
def test_skips_album_named_exactly_like_the_track():
# An 'album' whose name IS the track title is the single dressed as an album;
# don't treat it as the parent.
assert select_parent_album("Yellow", [
_alb("Yellow", ["Yellow"]),
]) is None
def test_matches_through_album_version_qualifier():
got = select_parent_album("Yellow", [
_alb("Parachutes", ["Shiver", "Yellow (Album Version)"]),
])
assert got and got["name"] == "Parachutes"
def test_first_qualifying_candidate_wins_deterministically():
got = select_parent_album("Yellow", [
_alb("Parachutes", ["Yellow"]),
_alb("Parachutes (Deluxe)", ["Yellow", "Bonus"]),
])
assert got["name"] == "Parachutes" # input order = priority
def test_empty_title_returns_none():
assert select_parent_album("", [_alb("Parachutes", ["Yellow"])]) is None
# ── injected-I/O resolver ─────────────────────────────────────────────────────
def test_resolver_finds_parent_album_lazily():
calls = {"tracks": 0}
albums = [
{"name": "Single Yellow", "album_type": "single", "id": "s1"}, # skipped (not album)
{"name": "Wrong Album", "album_type": "album", "id": "a1"},
{"name": "Parachutes", "album_type": "album", "id": "a2"},
]
def fetch_tracks(alb):
calls["tracks"] += 1
return {"a1": ["Other"], "a2": ["Yellow", "Shiver"]}.get(alb["id"], [])
got = resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: albums,
fetch_album_tracks=fetch_tracks,
)
assert got and got["name"] == "Parachutes" and got["album_id"] == "a2"
assert calls["tracks"] == 2 # probed a1 then a2, stopped; never probed the single
def test_resolver_returns_none_when_nothing_contains_track():
got = resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: [{"name": "X", "album_type": "album", "id": "a1"}],
fetch_album_tracks=lambda alb: ["Nope"],
)
assert got is None
def test_resolver_is_failsafe_on_candidate_fetch_error():
def boom():
raise RuntimeError("api down")
assert resolve_single_to_album(
"Yellow", fetch_album_candidates=boom, fetch_album_tracks=lambda a: []) is None
def test_resolver_is_failsafe_on_track_fetch_error():
def boom(alb):
raise RuntimeError("api down")
got = resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: [{"name": "Parachutes", "album_type": "album", "id": "a1"}],
fetch_album_tracks=boom)
assert got is None
def test_resolver_caps_albums_probed():
albums = [{"name": f"A{i}", "album_type": "album", "id": str(i)} for i in range(20)]
probed = {"n": 0}
def fetch_tracks(alb):
probed["n"] += 1
return ["nope"]
resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: albums,
fetch_album_tracks=fetch_tracks,
max_albums=5)
assert probed["n"] == 5 # never probes more than the cap
# ── gated wiring through detect_album_info_web (config gate + client shapes) ───
class _Cfg:
def __init__(self, on):
self._on = on
def get(self, key, default=None):
if key == "metadata_enhancement.single_to_album":
return self._on
return default
_SINGLE_CTX = {
"source": "spotify",
"artist": {"id": "art1", "name": "Coldplay"},
# album_type unset + name == track + total_tracks 1 -> is_album False, and the
# existing best-effort skips (album name == track), so the glue is reached.
"album": {"id": "s1", "name": "Yellow", "total_tracks": 1},
"track_info": {"id": "t1", "name": "Yellow", "track_number": 7},
"original_search_result": {"title": "Yellow", "album": "Yellow"},
}
def _patch_clients(monkeypatch, albums, tracks_by_id):
monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source",
lambda *a, **k: albums)
monkeypatch.setattr("core.metadata.album_tracks.get_artist_album_tracks",
lambda album_id, **k: {"tracks": tracks_by_id.get(album_id, [])})
def test_glue_promotes_single_to_parent_album_when_enabled(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
_patch_clients(
monkeypatch,
albums=[{"name": "Yellow", "album_type": "single", "id": "s1"},
{"name": "Parachutes", "album_type": "album", "id": "a2"}],
tracks_by_id={"a2": [{"title": "Shiver"}, {"title": "Yellow"}]},
)
out = detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"})
assert out and out["is_album"] is True
assert out["album_name"] == "Parachutes"
assert out["track_number"] == 7 # preserved
def test_glue_disabled_by_default_returns_none(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(False))
# Even with clients that WOULD match, the flag off => no promotion.
_patch_clients(monkeypatch,
albums=[{"name": "Parachutes", "album_type": "album", "id": "a2"}],
tracks_by_id={"a2": [{"title": "Yellow"}]})
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
def test_glue_no_match_returns_none(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
_patch_clients(monkeypatch,
albums=[{"name": "Other Album", "album_type": "album", "id": "a9"}],
tracks_by_id={"a9": [{"title": "Different Song"}]})
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
def test_glue_failsafe_when_client_raises(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
def boom(*a, **k):
raise RuntimeError("spotify down")
monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source", boom)
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None