diff --git a/web/prisma/migrations/20260711215631_add_discovery/migration.sql b/web/prisma/migrations/20260711215631_add_discovery/migration.sql new file mode 100644 index 0000000..d9f87cd --- /dev/null +++ b/web/prisma/migrations/20260711215631_add_discovery/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "DiscoveryKind" AS ENUM ('artist', 'album'); + +-- CreateEnum +CREATE TYPE "SuggestionStatus" AS ENUM ('pending', 'followed', 'wanted', 'dismissed'); + +-- AlterTable +ALTER TABLE "WatchedArtist" ADD COLUMN "lastDiscoveredAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "DiscoverySuggestion" ( + "id" TEXT NOT NULL, + "kind" "DiscoveryKind" NOT NULL, + "artistMbid" TEXT NOT NULL, + "artistName" TEXT NOT NULL, + "rgMbid" TEXT, + "album" TEXT, + "primaryType" TEXT, + "secondaryTypes" TEXT[], + "firstReleaseDate" TEXT, + "score" DOUBLE PRECISION NOT NULL, + "seedCount" INTEGER NOT NULL, + "sources" TEXT[], + "status" "SuggestionStatus" NOT NULL DEFAULT 'pending', + "dedupeKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DiscoverySuggestion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscoverySuggestion_dedupeKey_key" ON "DiscoverySuggestion"("dedupeKey"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 404e3c4..51768a5 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -100,6 +100,7 @@ model WatchedArtist { showAllTypes Boolean @default(false) monitorFrom DateTime @default(now()) lastPolledAt DateTime? + lastDiscoveredAt DateTime? createdAt DateTime @default(now()) releases MonitoredRelease[] } @@ -123,3 +124,34 @@ model MonitoredRelease { createdAt DateTime @default(now()) requests Request[] } + +enum DiscoveryKind { + artist + album +} + +enum SuggestionStatus { + pending + followed + wanted + dismissed +} + +model DiscoverySuggestion { + id String @id @default(cuid()) + kind DiscoveryKind + artistMbid String + artistName String + rgMbid String? + album String? + primaryType String? + secondaryTypes String[] + firstReleaseDate String? + score Float + seedCount Int + sources String[] + status SuggestionStatus @default(pending) + dedupeKey String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 7677b47..59bef8c 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -15,5 +15,6 @@ beforeEach(async () => { await prisma.libraryItem.deleteMany(); await prisma.monitoredRelease.deleteMany(); await prisma.watchedArtist.deleteMany(); + await prisma.discoverySuggestion.deleteMany(); await prisma.config.deleteMany(); }); diff --git a/worker/tests/conftest.py b/worker/tests/conftest.py index d428b3b..f0d22a1 100644 --- a/worker/tests/conftest.py +++ b/worker/tests/conftest.py @@ -27,9 +27,14 @@ def conn(): cur.execute('DELETE FROM "LibraryItem"') cur.execute('DELETE FROM "MonitoredRelease"') cur.execute('DELETE FROM "WatchedArtist"') + cur.execute('DELETE FROM "DiscoverySuggestion"') 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"') @@ -37,6 +42,7 @@ def conn(): cur.execute('DELETE FROM "LibraryItem"') cur.execute('DELETE FROM "MonitoredRelease"') cur.execute('DELETE FROM "WatchedArtist"') + cur.execute('DELETE FROM "DiscoverySuggestion"') cur.execute('DELETE FROM "Config"') connection.commit() connection.close() @@ -62,14 +68,15 @@ def insert_request(conn, artist="Artist", album="Album"): def insert_watched_artist(conn, mbid="mbid", name="Artist", auto_monitor_future=False, - monitor_from_sql="now()", last_polled_sql="NULL"): - """Insert a WatchedArtist; monitor_from_sql / last_polled_sql are raw SQL time expressions.""" + 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", "createdAt") ' - f"VALUES (gen_random_uuid()::text, %s, %s, %s, {monitor_from_sql}, {last_polled_sql}, now()) " - "RETURNING id", + 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] @@ -97,3 +104,23 @@ def insert_monitored_release(conn, watched_artist_id=None, artist_mbid="mbid", a 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 diff --git a/worker/tests/test_discovery_schema.py b/worker/tests/test_discovery_schema.py new file mode 100644 index 0000000..9da4659 --- /dev/null +++ b/worker/tests/test_discovery_schema.py @@ -0,0 +1,31 @@ +import psycopg +import pytest + +from tests.conftest import insert_discovery_suggestion, insert_watched_artist + + +def test_suggestion_round_trip(conn): + sid = insert_discovery_suggestion( + conn, kind="album", artist_mbid="a1", artist_name="Boygenius", + rg_mbid="rg1", album="the record", score=2.5, seed_count=2, + sources=["listenbrainz"], status="pending", + ) + with conn.cursor() as cur: + cur.execute( + 'SELECT kind::text, "artistName", "rgMbid", album, score, "seedCount", ' + 'sources, status::text FROM "DiscoverySuggestion" WHERE id = %s', (sid,)) + assert cur.fetchone() == ( + "album", "Boygenius", "rg1", "the record", 2.5, 2, ["listenbrainz"], "pending") + + +def test_dedupe_key_is_unique(conn): + insert_discovery_suggestion(conn, kind="artist", artist_mbid="dup", rg_mbid=None) + with pytest.raises(psycopg.errors.UniqueViolation): + insert_discovery_suggestion(conn, kind="artist", artist_mbid="dup", rg_mbid=None) + + +def test_watched_artist_has_last_discovered_column(conn): + insert_watched_artist(conn, mbid="a1", last_discovered_sql="now()") + with conn.cursor() as cur: + cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("a1",)) + assert cur.fetchone()[0] is True