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.
70 lines
2.7 KiB
Python
70 lines
2.7 KiB
Python
"""Helpers for Fix-popup manual match persistence.
|
|
|
|
When the user manually fixes a mirrored-playlist discovery via the Fix
|
|
popup, two questions land at the web_server route layer that are easier
|
|
to test in isolation:
|
|
|
|
1. *Which metadata source did the manual match come from?* — the popup
|
|
cascade queries the user's primary source first, then Spotify /
|
|
Deezer / iTunes / MusicBrainz as fallbacks; each search endpoint
|
|
stamps `source` on its rows but the MBID-paste lookup uses a lean
|
|
flat shape that doesn't carry it. `derive_manual_match_provider`
|
|
collapses the fallback chain into a single string.
|
|
|
|
2. *Should the discovery layer re-run for this track when the current
|
|
active provider differs from the cached one?* — re-running silently
|
|
overwrites the user's deliberate pick with whatever the auto-search
|
|
ranks first, so manual matches are exempt regardless of provider
|
|
drift. `is_drifted_for_redo` encapsulates the decision.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
def derive_manual_match_provider(
|
|
payload_track: Dict[str, Any],
|
|
active_provider: Optional[str],
|
|
) -> str:
|
|
"""Return the provider string to stamp on a manually-fixed match.
|
|
|
|
Resolution order:
|
|
1. ``payload_track['source']`` — every *_search_tracks endpoint
|
|
sets this; the MBID-paste path doesn't.
|
|
2. ``active_provider`` — what the user has configured as their
|
|
primary discovery source.
|
|
3. ``'spotify'`` — last-ditch default matching the historic
|
|
hardcode (so behaviour is identical when both upstream
|
|
signals are absent).
|
|
"""
|
|
if not isinstance(payload_track, dict):
|
|
payload_track = {}
|
|
source = payload_track.get('source')
|
|
if source:
|
|
return source
|
|
if active_provider:
|
|
return active_provider
|
|
return 'spotify'
|
|
|
|
|
|
def is_drifted_for_redo(
|
|
extra_data: Optional[Dict[str, Any]],
|
|
active_provider: Optional[str],
|
|
) -> bool:
|
|
"""Return True when a cached discovery entry should be treated as
|
|
stale because the user's active provider has changed since it was
|
|
cached AND the entry isn't a manual match.
|
|
|
|
Manual matches are *always* considered fresh: re-running discovery
|
|
against the current source would overwrite the user's deliberate
|
|
pick with whatever auto-search ranks first. The first Playlist
|
|
Pipeline run after a manual fix used to clobber it for exactly
|
|
this reason — the check lives here now so it's pinned by tests.
|
|
"""
|
|
if not isinstance(extra_data, dict):
|
|
return False
|
|
if extra_data.get('manual_match'):
|
|
return False
|
|
cached_provider = extra_data.get('provider', 'spotify')
|
|
return cached_provider != active_provider
|