You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/test_atomic_audio_save.py

138 lines
4.3 KiB

"""Atomic tag saves: an interrupted/OOM-killed save must never destroy the
user's file (#819 — CubeComming's hi-res FLACs imported to an empty shell).
save_audio_file writes the modified tags into a temp COPY, verifies it's still
valid audio, then os.replace()s it in — the original is untouched until that
atomic swap.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from types import SimpleNamespace
import pytest
from core.metadata.common import save_audio_file
def _symbols(file_length):
"""Symbols whose File() reports the given decoded length (None → invalid)."""
return SimpleNamespace(
ID3=type("ID3", (), {}), FLAC=type("FLAC", (), {}),
File=lambda p: (None if file_length is None
else SimpleNamespace(info=SimpleNamespace(length=file_length))),
)
def test_atomic_replace_on_success(tmp_path):
f = tmp_path / "song.flac"
f.write_bytes(b"ORIGINAL-AUDIO")
saved_to = []
class Audio:
filename = str(f)
tags = None
def save(self, target=None, **k):
saved_to.append(target)
# mimic mutagen writing modified tags into the temp copy
with open(target, "ab") as h:
h.write(b"+TAGS")
save_audio_file(Audio(), _symbols(180.0))
assert f.read_bytes() == b"ORIGINAL-AUDIO+TAGS" # replaced with the tagged copy
assert saved_to == [str(f) + ".sstmp"] # wrote the temp, NOT in place
assert not (tmp_path / "song.flac.sstmp").exists() # temp cleaned up
def test_original_survives_save_failure(tmp_path):
# The #819 scenario: the save blows up mid-write. The original must be intact.
f = tmp_path / "song.flac"
f.write_bytes(b"ORIGINAL")
inplace = []
class Audio:
filename = str(f)
tags = None
def save(self, target=None, **k):
if target is not None:
raise OSError("simulated interrupted/OOM save")
inplace.append(True) # fallback in-place save (writes nothing here)
save_audio_file(Audio(), _symbols(180.0))
assert f.read_bytes() == b"ORIGINAL" # never destroyed
assert not (tmp_path / "song.flac.sstmp").exists() # temp removed
assert inplace == [True] # fell back to in-place
def test_corrupt_temp_rejected(tmp_path):
# save-to-temp "succeeds" but produces a file with no audio → must NOT
# replace the original; fall back instead.
f = tmp_path / "song.flac"
f.write_bytes(b"ORIGINAL")
inplace = []
class Audio:
filename = str(f)
tags = None
def save(self, target=None, **k):
if target is None:
inplace.append(True)
save_audio_file(Audio(), _symbols(0)) # File().info.length == 0 → invalid
assert f.read_bytes() == b"ORIGINAL"
assert not (tmp_path / "song.flac.sstmp").exists()
assert inplace == [True]
def test_no_filename_plain_save():
saves = []
class Audio:
filename = None
tags = None
def save(self, target=None, **k):
saves.append(target)
save_audio_file(Audio(), _symbols(180.0))
assert saves == [None] # nothing to be atomic about → plain in-place
# ── real mutagen round-trip (only if ffmpeg can make a FLAC) ──
def _make_flac(path):
ff = shutil.which("ffmpeg")
if not ff:
return False
r = subprocess.run(
[ff, "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-y", str(path)],
capture_output=True)
return r.returncode == 0 and os.path.getsize(path) > 0
def test_real_flac_atomic_save_preserves_audio(tmp_path):
from mutagen.flac import FLAC
f = tmp_path / "real.flac"
if not _make_flac(f):
pytest.skip("ffmpeg unavailable — cannot build a real FLAC")
orig_len = FLAC(str(f)).info.length
audio = FLAC(str(f))
audio["title"] = "Atomic Test"
save_audio_file(audio, SimpleNamespace(ID3=type("ID3", (), {}), FLAC=FLAC,
File=__import__("mutagen").File))
reread = FLAC(str(f))
assert reread.info.length == pytest.approx(orig_len, abs=0.05) # audio intact
assert reread["title"] == ["Atomic Test"] # tag written
assert not (tmp_path / "real.flac.sstmp").exists()