diff --git a/README.md b/README.md index 3af0653f..35d67866 100644 --- a/README.md +++ b/README.md @@ -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. -### _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 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: ``` -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! diff --git a/pyproject.toml b/pyproject.toml index a612f935..72e41907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # Remember to run `poetry update` editing this file! # 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] name = "royalnet" @@ -35,11 +35,6 @@ pynacl = { version = "^1.3.0", optional = true } # This requires libffi-dev and # matrix 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 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 @@ -74,7 +69,6 @@ discord = ["discord.py", "pynacl", "lavalink", "aiohttp", "cchardet"] matrix = ["matrix-nio"] alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"] alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"] -bard = ["ffmpeg_python", "youtube_dl", "eyed3"] constellation = ["starlette", "uvicorn", "python-multipart"] sentry = ["sentry_sdk"] herald = ["websockets"] diff --git a/royalnet/bard/README.md b/royalnet/bard/README.md deleted file mode 100644 index 2c64c054..00000000 --- a/royalnet/bard/README.md +++ /dev/null @@ -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] -``` diff --git a/royalnet/bard/__init__.py b/royalnet/bard/__init__.py deleted file mode 100644 index df06bf49..00000000 --- a/royalnet/bard/__init__.py +++ /dev/null @@ -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", -] diff --git a/royalnet/bard/discord/README.md b/royalnet/bard/discord/README.md deleted file mode 100644 index 32fb43dd..00000000 --- a/royalnet/bard/discord/README.md +++ /dev/null @@ -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] -``` \ No newline at end of file diff --git a/royalnet/bard/discord/__init__.py b/royalnet/bard/discord/__init__.py deleted file mode 100644 index 0ed70bfd..00000000 --- a/royalnet/bard/discord/__init__.py +++ /dev/null @@ -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", -] diff --git a/royalnet/bard/discord/fileaudiosource.py b/royalnet/bard/discord/fileaudiosource.py deleted file mode 100644 index b8f4363e..00000000 --- a/royalnet/bard/discord/fileaudiosource.py +++ /dev/null @@ -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 diff --git a/royalnet/bard/discord/ytdldiscord.py b/royalnet/bard/discord/ytdldiscord.py deleted file mode 100644 index bcd63999..00000000 --- a/royalnet/bard/discord/ytdldiscord.py +++ /dev/null @@ -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 diff --git a/royalnet/bard/errors.py b/royalnet/bard/errors.py deleted file mode 100644 index 1368a7cb..00000000 --- a/royalnet/bard/errors.py +++ /dev/null @@ -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.""" diff --git a/royalnet/bard/ytdlfile.py b/royalnet/bard/ytdlfile.py deleted file mode 100644 index 070b4e40..00000000 --- a/royalnet/bard/ytdlfile.py +++ /dev/null @@ -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 `_.""" - - 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 diff --git a/royalnet/bard/ytdlinfo.py b/royalnet/bard/ytdlinfo.py deleted file mode 100644 index a5a1fe01..00000000 --- a/royalnet/bard/ytdlinfo.py +++ /dev/null @@ -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 `_ 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"" - if self.webpage_url: - return f"" - return f"" - - def __str__(self): - if self.title: - return self.title - if self.webpage_url: - return self.webpage_url - return self.id diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index e618affb..0a8b1731 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -11,7 +11,6 @@ import royalnet.commands as rc from royalnet.serf import Serf from royalnet.utils import asyncify, sentry_exc from .escape import escape -from .voiceplayer import VoicePlayer log = logging.getLogger(__name__) @@ -51,9 +50,6 @@ class DiscordSerf(Serf): self.client = self.Client(status=discord.Status.do_not_disturb) """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() def data_factory(self) -> Type[rc.CommandData]: @@ -163,16 +159,3 @@ class DiscordSerf(Serf): except discord.ConnectionClosed: log.error("Discord connection was closed! Retrying in 15 seconds...") 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 diff --git a/royalnet/serf/discord/errors.py b/royalnet/serf/discord/errors.py index 8afe0cd9..7622a978 100644 --- a/royalnet/serf/discord/errors.py +++ b/royalnet/serf/discord/errors.py @@ -3,40 +3,3 @@ from ..errors import SerfError class DiscordSerfError(SerfError): """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 - ` 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.""" diff --git a/royalnet/serf/discord/playable.py b/royalnet/serf/discord/playable.py deleted file mode 100644 index de995842..00000000 --- a/royalnet/serf/discord/playable.py +++ /dev/null @@ -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 - `, 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() diff --git a/royalnet/serf/discord/voiceplayer.py b/royalnet/serf/discord/voiceplayer.py deleted file mode 100644 index 5e6cca9d..00000000 --- a/royalnet/serf/discord/voiceplayer.py +++ /dev/null @@ -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