refactor(audio): wydzielenie warstwy pozyskiwania dźwięku

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 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:10:11 +02:00
1 parent e59b4ad9f1
commit 53ffae1663
5 files changed
+182

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Warstwa audio: pozyskiwanie źródeł dźwięku (yt-dlp, pliki lokalne)."""
+31
View File
@@ -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
+74
View File
@@ -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
+30
View File
@@ -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",
}
+46
View File
@@ -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