Self-review pass on the prior three commits — kettui-style cleanup
that should have landed first time.
**Length-preference sort ordering (real bug):**
The `search_tracks_with_artist` stable sort that promoted length-known
recordings ran in `core/musicbrainz_search.py`, but the MB endpoint in
`web_server.py:search_musicbrainz_tracks` runs `rerank_tracks` after
it — which re-sorts by relevance score and dropped the length-pref
ordering down to tiebreaker-only. For canonical-same-song MB duplicates
that all score identically the tiebreaker survived, but the
order-of-operations was wrong.
Moved into `rerank_tracks` itself via a new `prefer_known_duration`
flag. Sort key sits between relevance score and the stable-order
tiebreaker so relevance still wins (length only decides ties, never
overrides a higher-relevance match). The MB endpoint opts in via
`prefer_known_duration=True`; Spotify / iTunes / Deezer callers stay
on the default-off path since their search results always include
length. Pinned with three new `TestRerankTracks` cases:
ties-promote-length, relevance-still-wins, default-off-unchanged.
**Route logic lifted to `core/discovery/manual_match.py`:**
Two pieces lived as inline route logic in `web_server.py` — the
`derive_manual_match_provider` fallback chain (payload.source →
active source → 'spotify') used by `update_youtube_discovery_match`,
and the `is_drifted_for_redo` predicate (cached provider differs from
active AND not manual_match) used by `prepare_mirrored_discovery`.
Per kettui's "extract logic from web_server.py, don't AST-parse it"
standard, both helpers now live in `core/discovery/manual_match.py`
with 12 dedicated unit tests covering fallback resolution order,
non-dict payload defenses, manual_match exemption from drift,
absent-provider legacy default, and edge cases.
Side benefits from the lift:
- `match_source` now derived once before the cache-save try block
instead of being duplicated in try + except (the except block existed
only because the original used `match_source` later — pre-computing
killed the duplication).
- `prepare_mirrored_discovery`'s `has_cached` check now reuses
`is_drifted_for_redo` with inverted polarity instead of restating
the field whitelist inline, so a future schema change only has to
land in one place.
- The mirrored-DB persist block now gates on `matched_data is not None`
to avoid a pre-existing latent NameError if the cache-save block
raised before matched_data construction.
**Enhanced toggle localStorage key now profile-scoped:**
`soulsync-library-view-mode` was global — two admin profiles would
share one preference. Wrapped in `_libraryViewModeKey()` which appends
`:${currentProfile.id}` when a profile is loaded, falls back to the
unsuffixed key otherwise (preserves pre-multi-profile saved values).
Tests:
- 12 new in `tests/discovery/test_manual_match.py` pinning both helpers.
- 3 new in `tests/metadata/test_relevance.py` pinning the
`prefer_known_duration` semantics.
- `test_search_tracks_with_artist_prefers_results_with_known_length`
renamed to `_does_not_resort_by_length` since the sort moved out of
this method. 664 tests pass across discovery + metadata suites.
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
"""Tests for core.discovery.manual_match helpers.
|
|
|
|
These pin the contract for two route-layer decisions lifted out of
|
|
web_server.py so the Fix-popup → mirrored-playlist back-sync flow is
|
|
testable in isolation (per kettui's standing rule that web_server.py
|
|
behavior is reproduced in core/ modules with real unit tests, not by
|
|
AST-parsing the route file).
|
|
"""
|
|
|
|
from core.discovery.manual_match import (
|
|
derive_manual_match_provider,
|
|
is_drifted_for_redo,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_manual_match_provider
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_derive_uses_payload_source_when_present():
|
|
"""Search-endpoint payloads always stamp `source` — that's the
|
|
authoritative provider for a manual match."""
|
|
payload = {'id': 'rec-1', 'source': 'musicbrainz', 'name': 'Track'}
|
|
assert derive_manual_match_provider(payload, 'spotify') == 'musicbrainz'
|
|
|
|
|
|
def test_derive_falls_back_to_active_when_payload_missing_source():
|
|
"""MBID-paste path returns a lean flat shape without `source`. Fall
|
|
back to the user's active discovery source so the cached match
|
|
matches whatever provider next compares against it."""
|
|
payload = {'id': 'mb-mbid', 'name': 'Track'} # no `source`
|
|
assert derive_manual_match_provider(payload, 'musicbrainz') == 'musicbrainz'
|
|
|
|
|
|
def test_derive_falls_back_to_spotify_when_both_missing():
|
|
"""Last-ditch default matches the historic hardcode so behaviour is
|
|
identical when both upstream signals are absent (e.g. broken
|
|
config, missing active source)."""
|
|
assert derive_manual_match_provider({}, None) == 'spotify'
|
|
assert derive_manual_match_provider({}, '') == 'spotify'
|
|
|
|
|
|
def test_derive_handles_non_dict_payload_gracefully():
|
|
"""Defensive — caller passes whatever request.get_json() returned."""
|
|
assert derive_manual_match_provider(None, 'spotify') == 'spotify'
|
|
assert derive_manual_match_provider('not-a-dict', 'musicbrainz') == 'musicbrainz'
|
|
|
|
|
|
def test_derive_payload_source_wins_even_when_active_set():
|
|
"""`source` on payload is authoritative — even if the user's active
|
|
source changed mid-flow, the match came from whatever the popup
|
|
cascade actually queried."""
|
|
payload = {'source': 'itunes'}
|
|
assert derive_manual_match_provider(payload, 'spotify') == 'itunes'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_drifted_for_redo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_drift_redo_when_provider_changed_and_not_manual():
|
|
"""Standard provider-drift case: cached provider differs from
|
|
active, no manual flag → re-discover so active source's IDs /
|
|
artwork take effect."""
|
|
extra = {'discovered': True, 'provider': 'spotify'}
|
|
assert is_drifted_for_redo(extra, 'musicbrainz') is True
|
|
|
|
|
|
def test_drift_no_redo_when_provider_matches():
|
|
"""Same provider → cached entry is fresh, no redo needed."""
|
|
extra = {'discovered': True, 'provider': 'spotify'}
|
|
assert is_drifted_for_redo(extra, 'spotify') is False
|
|
|
|
|
|
def test_drift_no_redo_when_manual_match_even_if_provider_drifted():
|
|
"""The crux of the bug fix: manual matches are exempt from
|
|
provider-drift redo. Re-running would overwrite the user's pick."""
|
|
extra = {'discovered': True, 'provider': 'musicbrainz', 'manual_match': True}
|
|
assert is_drifted_for_redo(extra, 'spotify') is False
|
|
|
|
|
|
def test_drift_no_redo_when_manual_match_with_matching_provider():
|
|
"""Manual + provider match: trivially fresh."""
|
|
extra = {'discovered': True, 'provider': 'spotify', 'manual_match': True}
|
|
assert is_drifted_for_redo(extra, 'spotify') is False
|
|
|
|
|
|
def test_drift_no_redo_when_extra_data_missing():
|
|
"""No cached entry → nothing to drift from."""
|
|
assert is_drifted_for_redo(None, 'spotify') is False
|
|
assert is_drifted_for_redo({}, 'spotify') is False
|
|
|
|
|
|
def test_drift_handles_non_dict_extra_data():
|
|
"""Defensive — extra_data deserialisation can land non-dict shapes."""
|
|
assert is_drifted_for_redo('not-a-dict', 'spotify') is False
|
|
|
|
|
|
def test_drift_default_provider_is_spotify_when_absent():
|
|
"""Historic cached entries may pre-date the provider column being
|
|
populated — treat absent provider as 'spotify' (the legacy default)."""
|
|
extra = {'discovered': True} # no provider field
|
|
assert is_drifted_for_redo(extra, 'spotify') is False
|
|
assert is_drifted_for_redo(extra, 'musicbrainz') is True
|