feat(discovery): DiscoverySuggestion schema + WatchedArtist.lastDiscoveredAt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-11 23:58:45 +02:00
parent 0d2b7f0ae5
commit 0ddc9db611
5 changed files with 129 additions and 5 deletions
@@ -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");
+32
View File
@@ -100,6 +100,7 @@ model WatchedArtist {
showAllTypes Boolean @default(false) showAllTypes Boolean @default(false)
monitorFrom DateTime @default(now()) monitorFrom DateTime @default(now())
lastPolledAt DateTime? lastPolledAt DateTime?
lastDiscoveredAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
releases MonitoredRelease[] releases MonitoredRelease[]
} }
@@ -123,3 +124,34 @@ model MonitoredRelease {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
requests Request[] 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
}
+1
View File
@@ -15,5 +15,6 @@ beforeEach(async () => {
await prisma.libraryItem.deleteMany(); await prisma.libraryItem.deleteMany();
await prisma.monitoredRelease.deleteMany(); await prisma.monitoredRelease.deleteMany();
await prisma.watchedArtist.deleteMany(); await prisma.watchedArtist.deleteMany();
await prisma.discoverySuggestion.deleteMany();
await prisma.config.deleteMany(); await prisma.config.deleteMany();
}); });
+32 -5
View File
@@ -27,9 +27,14 @@ def conn():
cur.execute('DELETE FROM "LibraryItem"') cur.execute('DELETE FROM "LibraryItem"')
cur.execute('DELETE FROM "MonitoredRelease"') cur.execute('DELETE FROM "MonitoredRelease"')
cur.execute('DELETE FROM "WatchedArtist"') cur.execute('DELETE FROM "WatchedArtist"')
cur.execute('DELETE FROM "DiscoverySuggestion"')
cur.execute('DELETE FROM "Config"') cur.execute('DELETE FROM "Config"')
connection.commit() connection.commit()
yield connection 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). # Clean up rows created during the test (Job cascades from Request).
with connection.cursor() as cur: with connection.cursor() as cur:
cur.execute('DELETE FROM "Job"') cur.execute('DELETE FROM "Job"')
@@ -37,6 +42,7 @@ def conn():
cur.execute('DELETE FROM "LibraryItem"') cur.execute('DELETE FROM "LibraryItem"')
cur.execute('DELETE FROM "MonitoredRelease"') cur.execute('DELETE FROM "MonitoredRelease"')
cur.execute('DELETE FROM "WatchedArtist"') cur.execute('DELETE FROM "WatchedArtist"')
cur.execute('DELETE FROM "DiscoverySuggestion"')
cur.execute('DELETE FROM "Config"') cur.execute('DELETE FROM "Config"')
connection.commit() connection.commit()
connection.close() 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, def insert_watched_artist(conn, mbid="mbid", name="Artist", auto_monitor_future=False,
monitor_from_sql="now()", last_polled_sql="NULL"): monitor_from_sql="now()", last_polled_sql="NULL",
"""Insert a WatchedArtist; monitor_from_sql / last_polled_sql are raw SQL time expressions.""" last_discovered_sql="NULL"):
"""Insert a WatchedArtist; *_sql args are raw SQL time expressions."""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", ' 'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", '
f'"monitorFrom", "lastPolledAt", "createdAt") ' f'"monitorFrom", "lastPolledAt", "lastDiscoveredAt", "createdAt") '
f"VALUES (gen_random_uuid()::text, %s, %s, %s, {monitor_from_sql}, {last_polled_sql}, now()) " f"VALUES (gen_random_uuid()::text, %s, %s, %s, {monitor_from_sql}, "
"RETURNING id", f"{last_polled_sql}, {last_discovered_sql}, now()) RETURNING id",
(mbid, name, auto_monitor_future), (mbid, name, auto_monitor_future),
) )
artist_id = cur.fetchone()[0] 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] rel_id = cur.fetchone()[0]
conn.commit() conn.commit()
return rel_id 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
+31
View File
@@ -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