mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-23 11:34:18 +00:00
Nuke the bard
This commit is contained in:
parent
096d8a18c4
commit
1081f11fc1
15 changed files with 2 additions and 786 deletions
|
@ -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!
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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]
|
||||
```
|
|
@ -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",
|
||||
]
|
|
@ -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]
|
||||
```
|
|
@ -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",
|
||||
]
|
|
@ -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
|
|
@ -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
|
|
@ -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."""
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
<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."""
|
||||
|
|
|
@ -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()
|
|
@ -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
|
Loading…
Reference in a new issue