From c75b9d35627162bc90199d7ae6d5e403cfc49d90 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 11 Jul 2026 01:01:23 +0200 Subject: [PATCH] fix: force IPv4 in worker, count real Qobuz files, flatten nested output - worker had no IPv6 route -> Qobuz CDN (dual-stack) failed ~half the tracks with 'Network is unreachable'; disable IPv6 in the container (IPv4 only). - StreamripClient counted metadata tracks (always full) -> a partial download falsely imported; now count the actual files written (short count -> needs_attention). - streamrip writes into a nested 'Artist - Album [..]/' folder the tagger never saw; flatten audio files up into the album dir so tagging/organization runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 4 +++ worker/lyra_worker/adapters/_streamrip.py | 40 ++++++++++++++++++++--- worker/tests/test_streamrip_flatten.py | 33 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 worker/tests/test_streamrip_flatten.py diff --git a/docker-compose.yml b/docker-compose.yml index a009542..0054b0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: environment: DATABASE_URL: ${DATABASE_URL} LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} + # Qobuz's CDN also resolves to IPv6, but the container has no IPv6 route + # ("Network is unreachable"); disable IPv6 so downloads use IPv4 only. + sysctls: + - net.ipv6.conf.all.disable_ipv6=1 volumes: - ${MUSIC_DIR:-./music}:/music depends_on: diff --git a/worker/lyra_worker/adapters/_streamrip.py b/worker/lyra_worker/adapters/_streamrip.py index a1b3cf9..d02d9fd 100644 --- a/worker/lyra_worker/adapters/_streamrip.py +++ b/worker/lyra_worker/adapters/_streamrip.py @@ -9,6 +9,37 @@ def _hashed_password(password: str) -> str: return hashlib.md5(password.encode("utf-8")).hexdigest() +_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"} + + +def _flatten_audio(dest: str) -> int: + """streamrip writes into a nested `Artist - Album [...]/` subfolder. Move any audio + files up into `dest`, remove the now-empty subfolders, and return the count of audio + files directly in `dest` — the REAL number downloaded, so a partial download (streamrip + continues past per-track failures) reports its true short count to the completeness check. + """ + dest = os.path.abspath(dest) + for root, _dirs, files in os.walk(dest): + if os.path.abspath(root) == dest: + continue + for name in files: + if os.path.splitext(name)[1].lower() not in _AUDIO_EXT: + continue + src = os.path.join(root, name) + target = os.path.join(dest, name) + if os.path.exists(target): + base, ext = os.path.splitext(name) + target = os.path.join(dest, f"{base} ({os.path.basename(root)}){ext}") + os.rename(src, target) + for root, _dirs, _files in os.walk(dest, topdown=False): + if os.path.abspath(root) != dest: + try: + os.rmdir(root) + except OSError: + pass # subfolder still has non-audio files (e.g. cover art) — leave it + return sum(1 for f in os.listdir(dest) if os.path.splitext(f)[1].lower() in _AUDIO_EXT) + + class StreamripClient: """Real Qobuz client backed by streamrip (v2, async → sync via asyncio.run). @@ -76,9 +107,12 @@ class StreamripClient: return results def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict: - return asyncio.run(self._download(source_ref, dest, on_progress)) + asyncio.run(self._download(source_ref, dest, on_progress)) + # Count the files actually written and flatten streamrip's nested output into dest + # so the tag stage can organize it. A partial download -> short count -> needs_attention. + return {"track_count": _flatten_audio(dest), "path": dest} - async def _download(self, album_id: str, dest: str, on_progress: Callable[[float], None]) -> dict: + async def _download(self, album_id: str, dest: str, on_progress: Callable[[float], None]) -> None: os.makedirs(dest, exist_ok=True) from streamrip.client import QobuzClient from streamrip.db import Database, Dummy @@ -98,8 +132,6 @@ class StreamripClient: if album is None: raise RuntimeError(f"could not resolve Qobuz album {album_id}") await album.rip() - track_count = len(getattr(album, "tracks", []) or []) on_progress(1.0) finally: await client.session.close() - return {"track_count": track_count, "path": dest} diff --git a/worker/tests/test_streamrip_flatten.py b/worker/tests/test_streamrip_flatten.py new file mode 100644 index 0000000..4feee75 --- /dev/null +++ b/worker/tests/test_streamrip_flatten.py @@ -0,0 +1,33 @@ +from lyra_worker.adapters._streamrip import _flatten_audio + + +def test_flatten_moves_nested_audio_up_and_counts(tmp_path): + nested = tmp_path / "John Mayer - Continuum (2006) [FLAC] [24B-96kHz]" + nested.mkdir() + (nested / "01. Waiting.flac").write_bytes(b"x") + (nested / "02. Belief.flac").write_bytes(b"x") + (nested / "cover.jpg").write_bytes(b"x") # non-audio stays behind + + count = _flatten_audio(str(tmp_path)) + + assert count == 2 + assert (tmp_path / "01. Waiting.flac").exists() + assert (tmp_path / "02. Belief.flac").exists() + assert not (nested / "01. Waiting.flac").exists() # audio moved out of the nested folder + + +def test_flatten_counts_flat_files_and_ignores_non_audio(tmp_path): + (tmp_path / "a.flac").write_bytes(b"x") + (tmp_path / "b.mp3").write_bytes(b"x") + (tmp_path / "notes.txt").write_bytes(b"x") + assert _flatten_audio(str(tmp_path)) == 2 + + +def test_flatten_partial_download_reports_true_short_count(tmp_path): + # streamrip continues past per-track failures; only 3 of an album's tracks landed. + nested = tmp_path / "sub" + nested.mkdir() + for n in ["01.flac", "02.flac", "03.flac"]: + (nested / n).write_bytes(b"x") + assert _flatten_audio(str(tmp_path)) == 3 # not the album's metadata count + assert not nested.exists() # emptied subfolder removed