Feedback from the live dry-run: the job was pinning whichever source best fit
the files regardless of which source it was, which was surprising — users
expect it to respect their active metadata source. Made it a per-job setting
instead of a baked-in policy.
source_selection (default 'active_preferred'):
- active_preferred — use the active/primary metadata source's release when the
album has an ID for it AND it clears the score floor; otherwise fall back to
the best-fit among the other sources. Respects the configured source but
self-heals when that link is clearly broken (below floor / no ID).
- active_only — only ever the active source; never considers others.
- best_fit — previous behavior: whichever source matches the files best.
resolve_canonical_for_album gains mode + primary_source; the orchestration
threads the primary source through; the job reads source_selection from its
settings. Note: active_preferred respects the active source as long as it clears
the floor, so it will NOT override a deluxe-vs-standard mismatch on the primary
(#767-Bug2) — that's what best_fit is for; the choice is now the user's.
Tests: per-mode coverage in test_canonical_resolver.py (active_preferred uses
primary when it fits, falls back when primary is below floor, keeps primary even
when another fits better; active_only pins primary / never falls back; best_fit
unchanged), orchestration default-mode test, and the setting default. 39
canonical tests pass.
Turns the Stage-1 scorer into an end-to-end resolver + persists the result.
Still DORMANT — no consumer reads it yet, so zero behavior change.
- core/metadata/canonical_resolver.py — resolve_canonical_for_album(): builds
candidate releases from the album's per-source IDs (in source-priority order),
fetches each tracklist via an INJECTED fetch_tracklist (so it's unit-testable
without live APIs), scores them with pick_canonical_release, and returns the
best-fit {source, album_id, score}. Skips sources with no id / failed fetch;
returns None when there are no files, no candidates, or nothing clears the
confidence floor.
- database/music_database.py — set_album_canonical() / get_album_canonical()
write/read the Stage-1 columns. get returns None when unresolved, which every
consumer will treat as "fall back to today's behavior".
Tests: tests/test_canonical_resolver.py (7) — best-fit beats priority, priority
breaks true ties, skips missing-id/failed-fetch sources, None on
no-candidates/no-files/below-floor, score rounding. tests/test_canonical_db.py
(4) — set/get round-trip incl. timestamp, unresolved -> None, overwrite,
missing-album -> False. 34 canonical + DB-migration tests pass.
Remaining for Stage 2 (the trigger): read on-disk file durations/titles for an
album, gather its source IDs, call the resolver, store — wired via a backfill
repair job + an enrichment hook. Then Stages 3-4 wire the Reorganizer and Track
Number Repair to READ the pinned canonical.