diff --git a/worker/Dockerfile b/worker/Dockerfile index f75d665..4fe6b4a 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -1,5 +1,6 @@ FROM python:3.12-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY lyra_worker ./lyra_worker diff --git a/worker/lyra_worker/adapters/_ytdlp.py b/worker/lyra_worker/adapters/_ytdlp.py new file mode 100644 index 0000000..bfeff20 --- /dev/null +++ b/worker/lyra_worker/adapters/_ytdlp.py @@ -0,0 +1,56 @@ +import os +from typing import Callable + +import yt_dlp + + +class YtDlpClient: + """Real YtClient backed by yt_dlp. Not unit-tested offline; see test_youtube_live.py.""" + + def search_album(self, artist: str, album: str) -> list[dict]: + query = f"ytsearch5:{artist} {album} full album" + opts = { + "quiet": True, + "no_warnings": True, + "extract_flat": "in_playlist", + "skip_download": True, + } + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(query, download=False) + results: list[dict] = [] + for e in (info.get("entries") or []): + if not e: + continue + results.append( + { + "source_ref": e.get("url") or e.get("webpage_url") or e.get("id"), + "title": e.get("title") or "", + "artist": e.get("uploader") or e.get("channel") or "", + "track_count": e.get("playlist_count") or 1, + } + ) + return results + + def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict: + os.makedirs(dest, exist_ok=True) + + def hook(d: dict) -> None: + if d.get("status") == "downloading": + total = d.get("total_bytes") or d.get("total_bytes_estimate") + if total: + on_progress(min(1.0, d.get("downloaded_bytes", 0) / total)) + elif d.get("status") == "finished": + on_progress(1.0) + + opts = { + "quiet": True, + "no_warnings": True, + "format": "bestaudio/best", + "outtmpl": os.path.join(dest, "%(playlist_index)s-%(title)s.%(ext)s"), + "progress_hooks": [hook], + "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "opus"}], + } + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(source_ref, download=True) + entries = info.get("entries") if info.get("entries") is not None else [info] + return {"track_count": len([e for e in entries if e]), "path": dest} diff --git a/worker/requirements.txt b/worker/requirements.txt index 56c6792..3d62bf7 100644 --- a/worker/requirements.txt +++ b/worker/requirements.txt @@ -1,3 +1,4 @@ psycopg[binary]>=3.2,<4 pytest>=8.3,<9 cryptography>=42,<46 +yt-dlp>=2024.1 diff --git a/worker/tests/test_youtube_live.py b/worker/tests/test_youtube_live.py new file mode 100644 index 0000000..ab14b9a --- /dev/null +++ b/worker/tests/test_youtube_live.py @@ -0,0 +1,16 @@ +import os + +import pytest + +from lyra_worker.adapters._ytdlp import YtDlpClient + +pytestmark = pytest.mark.skipif( + not os.environ.get("LYRA_LIVE_TESTS"), + reason="live network test; set LYRA_LIVE_TESTS=1 to run", +) + + +def test_live_search_returns_results(): + results = YtDlpClient().search_album("Radiohead", "In Rainbows") + assert len(results) >= 1 + assert all(r["source_ref"] for r in results)