soulsync/SPEC_canonical_album_version.md
BoulderBadgeDad e40b328a94 docs: canonical-album-version design spec
The staged design doc for this branch (#765 + #767-Bug2): the
match-your-files canonical rule, the additive/dormant rollout, and the
stage-by-stage plan the 6 implementation commits followed. Kept on the branch
as its reference; not relevant to dev/main.
2026-06-02 12:41:36 -07:00

6.7 KiB

Spec: Canonical Album Version (fixes #765 + #767-Bug2)

Status: design only — no code yet. Goal: Pin ONE canonical (source, album_id) per album, chosen by best-fit to the user's actual files, so the Library Reorganizer, Track Number Repair, and tagging/enrichment all agree on the same release. Today each re-resolves independently and they contradict each other (Spotify Believer=4 vs MusicBrainz Believer=3; standard album mislinked to a deluxe release).

Canonical-selection rule (decided): match the user's actual files. The canonical release is the candidate whose track count + per-track durations + titles best fit what's on disk. Self-correcting: picks standard when you own the standard, deluxe when you own the deluxe.


Hard requirement: don't disrupt the running app

Every stage below is additive and dormant until explicitly consumed, and every consumer falls back to today's behavior when no canonical is set. So:

  • albums with no resolved canonical behave EXACTLY as they do now;
  • each stage is independently shippable and reversible;
  • nothing big-bangs.

Stage 1 — Schema + pure scorer (ships dormant, zero behavior change)

Schema (additive, nullable → migration-safe)

Add to albums (guarded ALTER TABLE ... ADD COLUMN, idempotent — mirror the existing column-exists checks; see db-schema-review migration-safety notes):

  • canonical_source TEXT — e.g. 'spotify' / 'itunes' / 'musicbrainz'
  • canonical_album_id TEXT
  • canonical_score REAL — best-fit score (for transparency / re-resolve gating)
  • canonical_resolved_at TIMESTAMP

All nullable. Existing rows = NULL → "unresolved" → consumers fall back. No backfill in this stage. No reads in this stage.

Pure core helper (the testable heart) — core/metadata/canonical_version.py

score_release_against_files(file_tracks, release_tracks) -> float
pick_canonical_release(file_tracks, candidates) -> (best, score) | (None, 0)
  • file_tracks: list of {duration_ms, title, track_number?} read from disk.
  • release_tracks: a candidate release's tracklist (same shape).
  • Scoring (tunable weights):
    • track-count fit — exact match strongly preferred; |Δcount| penalized.
    • duration alignment — greedily match each file to its closest release track by duration (within a tolerance, e.g. ±3s); reward coverage.
    • title overlap — token/fuzzy overlap as a tiebreaker.
    • graceful degradation — if a source gives no per-track durations, fall back to count + title only (never crash, never force-pick).
  • Returns the best candidate + score, or (None, 0) when nothing clears a floor (so we never pin a bad guess — leave it unresolved, consumers fall back).

Tests (extreme, like the rest of this codebase)

  • standard (11) vs deluxe (17) with 11 files on disk → picks standard.
  • same album, 17 files → picks deluxe.
  • duration disambiguation when track counts tie (e.g. radio edit vs album).
  • missing-duration source → count+title fallback still picks sanely.
  • no candidate clears the floor → (None, 0).
  • "Believer" standard(=track 3 listing) vs Spotify(=4) with the user's files → whichever the files actually match.

End of Stage 1: scorer exists + tested, columns exist, NOTHING reads/writes them yet. Provably zero behavior change.


Stage 2 — Resolver populates canonical (writes, still no consumers)

A function resolve_canonical_for_album(album_id, db, ...):

  1. Gather on-disk file metadata for the album (durations/titles) via the library's known file paths.
  2. Gather candidate releases: every source the album has an ID for (spotify/itunes/deezer/discogs/soul/musicbrainz) AND — for the deluxe/standard case — sibling editions discoverable from those. Fetch each tracklist (cached, rate-limited).
  3. pick_canonical_release(files, candidates) → store (source, album_id, score) on the album row if it clears the floor.

Wiring: a small backfill repair job (dry-run-capable) + a hook in enrichment when an album is (re)enriched. Still no tool READS canonical, so behavior is unchanged — this stage only populates the new columns. Reversible: clearing the columns reverts to unresolved.

Tests: resolver picks the right release for the standard/deluxe fixtures; stores nothing when below floor; idempotent re-resolve.

Cost note: fetching multiple candidate releases = more API calls. Mitigate via cache + only-on-(re)enrich + the existing rate trackers. Surface in the job's progress so it's not silent.


Stage 3 — Reorganizer reads canonical (first real behavior change, gated)

In library_reorganize._resolve_source: if the album has canonical_source/canonical_album_id, use THAT first; else fall back to the current get_source_priority walk. One-line precedence change, fully gated on non-NULL.

Tests: with canonical set → resolves to it; with canonical NULL → byte-identical to today. Re-run the existing reorganize battery (148 tests) — must stay green.

This alone fixes #767-Bug2 (a standard album whose files match the standard release pins the standard, so reorganize stops targeting the deluxe folder).


Stage 4 — Track Number Repair reads canonical (closes #765)

In track_number_repair._resolve_album_tracklist: add Fallback -1 (before everything) — if the album has a canonical (source, album_id), use it. The existing 6-level cascade stays as the fallback for albums with no canonical (preserves its all-01-album rescue ability — the regression risk we refused to take in the reactive fix).

Now both tools resolve the SAME release → same track numbers → no contradiction.

Tests: canonical present → both tools agree (shared-release test); canonical NULL → existing cascade unchanged.


Risks & mitigations

  • Extra API calls (Stage 2 fetches multiple releases) → cache, rate-limit, only-on-(re)enrich, progress-logged.
  • Sources without per-track durations → scorer degrades to count+title.
  • Schema migration → additive nullable columns only; idempotent guards.
  • Wrong pick → floor gate (never pin a low-confidence guess); canonical_score stored for inspection/re-resolve; manual override possible later.
  • Backward-compat → every consumer falls back to today's path when NULL, so un-resolved albums (incl. all existing albums until backfilled) are unaffected.

Out of scope (for now)

  • Per-album manual version override UI (can layer on later — the columns support it).
  • Merging the two tools into one (the reporter's alt suggestion) — unnecessary once they share the canonical.

Suggested order to build

1, then 2, then 3, then 4 — each shippable and verifiable on its own. We can stop after any stage and the app is consistent (just with fewer consumers wired).