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.
206 lines
7.7 KiB
Python
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
|