A single-use, user-designated 'which release does this track belong to' answer. Written when the user picks a release in the Re-identify modal and the file is staged; the import flow will read it at the top of matching and consume it. - rematch_hints table (additive, IF NOT EXISTS + indexes) keyed on staged_path with content_hash as a rename-proof fallback. - core/imports/rematch_hints.py: pure DB seam over an injected cursor (create/find/consume/list) + a cheap size+head+tail file fingerprint. - exempt_dedup baked into the hint (a re-identify must bypass dedup-skip); replace_track_id carried for deferred post-success cleanup. Inert until wired (Phase 5) — nothing calls it yet. 9 seam tests.
155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
"""#889 Phase 1: the re-identify hint store — create / find / consume.
|
|
|
|
The hint is the single-use, user-designated "which release" answer the import
|
|
flow reads at the top of matching. These lock down: a hint round-trips, it's
|
|
found by staged path, found by content_hash when the path missed (rename-proof),
|
|
found by basename when the dir changed, consumed exactly once, and that a
|
|
consumed hint is never handed back.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from core.imports.rematch_hints import (
|
|
RematchHint,
|
|
consume_hint,
|
|
create_hint,
|
|
find_hint_for_file,
|
|
list_pending_hints,
|
|
quick_file_signature,
|
|
)
|
|
|
|
# The slice of the real schema this module touches (kept in sync with
|
|
# database/music_database.py's rematch_hints CREATE).
|
|
_SCHEMA = """
|
|
CREATE TABLE rematch_hints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
staged_path TEXT NOT NULL,
|
|
content_hash TEXT,
|
|
source TEXT NOT NULL,
|
|
isrc TEXT,
|
|
track_id TEXT,
|
|
album_id TEXT,
|
|
artist_id TEXT,
|
|
track_title TEXT,
|
|
album_name TEXT,
|
|
artist_name TEXT,
|
|
album_type TEXT,
|
|
track_number INTEGER,
|
|
disc_number INTEGER,
|
|
replace_track_id INTEGER,
|
|
exempt_dedup INTEGER NOT NULL DEFAULT 1,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
consumed_at TIMESTAMP
|
|
)
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def cur():
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript(_SCHEMA)
|
|
yield conn.cursor()
|
|
conn.close()
|
|
|
|
|
|
def _hint(**kw):
|
|
base = dict(
|
|
staged_path="/staging/Song.flac",
|
|
source="spotify",
|
|
isrc="USABC1234567",
|
|
track_id="trk_1",
|
|
album_id="alb_album1",
|
|
artist_id="art_1",
|
|
track_title="Song",
|
|
album_name="Album1",
|
|
artist_name="Artist",
|
|
album_type="album",
|
|
track_number=5,
|
|
disc_number=1,
|
|
replace_track_id=42,
|
|
)
|
|
base.update(kw)
|
|
return RematchHint(**base)
|
|
|
|
|
|
def test_create_and_find_by_path_roundtrips(cur):
|
|
new_id = create_hint(cur, _hint())
|
|
assert new_id > 0
|
|
got = find_hint_for_file(cur, "/staging/Song.flac")
|
|
assert got is not None
|
|
assert got.id == new_id
|
|
assert got.album_id == "alb_album1" and got.album_type == "album"
|
|
assert got.isrc == "USABC1234567"
|
|
assert got.track_number == 5 and got.disc_number == 1
|
|
assert got.replace_track_id == 42
|
|
assert got.exempt_dedup is True # always set for a user-designated re-identify
|
|
assert got.status == "pending"
|
|
|
|
|
|
def test_find_by_content_hash_when_path_missed(cur):
|
|
create_hint(cur, _hint(content_hash="deadbeef"))
|
|
# Watcher renamed/moved the file → path lookup misses, hash rescues it.
|
|
assert find_hint_for_file(cur, "/totally/different.flac") is None
|
|
got = find_hint_for_file(cur, "/totally/different.flac", content_hash="deadbeef")
|
|
assert got is not None and got.album_name == "Album1"
|
|
|
|
|
|
def test_find_by_basename_when_dir_changed(cur):
|
|
create_hint(cur, _hint(staged_path="/staging/in/Song.flac"))
|
|
# Same filename, different directory (watcher moved it deeper).
|
|
got = find_hint_for_file(cur, "/staging/processing/Song.flac")
|
|
assert got is not None and got.track_id == "trk_1"
|
|
|
|
|
|
def test_consume_is_single_use(cur):
|
|
new_id = create_hint(cur, _hint())
|
|
assert find_hint_for_file(cur, "/staging/Song.flac") is not None
|
|
consume_hint(cur, new_id)
|
|
# Consumed → never handed back, by path or by hash.
|
|
assert find_hint_for_file(cur, "/staging/Song.flac") is None
|
|
assert find_hint_for_file(cur, "/x", content_hash=None) is None
|
|
|
|
|
|
def test_list_pending_excludes_consumed(cur):
|
|
a = create_hint(cur, _hint(staged_path="/staging/A.flac"))
|
|
create_hint(cur, _hint(staged_path="/staging/B.flac"))
|
|
assert len(list_pending_hints(cur)) == 2
|
|
consume_hint(cur, a)
|
|
pend = list_pending_hints(cur)
|
|
assert len(pend) == 1 and pend[0].staged_path == "/staging/B.flac"
|
|
|
|
|
|
def test_newest_pending_wins_on_duplicate_path(cur):
|
|
create_hint(cur, _hint(album_id="alb_old"))
|
|
create_hint(cur, _hint(album_id="alb_new")) # user re-picked for the same file
|
|
got = find_hint_for_file(cur, "/staging/Song.flac")
|
|
assert got.album_id == "alb_new"
|
|
|
|
|
|
def test_exempt_dedup_false_roundtrips(cur):
|
|
create_hint(cur, _hint(staged_path="/staging/Keep.flac", exempt_dedup=False))
|
|
got = find_hint_for_file(cur, "/staging/Keep.flac")
|
|
assert got.exempt_dedup is False
|
|
|
|
|
|
# ── content fingerprint ───────────────────────────────────────────────────────
|
|
def test_quick_file_signature_stable_and_distinct(tmp_path):
|
|
a = tmp_path / "a.bin"
|
|
b = tmp_path / "b.bin"
|
|
a.write_bytes(b"hello world" * 1000)
|
|
b.write_bytes(b"goodbye moon" * 1000)
|
|
sig_a1 = quick_file_signature(str(a))
|
|
sig_a2 = quick_file_signature(str(a))
|
|
sig_b = quick_file_signature(str(b))
|
|
assert sig_a1 and sig_a1 == sig_a2 # stable
|
|
assert sig_a1 != sig_b # distinct content → distinct sig
|
|
|
|
|
|
def test_quick_file_signature_missing_file_is_none():
|
|
assert quick_file_signature("/no/such/file.flac") is None
|