diff --git a/worker/lyra_worker/library.py b/worker/lyra_worker/library.py index 7d288dd..de0efbb 100644 --- a/worker/lyra_worker/library.py +++ b/worker/lyra_worker/library.py @@ -64,20 +64,34 @@ def import_album(staging: str, final: str) -> str: 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`.""" + # Recover from a swap interrupted by a crash: {final}.old holds the previous copy. + old = final + ".old" + if os.path.isdir(old) and not os.path.isdir(final): + os.rename(old, final) # restore the last-good copy + shutil.rmtree(old, ignore_errors=True) # drop a leftover .old (swap had completed) + shutil.rmtree(final + ".importing", ignore_errors=True) # drop a stale half-assembly os.makedirs(os.path.dirname(final), exist_ok=True) tmp = final + ".importing" shutil.rmtree(tmp, ignore_errors=True) os.makedirs(tmp) + imported = False 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)) + imported = True cover = _find_cover(staging) if cover is not None: shutil.move(cover, os.path.join(tmp, "cover.jpg")) + imported = True # swap: keep the old copy until the new one is assembled, then rename into place if os.path.isdir(final): + if not imported: + # Nothing new to import (e.g. a bare recovery pass with no/empty staging): leave the + # existing (possibly just-recovered) `final` untouched rather than swapping in empty. + shutil.rmtree(tmp, ignore_errors=True) + return final old = final + ".old" shutil.rmtree(old, ignore_errors=True) os.rename(final, old) diff --git a/worker/tests/test_staging.py b/worker/tests/test_staging.py index b1bb320..d5d434a 100644 --- a/worker/tests/test_staging.py +++ b/worker/tests/test_staging.py @@ -55,6 +55,29 @@ def test_import_album_leaves_no_temp_siblings(tmp_path): assert os.listdir(tmp_path / "Artist") == ["Album"] # no .importing / .old leftovers +def test_import_album_recovers_from_interrupted_swap(tmp_path): + final = str(tmp_path / "Artist" / "Album (2020)") + os.makedirs(final + ".old") + open(os.path.join(final + ".old", "01 Song.flac"), "w").close() + # `final` is missing (crash happened after rename(final, old), before rename(tmp, final)) + staging = str(tmp_path / "staging") # empty/missing staging + import_album(staging, final) + # previous good copy restored to `final`; no leftover .old + assert os.path.isfile(os.path.join(final, "01 Song.flac")) + assert not os.path.isdir(final + ".old") + + +def test_import_album_drops_stale_old_when_final_present(tmp_path): + final = str(tmp_path / "Artist" / "Album (2020)") + os.makedirs(final) + open(os.path.join(final, "keep.flac"), "w").close() + os.makedirs(final + ".old") # leftover from a completed swap + open(os.path.join(final + ".old", "stale.flac"), "w").close() + import_album(str(tmp_path / "staging"), final) + assert not os.path.isdir(final + ".old") # stale .old removed + assert os.path.isfile(os.path.join(final, "keep.flac")) # existing copy untouched by preamble + + def test_clear_staging_root_removes_jobs_but_keeps_root(tmp_path): # Clears per-job dirs (and stray files) under the root, but leaves the root itself so a # mounted staging volume stays mounted.