First stage of the canonical-album-version fix (#765 + #767-Bug2). Pins ONE canonical (source, album_id) per album, chosen by best-fit to the user's actual files, so the Reorganizer, Track Number Repair, and tagging stop re-resolving independently and contradicting each other. Ships DORMANT — nothing reads or writes the new data yet, so zero behavior change. Later stages populate (Stage 2) and consume (Stages 3-4) it. - core/metadata/canonical_version.py — pure scorer (the testable heart): score_release_against_files() rates a candidate release by track-count fit + duration alignment (greedy nearest within ±3s) + title overlap, dropping and renormalizing missing signals so it never crashes on sparse metadata. pick_canonical_release() takes candidates in source-priority order, picks the best fit, breaks ties toward the earlier (higher-priority) candidate so the choice is DETERMINISTIC — that determinism is what makes every tool agree (#765), while count/duration fit picks the right EDITION (#767-Bug2). A confidence floor (default 0.5) means a low-confidence guess is never pinned. - database/music_database.py — additive, nullable columns on albums (canonical_source / canonical_album_id / canonical_score / canonical_resolved_at), guarded by the existing PRAGMA-table_info pattern. NULL = unresolved = every consumer falls back to today's behavior. Tests: tests/test_canonical_version.py (11) — edition discrimination (11 files -> standard, 17 -> deluxe), deterministic priority tiebreak, duration disambiguation on count ties, graceful degradation (no durations / counts only / fuzzy titles), confidence floor, empty-input safety. tests/test_canonical_ columns_migration.py (4) — fresh DB has the columns, they're nullable w/ NULL default, migration is idempotent, and it ALTERs them onto an old albums table. 60 DB/schema regression tests still pass.
69 lines
2.5 KiB
Python
69 lines
2.5 KiB
Python
"""Migration test for the canonical-album-version columns (#765 Stage 1).
|
|
|
|
Additive + nullable, so it must: appear on a fresh DB, be idempotent (re-running
|
|
the migration is a no-op, not an error), and ALTER them onto an older albums
|
|
table that lacks them. NULL = unresolved = tools fall back to today's behavior.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
from database.music_database import MusicDatabase
|
|
|
|
_CANONICAL_COLS = {
|
|
'canonical_source', 'canonical_album_id', 'canonical_score', 'canonical_resolved_at',
|
|
}
|
|
|
|
|
|
def _album_cols(cur):
|
|
cur.execute("PRAGMA table_info(albums)")
|
|
return {c[1] for c in cur.fetchall()}
|
|
|
|
|
|
def test_fresh_db_has_canonical_columns(tmp_path):
|
|
db = MusicDatabase(str(tmp_path / "m.db"))
|
|
cur = db._get_connection().cursor()
|
|
assert _CANONICAL_COLS <= _album_cols(cur)
|
|
|
|
|
|
def test_canonical_columns_default_null(tmp_path):
|
|
# Unresolved by default -> every consumer falls back. Verify each canonical
|
|
# column declares DEFAULT NULL and is nullable (notnull flag == 0).
|
|
db = MusicDatabase(str(tmp_path / "m.db"))
|
|
cur = db._get_connection().cursor()
|
|
cur.execute("PRAGMA table_info(albums)")
|
|
info = {c[1]: c for c in cur.fetchall()} # name -> (cid, name, type, notnull, dflt, pk)
|
|
for col in _CANONICAL_COLS:
|
|
assert col in info, f"{col} missing"
|
|
assert info[col][3] == 0, f"{col} must be nullable"
|
|
dflt = info[col][4]
|
|
assert dflt is None or str(dflt).upper() == 'NULL', f"{col} default should be NULL"
|
|
|
|
|
|
def test_migration_is_idempotent(tmp_path):
|
|
db = MusicDatabase(str(tmp_path / "m.db"))
|
|
cur = db._get_connection().cursor()
|
|
before = _album_cols(cur)
|
|
# Re-running must not raise (the PRAGMA guard skips existing columns).
|
|
db._ensure_core_media_schema_columns(cur)
|
|
db._ensure_core_media_schema_columns(cur)
|
|
assert _album_cols(cur) == before
|
|
assert _CANONICAL_COLS <= _album_cols(cur)
|
|
|
|
|
|
def test_migration_adds_columns_to_old_albums_table(tmp_path):
|
|
# Simulate an upgraded DB whose albums table predates these columns.
|
|
path = str(tmp_path / "old.db")
|
|
conn = sqlite3.connect(path)
|
|
conn.execute("CREATE TABLE albums (id INTEGER PRIMARY KEY, title TEXT)")
|
|
conn.commit()
|
|
cur = conn.cursor()
|
|
assert not (_CANONICAL_COLS & _album_cols(cur)) # none present yet
|
|
|
|
# Run the real migration against this old cursor.
|
|
db = MusicDatabase(str(tmp_path / "scratch.db"))
|
|
db._ensure_core_media_schema_columns(cur)
|
|
conn.commit()
|
|
|
|
assert _CANONICAL_COLS <= _album_cols(cur)
|