soulsync/core/metadata/canonical_resolver.py
BoulderBadgeDad f9271c0cd8 Canonical album version — backfill job (the opt-in activation)
The populate trigger that turns the (until now dormant) feature on. Until a user
enables and runs this job, no album has a canonical -> both read sides (Stages
3-4) fall back -> zero behavior change. So the whole feature ships safely off.

- core/repair_jobs/canonical_version_resolve.py — "Resolve Canonical Album
  Versions". Iterates the active server's albums, skips ones already pinned, and
  calls the tested resolve_and_store_canonical_for_album per album. Opt-in
  (default_enabled=False) and dry-run-by-default: resolving compares an album's
  candidate releases across sources (metadata-source API calls, once per album),
  so it's deliberately user-triggered. Dry run reports a finding per album it
  would pin; live mode stores. Registered in _JOB_MODULES.
- core/metadata/canonical_resolver.py — resolve_and_store gains store=True; the
  job's dry run passes store=False to resolve-without-writing.

Tests: tests/test_canonical_version_job.py (6) — registered, opt-in + dry-run
defaults, live resolves+stores (auto_fixed), dry run creates findings without
persisting, already-pinned albums skipped. Registry loads all 19 jobs cleanly.
145 tests across the full feature + reorganize/track-repair/DB regression pass.
2026-06-02 11:53:45 -07:00

164 lines
6.3 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,
store: bool = True,
) -> Optional[Dict[str, Any]]:
"""Gather an album's source IDs + its tracks' (duration, title) from the DB,
resolve the best-fit canonical release, and (when ``store``) persist it.
Returns the resolved ``{source, album_id, score}`` or None when unresolved.
``store=False`` resolves without writing — used by the backfill job's dry run.
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 and store:
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",
]