Completes Stage 2's populate path. Still dormant — no consumer calls it yet.
- resolve_and_store_canonical_for_album(db, album_id, ...): loads the album's
source IDs + its tracks' (duration_ms, title) from the DB via the SAME
loader the Reorganizer uses (load_album_and_tracks + _extract_source_ids), so
the canonical is chosen over exactly the source IDs the reorganizer sees;
scores off the DB track rows (the library's view of the files — no per-file
disk reads), resolves the best fit, and persists it. Returns the stored result
or None when unresolved.
- default_fetch_tracklist(): production fetcher wrapping
get_album_tracks_for_source, normalising to {title, track_number, duration_ms}
(duration best-effort; sec->ms; absent -> scorer leans on count+title).
Design note: chose LAZY resolution (Stages 3-4 consumers call this when they hit
an album with no canonical) over a standalone backfill repair job — no new
scheduling/UI surface, resolves only when a tool actually needs it, and stays
gated (NULL canonical = today's behavior).
Tests: tests/test_canonical_orchestration.py (5) — end-to-end on a real temp DB
(11 files pick the 11-track release over a 17-track deluxe and persist it),
no-source-ids -> None, missing-album -> None, and default_fetch_tracklist
normalization (dict items, seconds->ms) + failure -> None. All canonical +
DB-migration tests green.
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""Resolve (and persist) the canonical release for an album — Stage 2 of #765.
|
|
|
|
Stage 1 gave us the pure scorer (``core.metadata.canonical_version``). This
|
|
module turns it into an end-to-end resolver: gather the album's candidate
|
|
releases (one per metadata-source ID it has), score each against the on-disk
|
|
files, and return the best fit. Wiring (backfill job / enrichment hook) and the
|
|
DB store live alongside; the decision logic here is kept dependency-injected
|
|
(``fetch_tracklist`` is passed in) so it's fully unit-testable without live APIs
|
|
or real files.
|
|
|
|
Still NO consumer reads the result in Stage 2 — populating the columns is
|
|
behavior-neutral. Stages 3-4 wire the Reorganizer and Track Number Repair to
|
|
read it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from core.metadata.canonical_version import pick_canonical_release
|
|
|
|
|
|
def resolve_canonical_for_album(
|
|
*,
|
|
album_source_ids: Dict[str, str],
|
|
file_tracks: List[Dict[str, Any]],
|
|
fetch_tracklist: Callable[[str, str], Optional[List[Dict[str, Any]]]],
|
|
source_priority: List[str],
|
|
min_score: float = 0.5,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Pick the canonical release for one album.
|
|
|
|
``album_source_ids``: ``{source: album_id}`` the album is linked to.
|
|
``file_tracks``: on-disk track metadata (``{duration_ms, title}``).
|
|
``fetch_tracklist(source, album_id)``: returns that release's tracklist (or
|
|
None/[] on miss); injected so callers supply ``get_album_tracks_for_source``
|
|
while tests supply a fake.
|
|
``source_priority``: order to build candidates in — ties break toward the
|
|
earlier (higher-priority) source, keeping the choice deterministic.
|
|
|
|
Returns ``{'source', 'album_id', 'score'}`` for the best fit, or ``None``
|
|
when there are no files, no resolvable candidates, or nothing clears
|
|
``min_score`` (caller leaves the album unresolved → tools fall back)."""
|
|
if not file_tracks:
|
|
return None
|
|
|
|
candidates: List[Dict[str, Any]] = []
|
|
for source in source_priority:
|
|
album_id = album_source_ids.get(source)
|
|
if not album_id:
|
|
continue
|
|
try:
|
|
tracks = fetch_tracklist(source, str(album_id))
|
|
except Exception:
|
|
tracks = None
|
|
if tracks:
|
|
candidates.append({
|
|
'source': source,
|
|
'album_id': str(album_id),
|
|
'tracks': tracks,
|
|
})
|
|
|
|
if not candidates:
|
|
return None
|
|
|
|
best, score = pick_canonical_release(file_tracks, candidates, min_score=min_score)
|
|
if not best:
|
|
return None
|
|
return {
|
|
'source': best['source'],
|
|
'album_id': best['album_id'],
|
|
'score': round(score, 4),
|
|
}
|
|
|
|
|
|
def default_fetch_tracklist(source: str, album_id: str) -> Optional[List[Dict[str, Any]]]:
|
|
"""Production ``fetch_tracklist``: pull a release's tracklist from a metadata
|
|
source and normalise to ``{title, track_number, duration_ms}``. Duration is
|
|
best-effort (not every source exposes it); when absent the scorer just leans
|
|
on track-count + title. Returns None on any failure."""
|
|
try:
|
|
from core.metadata_service import get_album_tracks_for_source
|
|
data = get_album_tracks_for_source(source, album_id)
|
|
except Exception:
|
|
return None
|
|
items = data if isinstance(data, list) else (
|
|
(data.get('items') or data.get('tracks') or []) if isinstance(data, dict) else []
|
|
)
|
|
if isinstance(items, dict): # {'tracks': {'items': [...]}}
|
|
items = items.get('items') or []
|
|
out: List[Dict[str, Any]] = []
|
|
for it in items:
|
|
get = it.get if isinstance(it, dict) else (lambda k, d=None: getattr(it, k, d))
|
|
dur = get('duration_ms')
|
|
if dur is None:
|
|
secs = get('duration') # some sources give seconds
|
|
dur = int(secs * 1000) if isinstance(secs, (int, float)) and secs else None
|
|
out.append({
|
|
'title': get('name') or get('title') or '',
|
|
'track_number': get('track_number'),
|
|
'duration_ms': dur,
|
|
})
|
|
return out or None
|
|
|
|
|
|
def resolve_and_store_canonical_for_album(
|
|
db,
|
|
album_id,
|
|
*,
|
|
fetch_tracklist: Optional[Callable[[str, str], Any]] = None,
|
|
source_priority: Optional[List[str]] = None,
|
|
min_score: float = 0.5,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Gather an album's source IDs + its tracks' (duration, title) from the DB,
|
|
resolve the best-fit canonical release, and persist it. Returns the stored
|
|
``{source, album_id, score}`` or None when unresolved.
|
|
|
|
Uses the SAME album/source-id loader the Reorganizer uses
|
|
(``load_album_and_tracks`` + ``_extract_source_ids``) so the canonical is
|
|
chosen over exactly the source IDs the reorganizer sees. Scores off the DB
|
|
track rows' ``duration`` (stored in ms) + ``title`` — the library's view of
|
|
the files — so no per-file disk reads are needed."""
|
|
from core.library_reorganize import _extract_source_ids, load_album_and_tracks
|
|
|
|
album_data, tracks = load_album_and_tracks(db, album_id)
|
|
if not album_data or not tracks:
|
|
return None
|
|
source_ids = {s: v for s, v in _extract_source_ids(album_data).items() if v}
|
|
if not source_ids:
|
|
return None
|
|
|
|
file_tracks = [
|
|
{'duration_ms': t.get('duration') or 0, 'title': t.get('title') or ''}
|
|
for t in tracks
|
|
]
|
|
|
|
if fetch_tracklist is None:
|
|
fetch_tracklist = default_fetch_tracklist
|
|
if source_priority is None:
|
|
try:
|
|
from core.metadata_service import get_primary_source, get_source_priority
|
|
source_priority = get_source_priority(get_primary_source())
|
|
except Exception:
|
|
source_priority = list(source_ids.keys())
|
|
|
|
result = resolve_canonical_for_album(
|
|
album_source_ids=source_ids,
|
|
file_tracks=file_tracks,
|
|
fetch_tracklist=fetch_tracklist,
|
|
source_priority=source_priority,
|
|
min_score=min_score,
|
|
)
|
|
if result:
|
|
db.set_album_canonical(album_id, result['source'], result['album_id'], result['score'])
|
|
return result
|
|
|
|
|
|
__all__ = [
|
|
"resolve_canonical_for_album",
|
|
"resolve_and_store_canonical_for_album",
|
|
"default_fetch_tracklist",
|
|
]
|