#889 Phase 3: re-identify search — multi-source track→release lookup + API

Search any configured source (tabs, default active) and surface the SAME song
across its collections (single/EP/album) so the user can pick which release a
track should be filed under.

- core/imports/rematch_search.py: pure normalize + injected client factory.
  search_release_candidates() → lightweight display rows from typed search_tracks
  (title/artist/release/type badge/year/count/art/isrc/track_id); resolve_hint_fields()
  runs ONCE on the picked row via get_track_details to pull the album_id (+ isrc/
  track#/disc) the hint needs. infer_release_type() handles Spotify's missing 'EP'
  (multi-track 'single' → EP badge); filing is driven by real album_id, not the label.
- GET /api/reidentify/sources (tabs) + GET /api/reidentify/search (rows). Graceful
  empty on dead source / blank query / client error — never raises.

14 tests. Inert until the modal (Phase 4) calls it.
This commit is contained in:
BoulderBadgeDad 2026-06-18 15:31:33 -07:00
parent 08fb21fb13
commit 3e554f8274
3 changed files with 409 additions and 0 deletions

View file

@ -0,0 +1,247 @@
"""#889 Phase 3: search a metadata source for the releases a track appears on.
The Re-identify modal lets the user search ANY configured source (tabs, defaulting
to the active one) and shows the SAME song across its different collections
single / EP / album so they can pick which release the track should be filed
under. Two steps, deliberately split:
* ``search_release_candidates(source, query)`` lightweight DISPLAY rows from the
normal typed ``search_tracks`` (title, artist, release name, type badge, year,
track count, art, ISRC, track_id). No album_id needed to draw the list.
* ``resolve_hint_fields(source, track_id)`` runs ONCE, on the row the user
picks: ``get_track_details`` yields the album_id / isrc / track#/disc the hint
needs. We don't pay that lookup for every search result, only the chosen one.
Pure normalization + injected client factory, so the search/normalize/resolve seam
is unit-tested with a fake client and no network.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
def _get(obj: Any, key: str, default=None):
"""Read ``key`` from either an object (attr) or a mapping (item)."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def _year(release_date: Any) -> Optional[str]:
s = str(release_date or "").strip()
return s[:4] if len(s) >= 4 and s[:4].isdigit() else None
def infer_release_type(album_type: Any, total_tracks: Any) -> str:
"""Normalize a source's release type to one of album / ep / single / compilation.
Sources disagree: Spotify has no 'EP' EPs come back as ``album_type='single'``
with several tracks; MusicBrainz/Deezer label EPs properly. So when a 'single'
carries more than a handful of tracks, call it an EP for the badge. The actual
filing is unaffected that's driven by the real album_id, not this label."""
t = str(album_type or "").strip().lower()
try:
n = int(total_tracks) if total_tracks is not None else 0
except (TypeError, ValueError):
n = 0
if t in ("compilation", "comp"):
return "compilation"
if t == "ep":
return "ep"
if t == "album":
return "album" # an explicit album stays an album; only 'single' gets promoted to EP
if t == "single":
# 13 tracks → single; 4+ → almost always an EP in practice.
return "ep" if n >= 4 else "single"
# Unknown type: infer purely from track count.
if n >= 7:
return "album"
if n >= 4:
return "ep"
if n >= 1:
return "single"
return t or "album"
def normalize_search_result(result: Any, source: str) -> Optional[Dict[str, Any]]:
"""One typed search Track (or raw dict) → a display row, or ``None`` if it has
no usable id/title. ``album`` is just a name at search time; album_id is
resolved later for the picked row only."""
track_id = _get(result, "id") or _get(result, "track_id")
title = _get(result, "name") or _get(result, "title")
if not track_id or not title:
return None
artists = _get(result, "artists")
if isinstance(artists, list):
artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists)
else:
artist_name = str(artists or _get(result, "artist") or "")
album = _get(result, "album")
album_name = album if isinstance(album, str) else (_get(album, "name") or "")
raw_type = _get(result, "album_type")
total = _get(result, "total_tracks")
ext = _get(result, "external_ids") or {}
isrc = _get(result, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None)
return {
"source": source,
"track_id": str(track_id),
"track_title": str(title),
"artist_name": artist_name,
"album_name": str(album_name or ""),
"album_type": infer_release_type(raw_type, total),
"raw_album_type": str(raw_type or ""),
"total_tracks": int(total) if isinstance(total, int) else None,
"year": _year(_get(result, "release_date")),
"image_url": _get(result, "image_url") or "",
"isrc": isrc or None,
}
def search_release_candidates(
source: str,
query: str,
*,
limit: int = 25,
client_factory: Optional[Callable[[str], Any]] = None,
) -> List[Dict[str, Any]]:
"""Search ``source`` for tracks matching ``query`` → normalized display rows.
Returns ``[]`` (never raises) when the source has no client or errors the UI
just shows an empty tab. Rows keep duplicate releases; the UI groups them."""
query = (query or "").strip()
if not query:
return []
factory = client_factory or _default_client_factory
try:
client = factory(source)
except Exception:
client = None
if client is None or not hasattr(client, "search_tracks"):
return []
try:
results = client.search_tracks(query, limit=limit)
except TypeError:
results = client.search_tracks(query) # clients with no limit kwarg
except Exception:
return []
rows: List[Dict[str, Any]] = []
for r in results or []:
row = normalize_search_result(r, source)
if row is not None:
rows.append(row)
return rows
def resolve_hint_fields(
source: str,
track_id: str,
*,
client_factory: Optional[Callable[[str], Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Resolve the picked track to the fields a hint needs (album_id critically,
plus isrc / track# / disc# / album name+type). One lookup for one chosen row.
Returns ``None`` if it can't be resolved (caller surfaces an error)."""
factory = client_factory or _default_client_factory
try:
client = factory(source)
except Exception:
client = None
if client is None or not hasattr(client, "get_track_details"):
return None
try:
details = client.get_track_details(track_id)
except Exception:
return None
if not details:
return None
album = _get(details, "album") or {}
album_id = _get(album, "id") if not isinstance(album, str) else None
album_name = _get(album, "name") if not isinstance(album, str) else album
album_type = _get(album, "album_type") or _get(details, "album_type")
total = _get(album, "total_tracks") or _get(details, "total_tracks")
artists = _get(details, "artists") or []
artist_id = None
artist_name = ""
if isinstance(artists, list) and artists:
artist_id = _get(artists[0], "id")
artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists)
ext = _get(details, "external_ids") or {}
isrc = _get(details, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None)
if not album_id:
return None # without an album_id the import can't fetch the tracklist
return {
"source": source,
"track_id": str(track_id),
"album_id": str(album_id),
"artist_id": str(artist_id) if artist_id else None,
"track_title": _get(details, "name") or _get(details, "title") or "",
"album_name": str(album_name or ""),
"artist_name": artist_name,
"album_type": infer_release_type(album_type, total),
"track_number": _get(details, "track_number"),
"disc_number": _get(details, "disc_number") or 1,
"isrc": isrc or None,
}
def _default_client_factory(source: str):
from core.metadata.registry import get_client_for_source
return get_client_for_source(source)
def available_sources() -> List[Dict[str, Any]]:
"""The source tabs for the modal: every metadata source with a live client,
the primary one flagged ``active`` so the UI selects it by default."""
from core.metadata.registry import (
METADATA_SOURCE_PRIORITY,
get_client_for_source,
get_primary_source,
)
try:
primary = get_primary_source()
except Exception:
primary = None
out: List[Dict[str, Any]] = []
seen = set()
for src in METADATA_SOURCE_PRIORITY:
if src in seen:
continue
seen.add(src)
try:
client = get_client_for_source(src)
except Exception:
client = None
if client is None or not hasattr(client, "search_tracks"):
continue
out.append({
"source": src,
"label": src.replace("_", " ").title(),
"active": src == primary,
})
# Guarantee the primary is selectable + first even if priority ordering missed it.
if primary and not any(s["active"] for s in out):
out.insert(0, {"source": primary, "label": primary.replace("_", " ").title(), "active": True})
return out
__all__ = [
"infer_release_type",
"normalize_search_result",
"search_release_candidates",
"resolve_hint_fields",
"available_sources",
]

View file

@ -0,0 +1,121 @@
"""#889 Phase 3: re-identify search — normalize results across sources, infer the
release-type badge, and resolve the picked row's album_id.
Locks down: same song surfaces as multiple rows (single/EP/album), the EP
inference from a multi-track 'single', graceful empty on a dead source, and that
resolve_hint_fields pulls album_id (and refuses a result without one).
"""
from __future__ import annotations
import types
from core.imports.rematch_search import (
available_sources,
infer_release_type,
normalize_search_result,
resolve_hint_fields,
search_release_candidates,
)
# typed-Track-ish object (mirrors core.metadata.types.Track attrs the modal reads)
def _track(tid, title, album, album_type, total, isrc=None, year="2020"):
return types.SimpleNamespace(
id=tid, name=title, artists=["Artist"], album=album,
album_type=album_type, total_tracks=total, release_date=year + "-01-01",
image_url="http://img/" + tid, isrc=isrc, external_ids={},
)
# ── release-type inference ────────────────────────────────────────────────────
def test_infer_album_stays_album():
assert infer_release_type("album", 12) == "album"
def test_infer_single_one_track_is_single():
assert infer_release_type("single", 1) == "single"
def test_infer_multitrack_single_promoted_to_ep():
# Spotify labels EPs as album_type='single' — promote on track count.
assert infer_release_type("single", 5) == "ep"
def test_infer_compilation():
assert infer_release_type("compilation", 40) == "compilation"
def test_infer_unknown_falls_back_to_count():
assert infer_release_type(None, 10) == "album"
assert infer_release_type("", 4) == "ep"
assert infer_release_type(None, 1) == "single"
# ── normalization ─────────────────────────────────────────────────────────────
def test_normalize_builds_display_row():
row = normalize_search_result(_track("t1", "Song", "Album1", "album", 12, isrc="US1234567890"), "spotify")
assert row["track_id"] == "t1"
assert row["album_name"] == "Album1" and row["album_type"] == "album"
assert row["artist_name"] == "Artist"
assert row["year"] == "2020" and row["isrc"] == "US1234567890"
def test_normalize_skips_result_without_id_or_title():
assert normalize_search_result(types.SimpleNamespace(id="", name="X"), "spotify") is None
assert normalize_search_result(types.SimpleNamespace(id="t", name=""), "spotify") is None
def test_same_song_multiple_collections():
"""The headline case: one song, three releases, three distinct rows + badges."""
results = [
_track("t_alb", "Song", "Album1", "album", 12),
_track("t_ep", "Song", "EP1", "single", 5), # multi-track single → EP
_track("t_sgl", "Song", "Song (Single)", "single", 1),
]
client = types.SimpleNamespace(search_tracks=lambda q, limit=25: results)
rows = search_release_candidates("spotify", "Song", client_factory=lambda s: client)
badges = {r["album_name"]: r["album_type"] for r in rows}
assert badges == {"Album1": "album", "EP1": "ep", "Song (Single)": "single"}
def test_search_empty_on_missing_client():
assert search_release_candidates("spotify", "x", client_factory=lambda s: None) == []
def test_search_empty_on_blank_query():
called = []
search_release_candidates("spotify", " ", client_factory=lambda s: called.append(1))
assert called == [] # never even fetches a client for an empty query
def test_search_swallows_client_error():
def boom(q, limit=25):
raise RuntimeError("rate limited")
client = types.SimpleNamespace(search_tracks=boom)
assert search_release_candidates("spotify", "x", client_factory=lambda s: client) == []
# ── resolve on select ─────────────────────────────────────────────────────────
def test_resolve_pulls_album_id_and_fields():
details = {
"name": "Song", "track_number": 5, "disc_number": 1, "isrc": "US1234567890",
"album": {"id": "alb_album1", "name": "Album1", "album_type": "album", "total_tracks": 12},
"artists": [{"id": "art_1", "name": "Artist"}],
}
client = types.SimpleNamespace(get_track_details=lambda tid: details)
out = resolve_hint_fields("spotify", "t_alb", client_factory=lambda s: client)
assert out["album_id"] == "alb_album1"
assert out["artist_id"] == "art_1"
assert out["track_number"] == 5 and out["disc_number"] == 1
assert out["album_type"] == "album" and out["isrc"] == "US1234567890"
def test_resolve_refuses_result_without_album_id():
details = {"name": "Song", "album": {"name": "NoId Album"}} # no album id
client = types.SimpleNamespace(get_track_details=lambda tid: details)
assert resolve_hint_fields("spotify", "t", client_factory=lambda s: client) is None
def test_resolve_none_on_missing_client():
assert resolve_hint_fields("spotify", "t", client_factory=lambda s: None) is None

View file

@ -10044,6 +10044,47 @@ def get_artist_enhanced_detail(artist_id):
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ── Re-identify an imported track (#889) ──
@app.route('/api/reidentify/sources', methods=['GET'])
def reidentify_sources():
"""Source tabs for the Re-identify modal — every metadata source with a live
client, the active one flagged so the UI selects it by default."""
try:
from core.imports.rematch_search import available_sources
return jsonify({"success": True, "sources": available_sources()})
except Exception as e:
return jsonify({"success": False, "error": str(e), "sources": []}), 500
@app.route('/api/reidentify/search', methods=['GET'])
def reidentify_search():
"""Search one metadata source for the releases a track appears on.
Query params: ``source`` (defaults to the active source), ``q`` (the query),
``limit``. Returns display rows the SAME song across single/EP/album, each
with a type badge for the user to pick. album_id is resolved later, only for
the chosen row."""
try:
from core.imports.rematch_search import available_sources, search_release_candidates
query = (request.args.get('q') or '').strip()
if not query:
return jsonify({"success": True, "results": []})
source = (request.args.get('source') or '').strip()
if not source:
actives = [s for s in available_sources() if s.get('active')]
source = actives[0]['source'] if actives else 'spotify'
try:
limit = max(1, min(50, int(request.args.get('limit', 25))))
except (TypeError, ValueError):
limit = 25
rows = search_release_candidates(source, query, limit=limit)
return jsonify({"success": True, "source": source, "results": rows})
except Exception as e:
logger.error(f"Re-identify search error: {e}")
return jsonify({"success": False, "error": str(e), "results": []}), 500
@app.route('/api/library/artist/<artist_id>/quality-analysis')
def get_artist_quality_analysis(artist_id):
"""Analyze track quality for an artist — returns tier classification for each track."""