feat(library): replace/upgrade an owned album on demand

Add Request.force (migration add_request_force): the pipeline's intake now
skips the "already in library" dedupe for a forced job, so an owned album is
re-downloaded. The import step still keeps the new copy only when it's higher
quality, so a forced upgrade can never downgrade what's on disk.

- Worker: _is_force_job → dedupe bypassed when Request.force.
- POST /api/library/[id]/upgrade finds the matching MonitoredRelease and
  enqueues a force request (guards against stacking on an in-flight job).
- Library route exposes monitoredReleaseId; the album modal shows a
  "Replace / upgrade" button when a release exists.

Worker + web tests added (pipeline force re-acquire; upgrade route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 21:28:54 +02:00
parent 7719b2a0d8
commit 8c8a34e320
9 changed files with 160 additions and 6 deletions
+4 -4
View File
@@ -56,13 +56,13 @@ def conn():
connection.close()
def insert_request(conn, artist="Artist", album="Album"):
def insert_request(conn, artist="Artist", album="Album", force=False):
"""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),
'INSERT INTO "Request" (id, artist, album, status, force, "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, 'pending', %s, now()) RETURNING id",
(artist, album, force),
)
request_id = cur.fetchone()[0]
cur.execute(
+19
View File
@@ -124,6 +124,25 @@ def test_dedupe_skips_already_in_library(conn):
assert cur.fetchone()[0] == 1 # not duplicated
def test_force_reacquires_an_album_already_in_library(conn):
# First acquisition puts the album in the library.
job1 = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job1, [FakeQobuz()], dest_root="/tmp/lib")
# A forced re-acquire skips the dedupe → it searches + persists candidates again.
job2 = insert_request(conn, artist="Radiohead", album="In Rainbows", force=True)
claim_next(conn)
run_pipeline(conn, job2, [FakeQobuz()], dest_root="/tmp/lib", upgrade_cutoff=2)
assert _job_state(conn, job2) == ("imported", "import")
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "Candidate" WHERE "jobId" = %s', (job2,))
assert cur.fetchone()[0] >= 1 # dedupe bypassed — it actually ran the search
cur.execute('SELECT count(*) FROM "LibraryItem" WHERE artist = %s AND album = %s',
("Radiohead", "In Rainbows"))
assert cur.fetchone()[0] == 1 # still not duplicated (keep-if-better import)
def test_below_threshold_candidates_are_persisted_for_diagnosis(conn):
from lyra_worker.adapters.fakes import FakeQobuz
from lyra_worker.types import MBTarget