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:
1 parent
e59b4ad9f1
commit
53ffae1663
5 files changed
+182
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Warstwa audio: pozyskiwanie źródeł dźwięku (yt-dlp, pliki lokalne)."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user