From 53ffae166322fbfe0e9387524334cd2d99bacf82 Mon Sep 17 00:00:00 2001 From: lgoose Date: Fri, 21 Aug 2026 01:10:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(audio):=20wydzielenie=20warstwy=20pozy?= =?UTF-8?q?skiwania=20d=C5=BAwi=C4=99ku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ytdl.py rozbity na cztery pliki o rozłącznych rolach: - YtdlOptions opcje yt-dlp i FFmpeg, wcześniej moduły-globalne - MediaProbe odczyt długości pliku przez ffprobe - YTDLSource samo źródło audio - dane utworu i zliczanie odtworzonego czasu; dochodzą właściwości is_local, filepath, display_url i has_web_link, które zastępują powtarzane w kilku miejscach data.get("webpage_url") or url - TrackFactory budowanie źródeł z zapytania, adresu lub pliku Rozdzielenie źródła od sposobu jego tworzenia usuwa globalną instancję YoutubeDL i pozwala podmienić fabrykę w testach. Metoda recreate zbiera w jednym miejscu odtwarzanie utworu od nowa z zachowaniem jego tożsamości i pozostałej liczby odtworzeń. Co-Authored-By: Claude Opus 5 --- zyrarbot/audio/__init__.py | 1 + zyrarbot/audio/media_probe.py | 31 ++++++++++++++ zyrarbot/audio/track_factory.py | 74 +++++++++++++++++++++++++++++++++ zyrarbot/audio/ytdl_options.py | 30 +++++++++++++ zyrarbot/audio/ytdl_source.py | 46 ++++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 zyrarbot/audio/__init__.py create mode 100644 zyrarbot/audio/media_probe.py create mode 100644 zyrarbot/audio/track_factory.py create mode 100644 zyrarbot/audio/ytdl_options.py create mode 100644 zyrarbot/audio/ytdl_source.py diff --git a/zyrarbot/audio/__init__.py b/zyrarbot/audio/__init__.py new file mode 100644 index 0000000..d1e3b19 --- /dev/null +++ b/zyrarbot/audio/__init__.py @@ -0,0 +1 @@ +"""Warstwa audio: pozyskiwanie źródeł dźwięku (yt-dlp, pliki lokalne).""" diff --git a/zyrarbot/audio/media_probe.py b/zyrarbot/audio/media_probe.py new file mode 100644 index 0000000..7f9437a --- /dev/null +++ b/zyrarbot/audio/media_probe.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +class MediaProbe: + """Odczyt metadanych pliku multimedialnego przy pomocy ffprobe.""" + + async def duration(self, filepath: str) -> float: + try: + process = await asyncio.create_subprocess_exec( + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + filepath, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await process.communicate() + if process.returncode == 0: + return float(stdout.decode().strip()) + except (OSError, ValueError) as error: + logger.error("ffprobe nie odczytał długości pliku %s: %s", filepath, error) + return 0.0 diff --git a/zyrarbot/audio/track_factory.py b/zyrarbot/audio/track_factory.py new file mode 100644 index 0000000..7aeef96 --- /dev/null +++ b/zyrarbot/audio/track_factory.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +import os +from typing import Any + +import discord +import yt_dlp + +from zyrarbot.audio.media_probe import MediaProbe +from zyrarbot.audio.ytdl_options import YtdlOptions +from zyrarbot.audio.ytdl_source import YTDLSource +from zyrarbot.storage.uploads_repository import UploadsRepository + + +class TrackFactory: + """Buduje odtwarzalne źródła audio z zapytań, adresów URL i plików lokalnych.""" + + def __init__(self, uploads: UploadsRepository, probe: MediaProbe | None = None): + self._uploads = uploads + self._probe = probe or MediaProbe() + self._ytdl = yt_dlp.YoutubeDL(YtdlOptions.EXTRACTOR) + + async def resolve(self, query: str, *, loop: asyncio.AbstractEventLoop | None = None) -> YTDLSource: + """Rozpoznaje, czy zapytanie wskazuje wgrany plik, czy zasób sieciowy.""" + if not query.startswith("http") and self._uploads.exists(query): + return await self.from_file(self._uploads.resolve(query)) + return await self.from_url(query, loop=loop) + + async def from_url( + self, + url: str, + *, + loop: asyncio.AbstractEventLoop | None = None, + stream: bool = True, + ) -> YTDLSource: + loop = loop or asyncio.get_running_loop() + data = await loop.run_in_executor( + None, lambda: self._ytdl.extract_info(url, download=not stream) + ) + if "entries" in data: + data = data["entries"][0] + + filename = data["url"] if stream else self._ytdl.prepare_filename(data) + source = discord.FFmpegPCMAudio(filename, **YtdlOptions.STREAM_FFMPEG) + return YTDLSource(source, data=data) + + async def from_file(self, filepath: str, *, title: str | None = None) -> YTDLSource: + duration = await self._probe.duration(filepath) + data: dict[str, Any] = { + "title": title or os.path.splitext(os.path.basename(filepath))[0], + "url": filepath, + "webpage_url": None, + "duration": duration, + "is_local": True, + "filepath": filepath, + } + source = discord.FFmpegPCMAudio(filepath, **YtdlOptions.LOCAL_FFMPEG) + return YTDLSource(source, data=data) + + async def recreate( + self, + track: YTDLSource, + *, + loop: asyncio.AbstractEventLoop | None = None, + ) -> YTDLSource: + """Tworzy świeże źródło dla już odtworzonego utworu, zachowując jego tożsamość.""" + if track.is_local: + player = await self.from_file(track.filepath or "", title=track.title) + else: + player = await self.from_url(track.display_url or "", loop=loop) + player.id = track.id + player.plays_left = track.plays_left + return player diff --git a/zyrarbot/audio/ytdl_options.py b/zyrarbot/audio/ytdl_options.py new file mode 100644 index 0000000..cbb8103 --- /dev/null +++ b/zyrarbot/audio/ytdl_options.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any, Final + + +class YtdlOptions: + """Zbiór opcji dla yt-dlp oraz FFmpeg używanych przy tworzeniu źródeł audio.""" + + EXTRACTOR: Final[dict[str, Any]] = { + "format": "bestaudio/best", + "outtmpl": "%(extractor)s-%(id)s-%(title)s.%(ext)s", + "restrictfilenames": True, + "noplaylist": True, + "nocheckcertificate": True, + "ignoreerrors": False, + "logtostderr": False, + "quiet": True, + "no_warnings": True, + "default_search": "auto", + "source_address": "0.0.0.0", + } + + STREAM_FFMPEG: Final[dict[str, str]] = { + "options": "-vn", + "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", + } + + LOCAL_FFMPEG: Final[dict[str, str]] = { + "options": "-vn", + } diff --git a/zyrarbot/audio/ytdl_source.py b/zyrarbot/audio/ytdl_source.py new file mode 100644 index 0000000..1682784 --- /dev/null +++ b/zyrarbot/audio/ytdl_source.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import uuid +from typing import Any + +import discord + +FRAME_LENGTH_SECONDS = 0.02 + + +class YTDLSource(discord.PCMVolumeTransformer): + """Źródło audio (strumień YouTube lub plik lokalny) z metadanymi utworu.""" + + def __init__(self, source: discord.AudioSource, *, data: dict[str, Any], volume: float = 0.5): + super().__init__(source, volume) + self.data = data + self.title: str | None = data.get("title") + self.url: str | None = data.get("url") + self.id: str = uuid.uuid4().hex + self.duration: float | None = data.get("duration") + self.elapsed: float = 0.0 + self.plays_left: int | None = data.get("plays_left") + self.played: bool = False + + @property + def is_local(self) -> bool: + return bool(self.data.get("is_local", False)) + + @property + def filepath(self) -> str | None: + return self.data.get("filepath") + + @property + def display_url(self) -> str | None: + return self.data.get("webpage_url") or self.url + + @property + def has_web_link(self) -> bool: + url = self.display_url + return bool(url and str(url).startswith("http")) + + def read(self) -> bytes: + data = super().read() + if data: + self.elapsed += FRAME_LENGTH_SECONDS + return data