soulsync/tests/database/test_get_artist_tracks_indexed.py
Broque Thomas feb6778af4 Address Cin review: extract helpers, indexed pool fetch, tidy nits
Three changes folded into one perf+cleanup pass:

1. Indexed fast path for the per-artist pool fetch. The previous
   `search_tracks(artist=name)` call hit
   `unidecode_lower(artists.name) LIKE ?`, a function-in-WHERE that
   can't use `idx_artists_name`. New `MusicDatabase.get_artist_tracks_indexed`
   does a two-step lookup: exact-name match (indexed) plus a
   case-insensitive fallback, then `tracks WHERE artist_id IN (...)`
   via `idx_tracks_artist_id`. Drops per-artist fetch from seconds to
   milliseconds for the common case. The sync helper falls back to
   the old LIKE-based `search_tracks` only when the indexed lookup
   finds nothing, preserving diacritic recall and `tracks.track_artist`
   feature-artist matches with zero regression.

2. Public text-normalization helper. Lifted the body of
   `MusicDatabase._normalize_for_comparison` into
   `core/text/normalize.py:normalize_for_comparison` so callers outside
   the database layer (matching engine, sync pool, future import-side
   comparisons) don't reach across the module boundary into a
   leading-underscore "private" method. The DB method now delegates,
   so existing internal call sites stay untouched. Sync's lazy pool
   now imports the public helper.

3. Artist-name walker extracted. `_artist_name` at module level in
   `services/sync_service.py` replaces two near-identical inline
   str-or-dict-or-fallback walkers (one in `sync_playlist`, one in
   `_find_track_in_media_server`). Returns `''` for None instead of
   the literal string `'None'`.

Plus three small tidies from the same review:

- `_POOL_FETCH_LIMIT = 10000` constant in place of the literal at the
  pool-fetch call site.
- Trimmed the verbose docstring + comment block on the pool helper.
- Set-intersection predicate for the trigger-shape reset in
  `core/automation/api.py` instead of a two-line `or` chain.

Also removed the duplicate `_get_active_media_client()` call at
sync_service.py:212/214 — pre-existing wart that was sitting in the
same block I was editing.

Tests: 21 new tests across `tests/database/`, `tests/sync/`, and
`tests/text/`, plus updates to the existing pool tests to cover the
new fast/fallback split. Full suite stays green (3953 passing).
2026-05-24 23:33:55 -07:00

127 lines
5 KiB
Python

"""Tests for `MusicDatabase.get_artist_tracks_indexed` — the indexed
two-step lookup that backs the sync candidate pool fast path."""
from __future__ import annotations
from database.music_database import MusicDatabase
def _seed(db: MusicDatabase, rows):
"""Insert (artist_name, album_title, track_title, server_source) tuples.
IDs are TEXT PRIMARY KEY in this schema so we hand-mint string IDs to
keep foreign-key wiring happy."""
conn = db._get_connection()
cursor = conn.cursor()
artist_ids: dict = {}
album_ids: dict = {}
track_counter = 0
for artist_name, album_title, track_title, server_source in rows:
if artist_name not in artist_ids:
aid = f"a-{len(artist_ids) + 1}"
cursor.execute(
"INSERT INTO artists (id, name, server_source) VALUES (?, ?, ?)",
(aid, artist_name, server_source),
)
artist_ids[artist_name] = aid
album_key = (artist_name, album_title, server_source)
if album_key not in album_ids:
alid = f"al-{len(album_ids) + 1}"
cursor.execute(
"INSERT INTO albums (id, artist_id, title, server_source) VALUES (?, ?, ?, ?)",
(alid, artist_ids[artist_name], album_title, server_source),
)
album_ids[album_key] = alid
track_counter += 1
cursor.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, server_source) VALUES (?, ?, ?, ?, ?)",
(f"t-{track_counter}", album_ids[album_key], artist_ids[artist_name], track_title, server_source),
)
conn.commit()
def test_exact_name_match_returns_tracks(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'),
('Drake', 'For All The Dogs', 'Slime You Out', 'plex'),
('SZA', 'SOS', 'Kill Bill', 'plex'),
])
tracks = db.get_artist_tracks_indexed('Drake')
titles = sorted(t.title for t in tracks)
assert titles == ['First Person Shooter', 'Slime You Out']
def test_case_insensitive_fallback_finds_artist(tmp_path):
"""Exact match misses 'DRAKE' (case-sensitive index lookup), but the
fallback LOWER() comparison still finds the canonical 'Drake' row."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'FATD', 'IDGAF', 'plex'),
])
tracks = db.get_artist_tracks_indexed('DRAKE')
assert len(tracks) == 1
assert tracks[0].title == 'IDGAF'
def test_artist_absent_returns_empty_list(tmp_path):
"""Genuinely missing artists must fall straight through both steps
and return [] — that's what lets the caller skip the slow LIKE
fallback when the artist isn't in the library at all."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'FATD', 'IDGAF', 'plex'),
])
assert db.get_artist_tracks_indexed('Nonexistent Artist') == []
def test_empty_name_returns_empty(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
assert db.get_artist_tracks_indexed('') == []
def test_server_source_filter_excludes_other_servers(tmp_path):
"""The pool is per-server — Plex sync must not see Jellyfin tracks
even when the artist exists on both."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'Plex Album', 'Plex Track', 'plex'),
('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'),
])
plex_tracks = db.get_artist_tracks_indexed('Drake', server_source='plex')
jellyfin_tracks = db.get_artist_tracks_indexed('Drake', server_source='jellyfin')
assert [t.title for t in plex_tracks] == ['Plex Track']
assert [t.title for t in jellyfin_tracks] == ['Jellyfin Track']
def test_no_server_filter_returns_all_servers(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'Plex Album', 'Plex Track', 'plex'),
('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'),
])
tracks = db.get_artist_tracks_indexed('Drake')
titles = sorted(t.title for t in tracks)
assert titles == ['Jellyfin Track', 'Plex Track']
def test_limit_caps_result_set(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [('Drake', 'Album', f'Track {i}', 'plex') for i in range(10)])
tracks = db.get_artist_tracks_indexed('Drake', limit=3)
assert len(tracks) == 3
def test_returned_tracks_carry_artist_and_album_fields(tmp_path):
"""check_track_exists' batched path reads `artist_name` and
`album_title` off each track for confidence scoring — verify the
indexed query attaches them like search_tracks does."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'),
])
tracks = db.get_artist_tracks_indexed('Drake')
assert len(tracks) == 1
t = tracks[0]
assert t.artist_name == 'Drake'
assert t.album_title == 'For All The Dogs'
assert t.title == 'First Person Shooter'