soulsync/tests/imports/test_album_grouping.py
BoulderBadgeDad b216233658 Library: group imported albums by canonical release id, not just the name string
Sokhi: songs in one album get mismatched cover art. Root cause is upstream of
the repair jobs (which correctly apply one cover per album_id): the standalone
import grouped albums by the album NAME hash (artist::album_name), so the SAME
release split into multiple album rows whenever the name string drifted, and the
cover-art/re-tag jobs then dressed each split row in its own art.

Foundation (new imports only; existing rows untouched): a pure, seam-testable
helper find_existing_soulsync_album_id() resolves the album row by precedence
name-hash id -> source RELEASE id -> (title, artist). When an import carries a
metadata-source album id, a differently-named import of the SAME release now
unifies into one row instead of splitting. Source-column lookup is allow-listed
(it's spliced into SQL) and guarded so a source without a dedicated album column
(Deezer) falls through to the name match instead of breaking the import.

Deliberate scope: this does NOT merge a track that genuinely matched a SINGLE
(a different release id) into its parent album — that needs single->album
resolution upstream and is the next step; this is the grouping substrate it will
feed. 10 seam tests (canonical unify, single-vs-album stays separate, precedence,
allowlist, server-source scope, missing-column fallthrough).
2026-06-18 09:16:00 -07:00

138 lines
5.6 KiB
Python

"""Seam tests for canonical album grouping (Sokhi: split album rows -> mixed
cover art). Drives find_existing_soulsync_album_id against a real in-memory
SQLite albums table — no app singletons, no I/O.
"""
from __future__ import annotations
import sqlite3
import pytest
from core.imports.album_grouping import (
find_existing_soulsync_album_id,
ALLOWED_ALBUM_SOURCE_COLS,
)
@pytest.fixture()
def cur():
conn = sqlite3.connect(":memory:")
conn.execute(
"""CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
server_source TEXT,
spotify_album_id TEXT,
itunes_album_id TEXT,
deezer_id TEXT,
soul_id TEXT,
discogs_id TEXT,
musicbrainz_release_id TEXT
)"""
)
yield conn.cursor()
conn.close()
def _add(cur, *, id, title, artist_id="art1", server_source="soulsync", **source_ids):
cols = ["id", "artist_id", "title", "server_source"] + list(source_ids)
vals = [id, artist_id, title, server_source] + list(source_ids.values())
cur.execute(
f"INSERT INTO albums ({', '.join(cols)}) VALUES ({', '.join(['?'] * len(cols))})",
vals,
)
def test_empty_db_returns_none(cur):
assert find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="Parachutes",
album_source_col="spotify_album_id", album_source_id="SP1") is None
def test_exact_name_hash_id_wins_first(cur):
_add(cur, id="nk", title="Parachutes")
assert find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="Parachutes") == "nk"
def test_canonical_source_id_unifies_differently_named_imports(cur):
# Existing row for release SP1 named "Parachutes". A second import of the
# SAME release id but a drifted name must JOIN it, not split.
_add(cur, id="existing", title="Parachutes", spotify_album_id="SP1")
got = find_existing_soulsync_album_id(
cur, name_key_id="different_hash", artist_id="art1",
album_name="Parachutes (Deluxe Edition)",
album_source_col="spotify_album_id", album_source_id="SP1")
assert got == "existing"
def test_different_release_id_stays_separate(cur):
# The single-vs-album case: a genuinely different release id must NOT merge
# (documents the known limit — single->album resolution is a separate step).
_add(cur, id="album_row", title="Parachutes", spotify_album_id="SP_ALBUM")
got = find_existing_soulsync_album_id(
cur, name_key_id="single_hash", artist_id="art1", album_name="Yellow",
album_source_col="spotify_album_id", album_source_id="SP_SINGLE")
assert got is None
def test_legacy_name_match_still_groups_without_a_source_id(cur):
_add(cur, id="byname", title="Parachutes")
got = find_existing_soulsync_album_id(
cur, name_key_id="other_hash", artist_id="art1", album_name="parachutes",
album_source_col=None, album_source_id=None)
assert got == "byname" # case-insensitive title + artist
def test_source_id_match_is_scoped_to_soulsync_rows(cur):
_add(cur, id="plexrow", title="Parachutes", server_source="plex", spotify_album_id="SP1")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="X",
album_source_col="spotify_album_id", album_source_id="SP1")
assert got is None # the matching row belongs to Plex, not soulsync
def test_non_allowlisted_column_is_ignored(cur):
# A column not on the allowlist must never be spliced into SQL.
assert "title" not in ALLOWED_ALBUM_SOURCE_COLS
_add(cur, id="row", title="Parachutes")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="nope",
album_source_col="title", album_source_id="Parachutes")
assert got is None # 'title' ignored as a source col; name 'nope' doesn't match
def test_empty_source_id_skips_canonical_match(cur):
_add(cur, id="row", title="Parachutes", spotify_album_id="")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="Other",
album_source_col="spotify_album_id", album_source_id="")
assert got is None
def test_missing_album_column_falls_through_not_raises(cur):
# Some sources (Deezer) don't have a dedicated album id column on the albums
# table; an allow-listed-but-absent column must NOT raise (it broke the whole
# import once) — it falls through to the name match.
cur.execute("CREATE TABLE albums_min (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)")
cur.execute("INSERT INTO albums_min VALUES ('byname','art1','DZ Album','soulsync')")
# Point the helper at a table missing deezer_id by aliasing via a fresh cursor.
conn2 = sqlite3.connect(":memory:")
conn2.execute("CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)")
conn2.execute("INSERT INTO albums VALUES ('byname','art1','DZ Album','soulsync')")
c2 = conn2.cursor()
got = find_existing_soulsync_album_id(
c2, name_key_id="nk", artist_id="art1", album_name="DZ Album",
album_source_col="deezer_id", album_source_id="67890")
conn2.close()
assert got == "byname" # deezer_id column absent -> fell through to name match
def test_musicbrainz_release_id_grouping(cur):
_add(cur, id="mbrow", title="Album", musicbrainz_release_id="mb-123")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk2", artist_id="art1", album_name="Album (Remaster)",
album_source_col="musicbrainz_release_id", album_source_id="mb-123")
assert got == "mbrow"