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) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-11 01:01:23 +02:00
parent 00b3dddca6
commit c75b9d3562
3 changed files with 73 additions and 4 deletions
+4
View File
@@ -35,6 +35,10 @@ services:
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} 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: volumes:
- ${MUSIC_DIR:-./music}:/music - ${MUSIC_DIR:-./music}:/music
depends_on: depends_on:
+36 -4
View File
@@ -9,6 +9,37 @@ def _hashed_password(password: str) -> str:
return hashlib.md5(password.encode("utf-8")).hexdigest() 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: class StreamripClient:
"""Real Qobuz client backed by streamrip (v2, async → sync via asyncio.run). """Real Qobuz client backed by streamrip (v2, async → sync via asyncio.run).
@@ -76,9 +107,12 @@ class StreamripClient:
return results return results
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict: 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) os.makedirs(dest, exist_ok=True)
from streamrip.client import QobuzClient from streamrip.client import QobuzClient
from streamrip.db import Database, Dummy from streamrip.db import Database, Dummy
@@ -98,8 +132,6 @@ class StreamripClient:
if album is None: if album is None:
raise RuntimeError(f"could not resolve Qobuz album {album_id}") raise RuntimeError(f"could not resolve Qobuz album {album_id}")
await album.rip() await album.rip()
track_count = len(getattr(album, "tracks", []) or [])
on_progress(1.0) on_progress(1.0)
finally: finally:
await client.session.close() await client.session.close()
return {"track_count": track_count, "path": dest}
+33
View File
@@ -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