soulsync/core/imports/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

94 lines
3.8 KiB
Python

"""Canonical album grouping for the SoulSync standalone import.
SoulSync grouped imported tracks into albums by the album NAME string
(``_stable_soulsync_id("artist::album_name")``). That splits one release into
several album rows whenever the name string drifts between imports (case,
punctuation, ``(Deluxe Edition)`` suffixes, source-A-vs-B spelling), and every
downstream tool (Library Re-tag, Cover-Art Filler) then dresses each split row
in its own cover — so songs that belong to one album end up with different art
(Sokhi).
This module is the pure, seam-testable heart of "group by canonical id, not
name": when an imported track carries a metadata-source RELEASE id, prefer
matching an existing album row by that id over the fragile name string, so the
SAME release always lands in ONE album row regardless of how its name was typed.
Scope (deliberate): this unifies differently-named imports of the SAME release.
It does NOT merge a track that genuinely matched a SINGLE release (a different
release id) into its parent album — that needs single->album resolution upstream
and is a separate change. New imports only; existing rows are left untouched.
Pure SQL-over-a-cursor; no app singletons, so it tests against an in-memory DB.
"""
from __future__ import annotations
from typing import Any, Optional
# Album source-id columns this grouping may key on. An allowlist (not arbitrary
# interpolation) — the column name IS spliced into SQL, so it must be a known,
# trusted identifier. Mirrors get_library_source_id_columns()' 'album' values.
ALLOWED_ALBUM_SOURCE_COLS = frozenset({
"spotify_album_id",
"itunes_album_id",
"deezer_id",
"soul_id",
"discogs_id",
"musicbrainz_release_id",
})
def find_existing_soulsync_album_id(
cursor: Any,
*,
name_key_id: str,
artist_id: str,
album_name: str,
album_source_col: Optional[str] = None,
album_source_id: Optional[str] = None,
) -> Optional[str]:
"""Resolve the existing ``soulsync`` album row a track should join, or None
(caller inserts a new row keyed by ``name_key_id``).
Match precedence:
1. ``name_key_id`` — the exact prior stable-name-hash id (unchanged
behaviour: a re-import with the identical name hits its own row).
2. ``album_source_col == album_source_id`` — CANONICAL grouping: an
existing row already carrying THIS release's source id, so a
differently-named import of the same release unifies instead of
splitting. Only when the column is allow-listed and the id is non-empty.
3. ``(title, artist_id)`` — the legacy name match (kept so nothing that
grouped before stops grouping now).
"""
cursor.execute(
"SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'",
(name_key_id,),
)
row = cursor.fetchone()
if row:
return row[0]
if album_source_col in ALLOWED_ALBUM_SOURCE_COLS and album_source_id:
try:
cursor.execute(
f"SELECT id FROM albums WHERE {album_source_col} = ? "
"AND server_source = 'soulsync' LIMIT 1",
(album_source_id,),
)
row = cursor.fetchone()
if row:
return row[0]
except Exception:
# That source has no dedicated album column on this DB (e.g. Deezer
# doesn't split per-entity id columns) — fall through to the name
# match rather than break the import. Mirrors the guarded source-id
# UPDATE the caller already does on insert.
pass
cursor.execute(
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? "
"AND server_source = 'soulsync' LIMIT 1",
(album_name, artist_id),
)
row = cursor.fetchone()
return row[0] if row else None