diff --git a/web/prisma/migrations/20260711100620_add_monitoring/migration.sql b/web/prisma/migrations/20260711100620_add_monitoring/migration.sql new file mode 100644 index 0000000..08375f2 --- /dev/null +++ b/web/prisma/migrations/20260711100620_add_monitoring/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "MonitoredReleaseState" AS ENUM ('wanted', 'grabbed', 'fulfilled'); + +-- AlterTable +ALTER TABLE "Request" ADD COLUMN "monitoredReleaseId" TEXT; + +-- CreateTable +CREATE TABLE "WatchedArtist" ( + "id" TEXT NOT NULL, + "mbid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "autoMonitorFuture" BOOLEAN NOT NULL DEFAULT false, + "monitorFrom" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastPolledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WatchedArtist_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MonitoredRelease" ( + "id" TEXT NOT NULL, + "watchedArtistId" TEXT, + "artistMbid" TEXT NOT NULL, + "artistName" TEXT NOT NULL, + "rgMbid" TEXT NOT NULL, + "album" TEXT NOT NULL, + "primaryType" TEXT, + "secondaryTypes" TEXT[], + "firstReleaseDate" TEXT, + "monitored" BOOLEAN NOT NULL DEFAULT false, + "state" "MonitoredReleaseState" NOT NULL DEFAULT 'wanted', + "currentQualityClass" INTEGER, + "firstGrabbedAt" TIMESTAMP(3), + "lastSearchedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MonitoredRelease_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WatchedArtist_mbid_key" ON "WatchedArtist"("mbid"); + +-- CreateIndex +CREATE UNIQUE INDEX "MonitoredRelease_rgMbid_key" ON "MonitoredRelease"("rgMbid"); + +-- AddForeignKey +ALTER TABLE "Request" ADD CONSTRAINT "Request_monitoredReleaseId_fkey" FOREIGN KEY ("monitoredReleaseId") REFERENCES "MonitoredRelease"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MonitoredRelease" ADD CONSTRAINT "MonitoredRelease_watchedArtistId_fkey" FOREIGN KEY ("watchedArtistId") REFERENCES "WatchedArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 209ca4d..6eac875 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -23,6 +23,12 @@ enum JobState { needs_attention } +enum MonitoredReleaseState { + wanted + grabbed + fulfilled +} + model Request { id String @id @default(cuid()) artist String @@ -31,6 +37,9 @@ model Request { createdAt DateTime @default(now()) job Job? libraryItem LibraryItem? + + monitoredReleaseId String? + monitoredRelease MonitoredRelease? @relation(fields: [monitoredReleaseId], references: [id], onDelete: SetNull) } model Job { @@ -82,3 +91,34 @@ model Config { secret Boolean @default(false) updatedAt DateTime @updatedAt } + +model WatchedArtist { + id String @id @default(cuid()) + mbid String @unique + name String + autoMonitorFuture Boolean @default(false) + monitorFrom DateTime @default(now()) + lastPolledAt DateTime? + createdAt DateTime @default(now()) + releases MonitoredRelease[] +} + +model MonitoredRelease { + id String @id @default(cuid()) + watchedArtist WatchedArtist? @relation(fields: [watchedArtistId], references: [id], onDelete: Cascade) + watchedArtistId String? + artistMbid String + artistName String + rgMbid String @unique + album String + primaryType String? + secondaryTypes String[] + firstReleaseDate String? + monitored Boolean @default(false) + state MonitoredReleaseState @default(wanted) + currentQualityClass Int? + firstGrabbedAt DateTime? + lastSearchedAt DateTime? + createdAt DateTime @default(now()) + requests Request[] +} diff --git a/worker/tests/conftest.py b/worker/tests/conftest.py index 4ff5caf..e7a7fb0 100644 --- a/worker/tests/conftest.py +++ b/worker/tests/conftest.py @@ -12,6 +12,8 @@ def conn(): with connection.cursor() as cur: cur.execute('DELETE FROM "Job"') cur.execute('DELETE FROM "Request"') + cur.execute('DELETE FROM "MonitoredRelease"') + cur.execute('DELETE FROM "WatchedArtist"') cur.execute('DELETE FROM "Config"') connection.commit() yield connection @@ -19,6 +21,8 @@ def conn(): with connection.cursor() as cur: cur.execute('DELETE FROM "Job"') cur.execute('DELETE FROM "Request"') + cur.execute('DELETE FROM "MonitoredRelease"') + cur.execute('DELETE FROM "WatchedArtist"') cur.execute('DELETE FROM "Config"') connection.commit() connection.close() @@ -41,3 +45,41 @@ def insert_request(conn, artist="Artist", album="Album"): 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"): + """Insert a WatchedArtist; monitor_from_sql / last_polled_sql 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", + (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 diff --git a/worker/tests/test_monitoring_schema.py b/worker/tests/test_monitoring_schema.py new file mode 100644 index 0000000..b322148 --- /dev/null +++ b/worker/tests/test_monitoring_schema.py @@ -0,0 +1,36 @@ +from tests.conftest import insert_monitored_release, insert_watched_artist + + +def test_watched_artist_and_release_round_trip(conn): + artist_id = insert_watched_artist(conn, mbid="mbid-1", name="John Mayer", auto_monitor_future=True) + rel_id = insert_monitored_release( + conn, watched_artist_id=artist_id, artist_mbid="mbid-1", artist_name="John Mayer", + rg_mbid="rg-1", album="Continuum", primary_type="Album", + secondary_types=["Live"], first_release_date="2006-09-12", monitored=True, + ) + with conn.cursor() as cur: + cur.execute( + 'SELECT "watchedArtistId", album, "primaryType", "secondaryTypes", ' + 'monitored, state FROM "MonitoredRelease" WHERE id = %s', + (rel_id,), + ) + row = cur.fetchone() + assert row == (artist_id, "Continuum", "Album", ["Live"], True, "wanted") + + +def test_request_has_nullable_monitored_release_link(conn): + rel_id = insert_monitored_release( + conn, watched_artist_id=None, artist_mbid="mbid-2", artist_name="X", + rg_mbid="rg-2", album="Y", monitored=True, + ) + with conn.cursor() as cur: + cur.execute( + 'INSERT INTO "Request" (id, artist, album, status, "monitoredReleaseId", "createdAt") ' + "VALUES (gen_random_uuid()::text, 'X', 'Y', 'pending', %s, now()) RETURNING id", + (rel_id,), + ) + req_id = cur.fetchone()[0] + conn.commit() + with conn.cursor() as cur: + cur.execute('SELECT "monitoredReleaseId" FROM "Request" WHERE id = %s', (req_id,)) + assert cur.fetchone()[0] == rel_id