fix: atomic import swap, disk/DB replace consistency, guarded pipeline loop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,19 +46,29 @@ def _find_cover(staging: str) -> str | None:
|
||||
|
||||
|
||||
def import_album(staging: str, final: str) -> str:
|
||||
"""Move the album's audio files (+ one cover image) from `staging` into a fresh `final`,
|
||||
replacing any existing folder there. Non-audio artifacts and nested subfolders are left
|
||||
behind (the caller removes `staging`). Tolerates a missing `staging`. Returns `final`."""
|
||||
"""Atomically place the album's audio (+ one cover) at `final`, replacing any existing folder.
|
||||
Assembles into a sibling temp dir, then swaps via rename so a mid-assembly failure never
|
||||
destroys the existing copy. Non-audio artifacts/subfolders in `staging` are left behind.
|
||||
Tolerates a missing `staging`. Returns `final`."""
|
||||
os.makedirs(os.path.dirname(final), exist_ok=True)
|
||||
tmp = final + ".importing"
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
os.makedirs(tmp)
|
||||
if os.path.isdir(staging):
|
||||
for name in sorted(os.listdir(staging)):
|
||||
src = os.path.join(staging, name)
|
||||
if os.path.isfile(src) and os.path.splitext(name)[1].lower() in _AUDIO_EXT:
|
||||
shutil.move(src, os.path.join(tmp, name))
|
||||
cover = _find_cover(staging)
|
||||
if cover is not None:
|
||||
shutil.move(cover, os.path.join(tmp, "cover.jpg"))
|
||||
# swap: keep the old copy until the new one is assembled, then rename into place
|
||||
if os.path.isdir(final):
|
||||
shutil.rmtree(final)
|
||||
os.makedirs(final, exist_ok=True)
|
||||
if not os.path.isdir(staging):
|
||||
return final
|
||||
for name in sorted(os.listdir(staging)):
|
||||
src = os.path.join(staging, name)
|
||||
if os.path.isfile(src) and os.path.splitext(name)[1].lower() in _AUDIO_EXT:
|
||||
shutil.move(src, os.path.join(final, name))
|
||||
cover = _find_cover(staging)
|
||||
if cover is not None:
|
||||
shutil.move(cover, os.path.join(final, "cover.jpg"))
|
||||
old = final + ".old"
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
os.rename(final, old)
|
||||
os.rename(tmp, final)
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
else:
|
||||
os.rename(tmp, final)
|
||||
return final
|
||||
|
||||
@@ -40,8 +40,12 @@ def run_forever() -> None:
|
||||
time.sleep(IDLE_SLEEP)
|
||||
continue
|
||||
print(f"worker: claimed job {job_id}", flush=True)
|
||||
run_pipeline(conn, job_id, adapters, resolver=resolver, tagger=tagger,
|
||||
upgrade_cutoff=mcfg.quality_cutoff)
|
||||
try:
|
||||
run_pipeline(conn, job_id, adapters, resolver=resolver, tagger=tagger,
|
||||
upgrade_cutoff=mcfg.quality_cutoff)
|
||||
except Exception as e: # one bad job must not take down the worker loop
|
||||
print(f"worker: pipeline failed for job {job_id}: {e}", flush=True)
|
||||
conn.rollback()
|
||||
if mcfg.enabled:
|
||||
try:
|
||||
reconcile(conn, job_id, mcfg)
|
||||
|
||||
@@ -80,6 +80,16 @@ def _already_in_library_at_cutoff(conn: psycopg.Connection, target: MBTarget, cu
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _library_quality(conn: psycopg.Connection, target: MBTarget) -> int | None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'SELECT "qualityClass" FROM "LibraryItem" WHERE artist = %s AND album = %s',
|
||||
(target.artist, target.album),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _persist_candidates(conn: psycopg.Connection, job_id: str, candidates: list[Candidate]) -> None:
|
||||
with conn.cursor() as cur:
|
||||
for c in candidates:
|
||||
@@ -211,16 +221,24 @@ def run_pipeline(
|
||||
return
|
||||
|
||||
final = album_dir(dest_root, target)
|
||||
import_album(staging, final) # clean audio(+cover) move into the library, replacing any prior copy
|
||||
|
||||
if tagger is not None:
|
||||
try:
|
||||
tagger.tag_album(final, target)
|
||||
except Exception as e: # a tagging failure must not discard a good download
|
||||
print(f"pipeline: tagging failed for job {job_id}: {e}", flush=True)
|
||||
|
||||
# 6. import (DB)
|
||||
existing_q = _library_quality(conn, target)
|
||||
new_q = quality_class(winner.quality)
|
||||
_set_state(conn, job_id, "imported", "import")
|
||||
_import(conn, job_id, target, winner, final)
|
||||
if existing_q is None or new_q > existing_q:
|
||||
import_album(staging, final) # clean audio(+cover) move, atomically replacing any prior copy
|
||||
if tagger is not None:
|
||||
try:
|
||||
tagger.tag_album(final, target)
|
||||
except Exception as e: # a tagging failure must not discard a good download
|
||||
print(f"pipeline: tagging failed for job {job_id}: {e}", flush=True)
|
||||
_import(conn, job_id, target, winner, final)
|
||||
else:
|
||||
# an existing copy is already at >= this quality — keep it untouched, just complete the request
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE \"Request\" SET status = 'completed' WHERE id = %s",
|
||||
(_request_id(conn, job_id),),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True) # a partial/failed download never persists
|
||||
|
||||
Reference in New Issue
Block a user