fix(worker): run streamrip on one persistent event loop (fixes Qobuz download failures)

Each search/download called asyncio.run, creating and closing a fresh event loop.
streamrip's module-global download-concurrency Semaphore binds to the loop on first
use, so the 2nd+ download reused a Semaphore bound to a closed loop -> "Semaphore is
bound to a different event loop", tracks failed, album arrived short -> "incomplete
download" / needs_attention. Run all streamrip coroutines on one persistent loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-13 23:04:25 +02:00
parent fdca692bb5
commit 53c0dcc256
2 changed files with 34 additions and 2 deletions
+17 -2
View File
@@ -11,6 +11,21 @@ def _hashed_password(password: str) -> str:
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
# streamrip holds module-global asyncio primitives (a download-concurrency Semaphore) that
# bind to the running event loop on first use. `asyncio.run` creates a NEW loop per call and
# closes it, orphaning that Semaphore, so the 2nd+ download fails with "Semaphore ... is bound
# to a different event loop" and its tracks silently fail (-> incomplete download). Running every
# streamrip coroutine on ONE persistent loop keeps those globals valid across downloads. The
# worker processes one job at a time, so a single shared loop is safe.
_loop: "asyncio.AbstractEventLoop | None" = None
def _run(coro):
global _loop
if _loop is None or _loop.is_closed():
_loop = asyncio.new_event_loop()
return _loop.run_until_complete(coro)
def _flatten_audio(dest: str) -> int:
"""streamrip writes into a nested `Artist - Album [...]/` subfolder. Move any audio
@@ -77,7 +92,7 @@ class StreamripClient:
return cfg
def search_album(self, artist: str, album: str) -> list[dict]:
return asyncio.run(self._search(f"{artist} {album}"))
return _run(self._search(f"{artist} {album}"))
async def _search(self, query: str) -> list[dict]:
from streamrip.client import QobuzClient
@@ -107,7 +122,7 @@ class StreamripClient:
return results
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict:
asyncio.run(self._download(source_ref, dest, on_progress))
_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}