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