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:
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.
|
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!
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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.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
|
|
||||||
|
|
|
@ -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."""
|
|
||||||
|
|
|
@ -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