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:
@@ -146,6 +146,19 @@ def _is_upgrade_job(conn: psycopg.Connection, job_id: str) -> bool:
|
||||
return bool(row and row[0])
|
||||
|
||||
|
||||
def _is_force_job(conn: psycopg.Connection, job_id: str) -> bool:
|
||||
"""A user-forced re-acquire (Library 'Replace / upgrade'): skip the already-in-library
|
||||
dedupe so an owned album is re-downloaded. The import step still keeps the new copy only
|
||||
if it's higher quality, so a forced upgrade can never downgrade what's on disk."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'SELECT force FROM "Request" WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
|
||||
(job_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return bool(row and row[0])
|
||||
|
||||
|
||||
def _already_in_library_at_cutoff(conn: psycopg.Connection, target: MBTarget, cutoff: int) -> bool:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -237,8 +250,11 @@ def run_pipeline(
|
||||
if resolved is not None:
|
||||
target = resolved
|
||||
# dedupe: a monitor-driven upgrade job proceeds unless the existing copy already meets
|
||||
# the cutoff; a plain request short-circuits on any existing copy (slice-1 behavior).
|
||||
if upgrade_cutoff is not None and _is_upgrade_job(conn, job_id):
|
||||
# the cutoff; a plain request short-circuits on any existing copy (slice-1 behavior). A
|
||||
# user-forced re-acquire skips the dedupe entirely (keep-if-better still guards the import).
|
||||
if _is_force_job(conn, job_id):
|
||||
_dedupe_hit = False
|
||||
elif upgrade_cutoff is not None and _is_upgrade_job(conn, job_id):
|
||||
_dedupe_hit = _already_in_library_at_cutoff(conn, target, upgrade_cutoff)
|
||||
else:
|
||||
_dedupe_hit = _already_in_library(conn, target)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user