1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00

Nuke the bard

This commit is contained in:
Steffo 2020-08-18 04:01:02 +02:00
parent 096d8a18c4
commit 1081f11fc1
15 changed files with 2 additions and 786 deletions

View file

@ -42,12 +42,6 @@ The Constellation service is a [Starlette](https://www.starlette.io )-based webs
The Constellation service also offers utilities for creating REST APIs as Python functions with `dict`s as inputs and outputs, leaving (de)serialization, transmission and eventually authentication to Royalnet. The Constellation service also offers utilities for creating REST APIs as Python functions with `dict`s as inputs and outputs, leaving (de)serialization, transmission and eventually authentication to Royalnet.
### _Deprecated:_ [Bard](royalnet/bard)
The Bard module allows Royalnet services to safely download and convert video files through [youtube-dl](https://youtube-dl.org/) and [ffmpeg](https://ffmpeg.org/).
It is mainly used to playback music on Discord Channels.
### Sentry ### Sentry
Royalnet can automatically report uncaught errors in all services to a [Sentry](https://sentry.io )-compatible server, while logging them in the console in development environments to facilitate debugging. Royalnet can automatically report uncaught errors in all services to a [Sentry](https://sentry.io )-compatible server, while logging them in the console in development environments to facilitate debugging.
@ -104,7 +98,7 @@ cd royalnet
And finally install all dependencies and the package: And finally install all dependencies and the package:
``` ```
poetry install -E telegram -E discord -E matrix -E alchemy_easy -E bard -E constellation -E sentry -E herald -E coloredlogs poetry install -E telegram -E discord -E matrix -E alchemy_easy -E constellation -E sentry -E herald -E coloredlogs
``` ```
## Help! ## Help!

View file

@ -1,7 +1,7 @@
# Remember to run `poetry update` editing this file! # Remember to run `poetry update` editing this file!
# Install everything with # Install everything with
# poetry install -E telegram -E discord -E matrix -E alchemy_easy -E bard -E constellation -E sentry -E herald -E coloredlogs # poetry install -E telegram -E discord -E matrix -E alchemy_easy -E constellation -E sentry -E herald -E coloredlogs
[tool.poetry] [tool.poetry]
name = "royalnet" name = "royalnet"
@ -35,11 +35,6 @@ pynacl = { version = "^1.3.0", optional = true } # This requires libffi-dev and
# matrix # matrix
matrix-nio = { version = "^0.6", optional = true } matrix-nio = { version = "^0.6", optional = true }
# bard
ffmpeg_python = { version = "~0.2.0", optional = true }
youtube_dl = { version = "*", optional = true }
eyed3 = { version = "^0.9", optional = true }
# alchemy # alchemy
sqlalchemy = { version = "^1.3.18", optional = true } sqlalchemy = { version = "^1.3.18", optional = true }
psycopg2 = { version = "^2.8.4", optional = true } # Requires quite a bit of stuff http://initd.org/psycopg/docs/install.html#install-from-source psycopg2 = { version = "^2.8.4", optional = true } # Requires quite a bit of stuff http://initd.org/psycopg/docs/install.html#install-from-source
@ -74,7 +69,6 @@ discord = ["discord.py", "pynacl", "lavalink", "aiohttp", "cchardet"]
matrix = ["matrix-nio"] matrix = ["matrix-nio"]
alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"] alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"]
alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"] alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"]
bard = ["ffmpeg_python", "youtube_dl", "eyed3"]
constellation = ["starlette", "uvicorn", "python-multipart"] constellation = ["starlette", "uvicorn", "python-multipart"]
sentry = ["sentry_sdk"] sentry = ["sentry_sdk"]
herald = ["websockets"] herald = ["websockets"]

View file

@ -1,10 +0,0 @@
# `royalnet.bard`
The subpackage providing all classes related to music files.
It requires the `bard` extra to be installed (the [`ffmpeg_python`](https://pypi.org/project/ffmpeg-python/), [`youtube_dl`](https://pypi.org/project/youtube_dl/) and [`eyed3`](https://pypi.org/project/eyeD3/) packages).
You can install it with:
```
pip install royalnet[bard]
```

View file

@ -1,29 +0,0 @@
"""The subpackage providing all classes related to music files.
It requires the ``bard`` extra to be installed (the :mod:`ffmpeg_python`, :mod:`youtube_dl` and :mod:`eyed3` packages).
You can install it with: ::
pip install royalnet[bard]
"""
try:
import ffmpeg
import youtube_dl
import eyed3
except ImportError:
raise ImportError("The `bard` extra is not installed. Please install it with `pip install royalnet[bard]`.")
from .errors import BardError, YtdlError, NotFoundError, MultipleFilesError
from .ytdlfile import YtdlFile
from .ytdlinfo import YtdlInfo
__all__ = [
"YtdlInfo",
"YtdlFile",
"BardError",
"YtdlError",
"NotFoundError",
"MultipleFilesError",
]

View file

@ -1,10 +0,0 @@
# `royalnet.bard.discord`
The subpackage providing all functions and classes related to music playback on Discord.
It requires the both the ``bard`` and ``discord`` extras to be installed.
You can install them with:
```
pip install royalnet[bard,discord]
```

View file

@ -1,17 +0,0 @@
"""The subpackage providing all functions and classes related to music playback on Discord.
It requires the both the ``bard`` and ``discord`` extras to be installed.
You can install them with: ::
pip install royalnet[bard,discord]
"""
from .fileaudiosource import FileAudioSource
from .ytdldiscord import YtdlDiscord
__all__ = [
"YtdlDiscord",
"FileAudioSource",
]

View file

@ -1,47 +0,0 @@
import discord
class FileAudioSource(discord.AudioSource):
"""A :py:class:`discord.AudioSource` that uses a :py:class:`io.BufferedIOBase` as an input instead of memory.
The stream should be in the usual PCM encoding.
Warning:
This AudioSource will consume (and close) the passed stream."""
def __init__(self, file):
"""Create a FileAudioSource.
Arguments:
file: the file to be played back."""
self.file = file
self._stopped = False
def __repr__(self):
if self.file.seekable():
return f"<{self.__class__.__name__} @{self.file.tell()}>"
else:
return f"<{self.__class__.__name__}>"
def is_opus(self):
"""This audio file isn't Opus-encoded, but PCM-encoded.
Returns:
``False``."""
return False
def stop(self):
"""Stop the FileAudioSource. Once stopped, a FileAudioSource will immediatly stop reading more bytes from the
file."""
self._stopped = True
def read(self):
"""Reads 20ms of audio.
If the stream has ended, then return an empty :py:class:`bytes`-like object."""
data: bytes = self.file.read(discord.opus.Encoder.FRAME_SIZE)
# If there is no more data to be streamed
if self._stopped or len(data) != discord.opus.Encoder.FRAME_SIZE:
# Return that the stream has ended
return b""
return data

View file

@ -1,121 +0,0 @@
import logging
import os
import re
import typing
from contextlib import asynccontextmanager
import discord
import ffmpeg
from royalnet.bard import YtdlInfo, YtdlFile
from royalnet.utils import asyncify, MultiLock
from .fileaudiosource import FileAudioSource
log = logging.getLogger(__name__)
class YtdlDiscord:
"""A representation of a :class:`YtdlFile` conversion to the :mod:`discord` PCM format."""
def __init__(self, ytdl_file: YtdlFile):
self.ytdl_file: YtdlFile = ytdl_file
self.pcm_filename: typing.Optional[str] = None
self.lock: MultiLock = MultiLock()
def __repr__(self):
if not self.ytdl_file.has_info:
return f"<{self.__class__.__qualname__} without info>"
elif not self.ytdl_file.is_downloaded:
return f"<{self.__class__.__qualname__} not downloaded>"
elif not self.is_converted:
return f"<{self.__class__.__qualname__} at '{self.ytdl_file.filename}' not converted>"
else:
return f"<{self.__class__.__qualname__} at '{self.pcm_filename}'>"
@property
def is_converted(self):
"""Has the file been converted?"""
return self.pcm_filename is not None
async def convert_to_pcm(self) -> None:
"""Convert the file to pcm with :mod:`ffmpeg`."""
await self.ytdl_file.download_file()
if self.pcm_filename is None:
async with self.ytdl_file.lock.normal():
destination_filename = re.sub(r"\.[^.]+$", ".pcm", self.ytdl_file.filename)
async with self.lock.exclusive():
log.debug(f"Converting to PCM: {self.ytdl_file.filename}")
out, err = await asyncify(
ffmpeg.input(self.ytdl_file.filename)
.output(destination_filename, format="s16le", ac=2, ar="48000")
.overwrite_output()
.run,
capture_stdout=True,
capture_stderr=True,
)
log.debug(f"ffmpeg returned: ({type(out)}, {type(err)})")
self.pcm_filename = destination_filename
async def delete_asap(self) -> None:
"""Delete the mp3 file."""
log.debug(f"Trying to delete: {self}")
if self.is_converted:
async with self.lock.exclusive():
os.remove(self.pcm_filename)
log.debug(f"Deleted: {self.pcm_filename}")
self.pcm_filename = None
@classmethod
async def from_url(cls, url, **ytdl_args) -> typing.List["YtdlDiscord"]:
"""Create a :class:`list` of :class:`YtdlMp3` from a URL."""
files = await YtdlFile.from_url(url, **ytdl_args)
dfiles = []
for file in files:
dfile = YtdlDiscord(file)
dfiles.append(dfile)
return dfiles
@property
def info(self) -> typing.Optional[YtdlInfo]:
"""Shortcut to get the :class:`YtdlInfo` of the object."""
return self.ytdl_file.info
@asynccontextmanager
async def spawn_audiosource(self):
log.debug(f"Spawning audio_source for: {self}")
await self.convert_to_pcm()
async with self.lock.normal():
with open(self.pcm_filename, "rb") as stream:
fas = FileAudioSource(stream)
yield fas
def embed(self) -> "discord.Embed":
"""Return this info as a :py:class:`discord.Embed`."""
colors = {
"youtube": 0xCC0000,
"soundcloud": 0xFF5400,
"Clyp": 0x3DBEB3,
"Bandcamp": 0x1DA0C3,
"PeerTube": 0xF1680D,
"generic": 0x4F545C,
}
embed = discord.Embed(title=self.info.title,
colour=discord.Colour(colors.get(self.info.extractor, 0x4F545C)),
url=self.info.webpage_url if (self.info.webpage_url and self.info.webpage_url.startswith(
"http")) else discord.embeds.EmptyEmbed)
if self.info.thumbnail:
embed.set_thumbnail(url=self.info.thumbnail)
if self.info.uploader:
embed.set_author(name=self.info.uploader,
url=self.info.uploader_url if self.info.uploader_url is not None else discord.embeds.EmptyEmbed)
elif self.info.artist:
embed.set_author(name=self.info.artist,
url=discord.embeds.EmptyEmbed)
if self.info.album:
embed.add_field(name="Album", value=self.info.album, inline=True)
if self.info.duration:
embed.add_field(name="Duration", value=str(self.info.duration), inline=True)
if self.info.extractor != "generic" and self.info.upload_date:
embed.add_field(name="Published on", value=self.info.upload_date.strftime("%d %b %Y"), inline=True)
# embed.set_footer(text="Source: youtube-dl", icon_url="https://i.imgur.com/TSvSRYn.png")
return embed

View file

@ -1,14 +0,0 @@
class BardError(Exception):
"""Base class for :mod:`bard` errors."""
class YtdlError(BardError):
"""Base class for errors caused by :mod:`youtube_dl`."""
class NotFoundError(YtdlError):
"""The requested resource wasn't found."""
class MultipleFilesError(YtdlError):
"""The resource contains multiple media files."""

View file

@ -1,150 +0,0 @@
import logging
import os
import re
from asyncio import AbstractEventLoop, get_event_loop
from contextlib import asynccontextmanager
from typing import *
import eyed3
from youtube_dl import YoutubeDL
from royalnet.utils import *
from .errors import NotFoundError, MultipleFilesError
from .ytdlinfo import YtdlInfo
log = logging.getLogger(__name__)
class YtdlFile:
"""A representation of a file download with `youtube_dl <https://ytdl-org.github.io/youtube-dl/index.html>`_."""
default_ytdl_args = {
"quiet": not __debug__, # Do not print messages to stdout.
"noplaylist": True, # Download single video instead of a playlist if in doubt.
"no_warnings": not __debug__, # Do not print out anything for warnings.
"outtmpl": "./downloads/%(epoch)s-%(title)s-%(id)s.%(ext)s", # Use the default outtmpl.
"ignoreerrors": True # Ignore unavailable videos
}
def __init__(self,
url: str,
info: Optional[YtdlInfo] = None,
filename: Optional[str] = None,
ytdl_args: Optional[Dict[str, Any]] = None,
loop: Optional[AbstractEventLoop] = None):
"""Create a :class:`YtdlFile` instance.
Warning:
Please avoid using directly :meth:`.__init__`, use :meth:`.from_url` instead!"""
self.url: str = url
self.info: Optional[YtdlInfo] = info
self.filename: Optional[str] = filename
self.ytdl_args: Dict[str, Any] = {**self.default_ytdl_args, **ytdl_args}
self.lock: MultiLock = MultiLock()
if not loop:
loop = get_event_loop()
self._loop = loop
def __repr__(self):
if not self.has_info:
return f"<{self.__class__.__qualname__} without info>"
elif not self.is_downloaded:
return f"<{self.__class__.__qualname__} not downloaded>"
else:
return f"<{self.__class__.__qualname__} at '{self.filename}'>"
@property
def has_info(self) -> bool:
"""Does the :class:`YtdlFile` have info available?"""
return self.info is not None
async def retrieve_info(self) -> None:
"""Retrieve info about the :class:`YtdlFile` through :class:`YoutubeDL`."""
if not self.has_info:
infos = await asyncify(YtdlInfo.from_url, self.url, loop=self._loop, **self.ytdl_args)
if len(infos) == 0:
raise NotFoundError()
elif len(infos) > 1:
raise MultipleFilesError()
self.info = infos[0]
@property
def is_downloaded(self) -> bool:
"""Has the file been downloaded yet?"""
return self.filename is not None
async def download_file(self) -> None:
"""Download the file."""
def download():
"""Download function block to be asyncified."""
with YoutubeDL(self.ytdl_args) as ytdl:
filename = ytdl.prepare_filename(self.info.__dict__)
with YoutubeDL({**self.ytdl_args, "outtmpl": filename}) as ytdl:
ytdl.download([self.info.webpage_url])
# "WARNING: Requested formats are incompatible for merge and will be merged into mkv."
if not os.path.exists(filename):
filename = re.sub(r"\.[^.]+$", ".mkv", filename)
self.filename = filename
await self.retrieve_info()
if not self.is_downloaded:
async with self.lock.exclusive():
log.debug(f"Downloading with youtube-dl: {self}")
await asyncify(download, loop=self._loop)
if self.info.extractor == "generic":
log.debug(f"Generic extractor detected, updating info from the downloaded file: {self}")
self.set_ytdlinfo_from_id3_tags()
def set_ytdlinfo_from_id3_tags(self):
tag_file = eyed3.load(self.filename)
if not tag_file:
log.debug(f"No ID3 tags found: {self}")
return
tag: eyed3.core.Tag = tag_file.tag
if tag.title:
log.debug(f"Found title: {self}")
self.info.title = tag.title
if tag.album:
log.debug(f"Found album: {self}")
self.info.album = tag.album
if tag.artist:
log.debug(f"Found artist: {self}")
self.info.artist = tag.artist
@asynccontextmanager
async def aopen(self):
"""Open the downloaded file as an async context manager (and download it if it isn't available yet).
Example:
You can use the async context manager like this: ::
async with ytdlfile.aopen() as file:
b: bytes = file.read()
"""
await self.download_file()
async with self.lock.normal():
log.debug(f"File opened: {self.filename}")
with open(self.filename, "rb") as file:
yield file
log.debug(f"File closed: {self.filename}")
async def delete_asap(self):
"""As soon as nothing is using the file, delete it."""
log.debug(f"Trying to delete: {self.filename}")
if self.filename is not None:
async with self.lock.exclusive():
os.remove(self.filename)
log.debug(f"Deleted: {self.filename}")
self.filename = None
@classmethod
async def from_url(cls, url: str, **ytdl_args) -> List["YtdlFile"]:
"""Create a :class:`list` of :class:`YtdlFile` from a URL."""
infos = await YtdlInfo.from_url(url, **ytdl_args)
files = []
for info in infos:
file = YtdlFile(url=info.webpage_url, info=info, ytdl_args=ytdl_args)
files.append(file)
return files

View file

@ -1,132 +0,0 @@
import asyncio as aio
import datetime
import logging
from typing import *
import dateparser
import youtube_dl
import royalnet.utils as ru
log = logging.getLogger(__name__)
class YtdlInfo:
"""A wrapper around `youtube_dl <https://ytdl-org.github.io/youtube-dl/index.html>`_ extracted info."""
_default_ytdl_args = {
"quiet": True, # Do not print messages to stdout.
"noplaylist": True, # Download single video instead of a playlist if in doubt.
"no_warnings": True, # Do not print out anything for warnings.
"outtmpl": "./downloads/%(epoch)s-%(title)s-%(id)s.%(ext)s", # Use the default outtmpl.
"ignoreerrors": True # Ignore unavailable videos
}
def __init__(self, info: Dict[str, Any]):
"""Create a :class:`YtdlInfo` from the dict returned by the :func:`YoutubeDL.extract_info` function.
Warning:
Does not download the info, to do that use :func:`.retrieve_for_url`."""
self.id: Optional[str] = info.get("id")
self.uploader: Optional[str] = info.get("uploader")
self.uploader_id: Optional[str] = info.get("uploader_id")
self.uploader_url: Optional[str] = info.get("uploader_url")
self.channel_id: Optional[str] = info.get("channel_id")
self.channel_url: Optional[str] = info.get("channel_url")
self.upload_date: Optional[datetime.datetime] = dateparser.parse(ru.ytdldateformat(info.get("upload_date")))
self.license: Optional[str] = info.get("license")
self.creator: Optional[...] = info.get("creator")
self.title: Optional[str] = info.get("title")
self.alt_title: Optional[...] = info.get("alt_title")
self.thumbnail: Optional[str] = info.get("thumbnail")
self.description: Optional[str] = info.get("description")
self.categories: Optional[List[str]] = info.get("categories")
self.tags: Optional[List[str]] = info.get("tags")
self.subtitles: Optional[Dict[str, List[Dict[str, str]]]] = info.get("subtitles")
self.automatic_captions: Optional[dict] = info.get("automatic_captions")
self.duration: Optional[datetime.timedelta] = datetime.timedelta(seconds=info.get("duration", 0))
self.age_limit: Optional[int] = info.get("age_limit")
self.annotations: Optional[...] = info.get("annotations")
self.chapters: Optional[...] = info.get("chapters")
self.webpage_url: Optional[str] = info.get("webpage_url")
self.view_count: Optional[int] = info.get("view_count")
self.like_count: Optional[int] = info.get("like_count")
self.dislike_count: Optional[int] = info.get("dislike_count")
self.average_rating: Optional[...] = info.get("average_rating")
self.formats: Optional[list] = info.get("formats")
self.is_live: Optional[bool] = info.get("is_live")
self.start_time: Optional[float] = info.get("start_time")
self.end_time: Optional[float] = info.get("end_time")
self.series: Optional[str] = info.get("series")
self.season_number: Optional[int] = info.get("season_number")
self.episode_number: Optional[int] = info.get("episode_number")
self.track: Optional[...] = info.get("track")
self.artist: Optional[...] = info.get("artist")
self.extractor: Optional[str] = info.get("extractor")
self.webpage_url_basename: Optional[str] = info.get("webpage_url_basename")
self.extractor_key: Optional[str] = info.get("extractor_key")
self.playlist: Optional[str] = info.get("playlist")
self.playlist_index: Optional[int] = info.get("playlist_index")
self.thumbnails: Optional[List[Dict[str, str]]] = info.get("thumbnails")
self.display_id: Optional[str] = info.get("display_id")
self.requested_subtitles: Optional[...] = info.get("requested_subtitles")
self.requested_formats: Optional[tuple] = info.get("requested_formats")
self.format: Optional[str] = info.get("format")
self.format_id: Optional[str] = info.get("format_id")
self.width: Optional[int] = info.get("width")
self.height: Optional[int] = info.get("height")
self.resolution: Optional[...] = info.get("resolution")
self.fps: Optional[int] = info.get("fps")
self.vcodec: Optional[str] = info.get("vcodec")
self.vbr: Optional[int] = info.get("vbr")
self.stretched_ratio: Optional[...] = info.get("stretched_ratio")
self.acodec: Optional[str] = info.get("acodec")
self.abr: Optional[int] = info.get("abr")
self.ext: Optional[str] = info.get("ext")
# Additional custom information
self.album: Optional[str] = None
@classmethod
async def from_url(cls, url, loop: Optional[aio.AbstractEventLoop] = None, **ytdl_args) -> List["YtdlInfo"]:
"""Fetch the info for an url through :class:`YoutubeDL`.
Returns:
A :class:`list` containing the infos for the requested videos."""
if loop is None:
loop: aio.AbstractEventLoop = aio.get_event_loop()
# So many redundant options!
log.debug(f"Fetching info: {url}")
with youtube_dl.YoutubeDL({**cls._default_ytdl_args, **ytdl_args}) as ytdl:
first_info = await ru.asyncify(ytdl.extract_info, loop=loop, url=url, download=False)
# No video was found
if first_info is None:
return []
# If it is a playlist, create multiple videos!
if "entries" in first_info:
if len(first_info["entries"]) == 0:
return []
if first_info["entries"][0] is None:
return []
log.debug(f"Found a playlist: {url}")
second_info_list = []
for second_info in first_info["entries"]:
if second_info is None:
continue
second_info_list.append(YtdlInfo(second_info))
return second_info_list
log.debug(f"Found a single video: {url}")
return [YtdlInfo(first_info)]
def __repr__(self):
if self.title:
return f"<YtdlInfo of '{self.title}'>"
if self.webpage_url:
return f"<YtdlInfo for '{self.webpage_url}'>"
return f"<YtdlInfo id={self.id} ...>"
def __str__(self):
if self.title:
return self.title
if self.webpage_url:
return self.webpage_url
return self.id

View file

@ -11,7 +11,6 @@ import royalnet.commands as rc
from royalnet.serf import Serf from royalnet.serf import Serf
from royalnet.utils import asyncify, sentry_exc from royalnet.utils import asyncify, sentry_exc
from .escape import escape from .escape import escape
from .voiceplayer import VoicePlayer
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -51,9 +50,6 @@ class DiscordSerf(Serf):
self.client = self.Client(status=discord.Status.do_not_disturb) self.client = self.Client(status=discord.Status.do_not_disturb)
"""The custom :class:`discord.Client` instance.""" """The custom :class:`discord.Client` instance."""
self.voice_players: List[VoicePlayer] = []
"""A :class:`list` of the :class:`VoicePlayer` in use by this :class:`DiscordSerf`."""
self.Data: Type[rc.CommandData] = self.data_factory() self.Data: Type[rc.CommandData] = self.data_factory()
def data_factory(self) -> Type[rc.CommandData]: def data_factory(self) -> Type[rc.CommandData]:
@ -163,16 +159,3 @@ class DiscordSerf(Serf):
except discord.ConnectionClosed: except discord.ConnectionClosed:
log.error("Discord connection was closed! Retrying in 15 seconds...") log.error("Discord connection was closed! Retrying in 15 seconds...")
await aio.sleep(60) await aio.sleep(60)
def find_voice_players(self, guild: "discord.Guild") -> List[VoicePlayer]:
candidate_players: List[VoicePlayer] = []
for player in self.voice_players:
player: VoicePlayer
if not player.voice_client.is_connected():
continue
if guild is not None and guild != player.voice_client.guild:
continue
candidate_players.append(player)
if guild:
assert len(candidate_players) <= 1
return candidate_players

View file

@ -3,40 +3,3 @@ from ..errors import SerfError
class DiscordSerfError(SerfError): class DiscordSerfError(SerfError):
"""Base class for all :mod:`royalnet.serf.discord` errors.""" """Base class for all :mod:`royalnet.serf.discord` errors."""
class VoicePlayerError(DiscordSerfError):
"""Base class for all :class:`VoicePlayer` errors."""
class AlreadyConnectedError(VoicePlayerError):
"""Base class for the "Already Connected" errors."""
class PlayerAlreadyConnectedError(AlreadyConnectedError):
"""The :class:`VoicePlayer` is already connected to voice.
Access the :class:`discord.VoiceClient` through :attr:`VoicePlayer.voice_client`!"""
class GuildAlreadyConnectedError(AlreadyConnectedError):
"""The :class:`discord.Client` is already connected to voice in a channel of this guild."""
class OpusNotLoadedError(VoicePlayerError):
"""The Opus library hasn't been loaded `as required
<https://discordpy.readthedocs.io/en/latest/api.html#discord.VoiceClient>` by :mod:`discord`."""
class DiscordTimeoutError(VoicePlayerError):
"""The websocket didn't get a response from the Discord voice servers in time."""
class PlayerNotConnectedError(VoicePlayerError):
"""The :class:`VoicePlayer` isn't connected to the Discord voice servers.
Use :meth:`VoicePlayer.connect` first!"""
class PlayerAlreadyPlayingError(VoicePlayerError):
"""The :class:`VoicePlayer` is already playing audio and cannot start playing audio again."""

View file

@ -1,66 +0,0 @@
import logging
from typing import Optional, AsyncGenerator, Tuple, Any, Dict
try:
import discord
except ImportError:
discord = None
log = logging.getLogger(__name__)
class Playable:
"""An abstract class representing something that can be played back in a :class:`VoicePlayer`."""
def __init__(self):
"""Create a :class:`Playable`.
Warning:
Avoid using this method, as it does not initialize the generator! Use :meth:`.create` instead."""
log.debug("Creating a Playable...")
self.generator: Optional[AsyncGenerator[Optional["discord.AudioSource"],
Tuple[Tuple[Any, ...], Dict[str, Any]]]] = self._generator()
# PyCharm doesn't like what I'm doing here.
# noinspection PyTypeChecker
@classmethod
async def create(cls):
"""Create a :class:`Playable` and initialize its generator."""
playable = cls()
log.debug("Sending None to the generator...")
await playable.generator.asend(None)
log.debug("Playable ready!")
return playable
async def next(self, *args, **kwargs) -> Optional["discord.AudioSource"]:
"""Get the next :class:`discord.AudioSource` that should be played.
Called when the :class:`Playable` is first attached to a :class:`VoicePlayer` and when a
:class:`discord.AudioSource` stops playing.
Args and kwargs can be used to pass data to the generator.
Returns:
:const:`None` if there is nothing available to play, otherwise the :class:`discord.AudioSource` that should
be played.
"""
log.debug("Getting next AudioSource...")
audio_source: Optional["discord.AudioSource"] = await self.generator.asend((args, kwargs,))
log.debug(f"Next: {audio_source}")
return audio_source
async def _generator(self) \
-> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]:
"""Create an async generator that returns the next source to be played;
it can take a args+kwargs tuple in input to optionally select a different source.
Note:
For `weird Python reasons
<https://www.python.org/dev/peps/pep-0525/#support-for-asynchronous-iteration-protocol>`, the generator
should ``yield`` once before doing anything else."""
yield
raise NotImplementedError()
async def destroy(self):
"""Clean up the Playable, as it is about to be replaced or deleted."""
raise NotImplementedError()

View file

@ -1,122 +0,0 @@
import asyncio
import logging
import threading
from typing import Optional
from .errors import *
from .playable import Playable
from ...utils import sentry_exc
try:
import discord
except ImportError:
discord = None
log = logging.getLogger(__name__)
class VoicePlayer:
def __init__(self, *, loop: Optional[asyncio.AbstractEventLoop] = None):
self.voice_client: Optional["discord.VoiceClient"] = None
self.playing: Optional[Playable] = None
if loop is None:
self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
else:
self.loop = loop
self._playback_ended_event: threading.Event = threading.Event()
async def connect(self, channel: "discord.VoiceChannel") -> "discord.VoiceClient":
"""Connect the :class:`VoicePlayer` to a :class:`discord.VoiceChannel`, creating a :class:`discord.VoiceClient`
that handles the connection.
Args:
channel: The :class:`discord.VoiceChannel` to connect into.
Returns:
The created :class:`discord.VoiceClient`.
(It will be stored in :attr:`VoicePlayer.voice_client` anyways!)
Raises:
PlayerAlreadyConnectedError:
DiscordTimeoutError:
GuildAlreadyConnectedError:
OpusNotLoadedError:
"""
if self.voice_client is not None and self.voice_client.is_connected():
raise PlayerAlreadyConnectedError()
log.debug(f"Connecting to: {channel}")
try:
self.voice_client = await channel.connect(reconnect=False, timeout=3)
except asyncio.TimeoutError:
raise DiscordTimeoutError()
except discord.ClientException:
raise GuildAlreadyConnectedError()
except discord.opus.OpusNotLoaded:
raise OpusNotLoadedError()
return self.voice_client
async def disconnect(self) -> None:
"""Disconnect the :class:`VoicePlayer` from the channel where it is currently connected, and set
:attr:`.voice_client` to :const:`None`.
Raises:
PlayerNotConnectedError:
"""
if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError()
log.debug(f"Disconnecting...")
await self.voice_client.disconnect(force=True)
self.voice_client = None
async def move(self, channel: "discord.VoiceChannel"):
"""Move the :class:`VoicePlayer` to a different channel.
This requires the :class:`VoicePlayer` to already be connected, and for the passed :class:`discord.VoiceChannel`
to be in the same :class:`discord.Guild` of the :class:`VoicePlayer`."""
if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError()
if self.voice_client.guild != channel.guild:
raise ValueError("Can't move between two guilds.")
log.debug(f"Moving to: {channel}")
await self.voice_client.move_to(channel)
async def start(self):
"""Start playing music on the :class:`discord.VoiceClient`.
Info:
Doesn't pass any ``*args`` or ``**kwargs`` to the :class:`Playable`.
"""
if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError()
if self.voice_client.is_playing():
raise PlayerAlreadyPlayingError()
log.debug("Getting next AudioSource...")
next_source: Optional["discord.AudioSource"] = await self.playing.next()
if next_source is None:
log.debug(f"Next source would be None, stopping here...")
return
log.debug(f"Next: {next_source}")
try:
self.voice_client.play(next_source, after=self._playback_ended)
except discord.ClientException as e:
sentry_exc(e)
await self.disconnect()
self.loop.create_task(self._playback_check())
async def _playback_check(self):
while True:
if self._playback_ended_event.is_set():
self._playback_ended_event.clear()
await self.start()
break
await asyncio.sleep(1)
def _playback_ended(self, error=None):
if error is not None:
sentry_exc(error)
return
self._playback_ended_event.set()
async def change_playing(self, value: Playable):
await self.playing.destroy()
self.playing = value