Files
Lyra/worker/tests/conftest.py
T
Jonathan 6ca39859fa feat(library): surface albums the scan couldn't match
The library scan silently skipped album folders with no MusicBrainz match
(or an unreadable file). Persist them so they're visible and fixable.

- New UnmatchedAlbum model (migration add_unmatched_album), keyed by path.
- scan_chunk records a skipped album as unmatched (reason "no MusicBrainz
  match" or "unreadable: <err>"), clears the row when an album later
  resolves, and prunes stale rows (a prior scan's, or a removed folder)
  when a scan completes.
- GET /api/library/unmatched lists them; DELETE ?id= dismisses one.
- /library shows a "Couldn't match" section (path + reason + Dismiss).

Worker + web tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:56:31 +02:00

135 lines
6.0 KiB
Python

import os
os.environ.setdefault("DATABASE_URL", "postgresql://lyra:lyra@localhost:5432/lyra_test")
import psycopg
import pytest
def _require_test_db(dsn: str) -> None:
name = dsn.rsplit("/", 1)[-1].split("?")[0]
if not name.endswith("_test"):
raise RuntimeError(
f"refusing to run destructive test fixtures against non-test database '{name}'. "
"Point DATABASE_URL at a database whose name ends in '_test' (e.g. lyra_test)."
)
@pytest.fixture()
def conn():
dsn = os.environ["DATABASE_URL"]
_require_test_db(dsn)
connection = psycopg.connect(dsn)
# Clean up any pre-existing rows before the test runs (Job cascades from Request).
with connection.cursor() as cur:
cur.execute('DELETE FROM "Job"')
cur.execute('DELETE FROM "Request"')
cur.execute('DELETE FROM "LibraryItem"')
cur.execute('DELETE FROM "MonitoredRelease"')
cur.execute('DELETE FROM "WatchedArtist"')
cur.execute('DELETE FROM "DiscoverySuggestion"')
cur.execute('DELETE FROM "DiscoverySeedContribution"')
cur.execute('DELETE FROM "ScanWorkItem"')
cur.execute('DELETE FROM "UnmatchedAlbum"')
cur.execute('DELETE FROM "ApiCache"')
cur.execute('DELETE FROM "Config"')
connection.commit()
yield connection
# A test may have left the transaction aborted (e.g. asserting on a
# UniqueViolation via pytest.raises without rolling back); reset it so
# the cleanup below can run.
connection.rollback()
# Clean up rows created during the test (Job cascades from Request).
with connection.cursor() as cur:
cur.execute('DELETE FROM "Job"')
cur.execute('DELETE FROM "Request"')
cur.execute('DELETE FROM "LibraryItem"')
cur.execute('DELETE FROM "MonitoredRelease"')
cur.execute('DELETE FROM "WatchedArtist"')
cur.execute('DELETE FROM "DiscoverySuggestion"')
cur.execute('DELETE FROM "DiscoverySeedContribution"')
cur.execute('DELETE FROM "ScanWorkItem"')
cur.execute('DELETE FROM "UnmatchedAlbum"')
cur.execute('DELETE FROM "ApiCache"')
cur.execute('DELETE FROM "Config"')
connection.commit()
connection.close()
def insert_request(conn, artist="Artist", album="Album"):
"""Insert a Request + its Job (state 'requested') and return the job id."""
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Request" (id, artist, album, status, "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, 'pending', now()) RETURNING id",
(artist, album),
)
request_id = cur.fetchone()[0]
cur.execute(
'INSERT INTO "Job" (id, "requestId", state, "currentStage", attempts, "createdAt", "updatedAt") '
"VALUES (gen_random_uuid()::text, %s, 'requested', 'intake', 0, now(), now()) RETURNING id",
(request_id,),
)
job_id = cur.fetchone()[0]
conn.commit()
return job_id
def insert_watched_artist(conn, mbid="mbid", name="Artist", auto_monitor_future=False,
monitor_from_sql="now()", last_polled_sql="NULL",
last_discovered_sql="NULL"):
"""Insert a WatchedArtist; *_sql args are raw SQL time expressions."""
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", '
f'"monitorFrom", "lastPolledAt", "lastDiscoveredAt", "createdAt") '
f"VALUES (gen_random_uuid()::text, %s, %s, %s, {monitor_from_sql}, "
f"{last_polled_sql}, {last_discovered_sql}, now()) RETURNING id",
(mbid, name, auto_monitor_future),
)
artist_id = cur.fetchone()[0]
conn.commit()
return artist_id
def insert_monitored_release(conn, watched_artist_id=None, artist_mbid="mbid", artist_name="Artist",
rg_mbid="rg", album="Album", primary_type=None, secondary_types=None,
first_release_date=None, monitored=False, state="wanted",
current_quality_class=None, first_grabbed_sql="NULL",
last_searched_sql="NULL"):
"""Insert a MonitoredRelease; *_sql args are raw SQL time expressions."""
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "MonitoredRelease" (id, "watchedArtistId", "artistMbid", "artistName", '
'"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", monitored, '
f'state, "currentQualityClass", "firstGrabbedAt", "lastSearchedAt", "createdAt") '
f"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
f"{first_grabbed_sql}, {last_searched_sql}, now()) RETURNING id",
(watched_artist_id, artist_mbid, artist_name, rg_mbid, album, primary_type,
secondary_types if secondary_types is not None else [], first_release_date,
monitored, state, current_quality_class),
)
rel_id = cur.fetchone()[0]
conn.commit()
return rel_id
def insert_discovery_suggestion(conn, kind="artist", artist_mbid="cand", artist_name="Cand",
rg_mbid=None, album=None, score=1.0, seed_count=1,
sources=None, status="pending"):
"""Insert a DiscoverySuggestion; returns its id."""
dedupe = f"{kind}:{artist_mbid}:{rg_mbid or ''}"
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", '
'"rgMbid", album, score, "seedCount", sources, status, "dedupeKey", '
'"createdAt", "updatedAt") '
'VALUES (gen_random_uuid()::text, %s::"DiscoveryKind", %s, %s, %s, %s, %s, %s, %s, '
'%s::"SuggestionStatus", %s, now(), now()) RETURNING id',
(kind, artist_mbid, artist_name, rg_mbid, album, score, seed_count,
sources if sources is not None else ["listenbrainz"], status, dedupe),
)
sid = cur.fetchone()[0]
conn.commit()
return sid