Merge V3/feature/audio into V3/develop (a.k.a. audio refactor) (#3459)

This commit is contained in:
Draper 2020-05-20 21:30:06 +01:00 committed by GitHub
parent ef76affd77
commit 8fa47cb789
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 12372 additions and 10144 deletions

View File

@ -1,8 +1,9 @@
from redbot.core import commands
from redbot.core.bot import Red
from .audio import Audio
from .core import Audio
def setup(bot: commands.Bot):
def setup(bot: Red):
cog = Audio(bot)
bot.add_cog(cog)
cog.start_up_task()

View File

@ -0,0 +1,10 @@
from . import (
api_utils,
global_db,
interface,
local_db,
playlist_interface,
playlist_wrapper,
spotify,
youtube,
)

View File

@ -0,0 +1,140 @@
import datetime
import json
import logging
from collections import namedtuple
from dataclasses import dataclass, field
from typing import List, MutableMapping, Optional, Union
import discord
from redbot.core.bot import Red
from redbot.core.utils.chat_formatting import humanize_list
from ..errors import InvalidPlaylistScope, MissingAuthor, MissingGuild
from ..utils import PlaylistScope
log = logging.getLogger("red.cogs.Audio.api.utils")
@dataclass
class YouTubeCacheFetchResult:
query: Optional[str]
last_updated: int
def __post_init__(self):
if isinstance(self.last_updated, int):
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
@dataclass
class SpotifyCacheFetchResult:
query: Optional[str]
last_updated: int
def __post_init__(self):
if isinstance(self.last_updated, int):
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
@dataclass
class LavalinkCacheFetchResult:
query: Optional[MutableMapping]
last_updated: int
def __post_init__(self):
if isinstance(self.last_updated, int):
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
if isinstance(self.query, str):
self.query = json.loads(self.query)
@dataclass
class LavalinkCacheFetchForGlobalResult:
query: str
data: MutableMapping
def __post_init__(self):
if isinstance(self.data, str):
self.data_string = str(self.data)
self.data = json.loads(self.data)
@dataclass
class PlaylistFetchResult:
playlist_id: int
playlist_name: str
scope_id: int
author_id: int
playlist_url: Optional[str] = None
tracks: List[MutableMapping] = field(default_factory=lambda: [])
def __post_init__(self):
if isinstance(self.tracks, str):
self.tracks = json.loads(self.tracks)
def standardize_scope(scope: str) -> str:
"""Convert any of the used scopes into one we are expecting"""
scope = scope.upper()
valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"]
if scope in PlaylistScope.list():
return scope
elif scope not in valid_scopes:
raise InvalidPlaylistScope(
f'"{scope}" is not a valid playlist scope.'
f" Scope needs to be one of the following: {humanize_list(valid_scopes)}"
)
if scope in ["GLOBAL", "BOT"]:
scope = PlaylistScope.GLOBAL.value
elif scope in ["GUILD", "SERVER"]:
scope = PlaylistScope.GUILD.value
elif scope in ["USER", "MEMBER", "AUTHOR"]:
scope = PlaylistScope.USER.value
return scope
def prepare_config_scope(
bot: Red,
scope,
author: Union[discord.abc.User, int] = None,
guild: Union[discord.Guild, int] = None,
):
"""Return the scope used by Playlists"""
scope = standardize_scope(scope)
if scope == PlaylistScope.GLOBAL.value:
config_scope = [PlaylistScope.GLOBAL.value, bot.user.id]
elif scope == PlaylistScope.USER.value:
if author is None:
raise MissingAuthor("Invalid author for user scope.")
config_scope = [PlaylistScope.USER.value, int(getattr(author, "id", author))]
else:
if guild is None:
raise MissingGuild("Invalid guild for guild scope.")
config_scope = [PlaylistScope.GUILD.value, int(getattr(guild, "id", guild))]
return config_scope
def prepare_config_scope_for_migration23( # TODO: remove me in a future version ?
scope, author: Union[discord.abc.User, int] = None, guild: discord.Guild = None
):
"""Return the scope used by Playlists"""
scope = standardize_scope(scope)
if scope == PlaylistScope.GLOBAL.value:
config_scope = [PlaylistScope.GLOBAL.value]
elif scope == PlaylistScope.USER.value:
if author is None:
raise MissingAuthor("Invalid author for user scope.")
config_scope = [PlaylistScope.USER.value, str(getattr(author, "id", author))]
else:
if guild is None:
raise MissingGuild("Invalid guild for guild scope.")
config_scope = [PlaylistScope.GUILD.value, str(getattr(guild, "id", guild))]
return config_scope
FakePlaylist = namedtuple("Playlist", "author scope")

View File

@ -0,0 +1,42 @@
import asyncio
import contextlib
import logging
import urllib.parse
from typing import Mapping, Optional, TYPE_CHECKING, Union
import aiohttp
from lavalink.rest_api import LoadResult
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.commands import Cog
from ..audio_dataclasses import Query
from ..audio_logging import IS_DEBUG, debug_exc_log
if TYPE_CHECKING:
from .. import Audio
_API_URL = "https://redbot.app/"
log = logging.getLogger("red.cogs.Audio.api.GlobalDB")
class GlobalCacheWrapper:
def __init__(
self, bot: Red, config: Config, session: aiohttp.ClientSession, cog: Union["Audio", Cog]
):
# Place Holder for the Global Cache PR
self.bot = bot
self.config = config
self.session = session
self.api_key = None
self._handshake_token = ""
self.can_write = False
self._handshake_token = ""
self.has_api_key = None
self._token: Mapping[str, str] = {}
self.cog = cog
def update_token(self, new_token: Mapping[str, str]):
self._token = new_token

View File

@ -0,0 +1,372 @@
import concurrent
import contextlib
import datetime
import logging
import random
import time
from types import SimpleNamespace
from typing import Callable, List, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union
from redbot.core.utils import AsyncIter
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.commands import Cog
from redbot.core.utils.dbtools import APSWConnectionWrapper
from ..audio_logging import debug_exc_log
from ..sql_statements import (
LAVALINK_CREATE_INDEX,
LAVALINK_CREATE_TABLE,
LAVALINK_DELETE_OLD_ENTRIES,
LAVALINK_FETCH_ALL_ENTRIES_GLOBAL,
LAVALINK_QUERY,
LAVALINK_QUERY_ALL,
LAVALINK_QUERY_LAST_FETCHED_RANDOM,
LAVALINK_UPDATE,
LAVALINK_UPSERT,
SPOTIFY_CREATE_INDEX,
SPOTIFY_CREATE_TABLE,
SPOTIFY_DELETE_OLD_ENTRIES,
SPOTIFY_QUERY,
SPOTIFY_QUERY_ALL,
SPOTIFY_QUERY_LAST_FETCHED_RANDOM,
SPOTIFY_UPDATE,
SPOTIFY_UPSERT,
YOUTUBE_CREATE_INDEX,
YOUTUBE_CREATE_TABLE,
YOUTUBE_DELETE_OLD_ENTRIES,
YOUTUBE_QUERY,
YOUTUBE_QUERY_ALL,
YOUTUBE_QUERY_LAST_FETCHED_RANDOM,
YOUTUBE_UPDATE,
YOUTUBE_UPSERT,
PRAGMA_FETCH_user_version,
PRAGMA_SET_journal_mode,
PRAGMA_SET_read_uncommitted,
PRAGMA_SET_temp_store,
PRAGMA_SET_user_version,
)
from .api_utils import (
LavalinkCacheFetchForGlobalResult,
LavalinkCacheFetchResult,
SpotifyCacheFetchResult,
YouTubeCacheFetchResult,
)
if TYPE_CHECKING:
from .. import Audio
log = logging.getLogger("red.cogs.Audio.api.LocalDB")
_SCHEMA_VERSION = 3
class BaseWrapper:
def __init__(
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
):
self.bot = bot
self.config = config
self.database = conn
self.statement = SimpleNamespace()
self.statement.pragma_temp_store = PRAGMA_SET_temp_store
self.statement.pragma_journal_mode = PRAGMA_SET_journal_mode
self.statement.pragma_read_uncommitted = PRAGMA_SET_read_uncommitted
self.statement.set_user_version = PRAGMA_SET_user_version
self.statement.get_user_version = PRAGMA_FETCH_user_version
self.fetch_result: Optional[Callable] = None
self.cog = cog
async def init(self) -> None:
"""Initialize the local cache"""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.cursor().execute, self.statement.pragma_temp_store)
executor.submit(self.database.cursor().execute, self.statement.pragma_journal_mode)
executor.submit(self.database.cursor().execute, self.statement.pragma_read_uncommitted)
executor.submit(self.maybe_migrate)
executor.submit(self.database.cursor().execute, LAVALINK_CREATE_TABLE)
executor.submit(self.database.cursor().execute, LAVALINK_CREATE_INDEX)
executor.submit(self.database.cursor().execute, YOUTUBE_CREATE_TABLE)
executor.submit(self.database.cursor().execute, YOUTUBE_CREATE_INDEX)
executor.submit(self.database.cursor().execute, SPOTIFY_CREATE_TABLE)
executor.submit(self.database.cursor().execute, SPOTIFY_CREATE_INDEX)
await self.clean_up_old_entries()
def close(self) -> None:
"""Close the connection with the local cache"""
with contextlib.suppress(Exception):
self.database.close()
async def clean_up_old_entries(self) -> None:
"""Delete entries older than x in the local cache tables"""
max_age = await self.config.cache_age()
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
maxage_int = int(time.mktime(maxage.timetuple()))
values = {"maxage": maxage_int}
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.cursor().execute, LAVALINK_DELETE_OLD_ENTRIES, values)
executor.submit(self.database.cursor().execute, YOUTUBE_DELETE_OLD_ENTRIES, values)
executor.submit(self.database.cursor().execute, SPOTIFY_DELETE_OLD_ENTRIES, values)
def maybe_migrate(self) -> None:
"""Maybe migrate Database schema for the local cache"""
current_version = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[executor.submit(self.database.cursor().execute, self.statement.get_user_version)]
):
try:
row_result = future.result()
current_version = row_result.fetchone()
break
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed fetch from database")
if isinstance(current_version, tuple):
current_version = current_version[0]
if current_version == _SCHEMA_VERSION:
return
executor.submit(
self.database.cursor().execute,
self.statement.set_user_version,
{"version": _SCHEMA_VERSION},
)
async def insert(self, values: List[MutableMapping]) -> None:
"""Insert an entry into the local cache"""
try:
with self.database.transaction() as transaction:
transaction.executemany(self.statement.upsert, values)
except Exception as exc:
debug_exc_log(log, exc, "Error during table insert")
async def update(self, values: MutableMapping) -> None:
"""Update an entry of the local cache"""
try:
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
values["last_fetched"] = time_now
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.cursor().execute, self.statement.update, values)
except Exception as exc:
debug_exc_log(log, exc, "Error during table update")
async def _fetch_one(
self, values: MutableMapping
) -> Optional[
Union[LavalinkCacheFetchResult, SpotifyCacheFetchResult, YouTubeCacheFetchResult]
]:
"""Get an entry from the local cache"""
max_age = await self.config.cache_age()
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
maxage_int = int(time.mktime(maxage.timetuple()))
values.update({"maxage": maxage_int})
row = None
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[executor.submit(self.database.cursor().execute, self.statement.get_one, values)]
):
try:
row_result = future.result()
row = row_result.fetchone()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed fetch from database")
if not row:
return None
if self.fetch_result is None:
return None
return self.fetch_result(*row)
async def _fetch_all(
self, values: MutableMapping
) -> List[Union[LavalinkCacheFetchResult, SpotifyCacheFetchResult, YouTubeCacheFetchResult]]:
"""Get all entries from the local cache"""
output = []
row_result = []
if self.fetch_result is None:
return []
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[executor.submit(self.database.cursor().execute, self.statement.get_all, values)]
):
try:
row_result = future.result()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed fetch from database")
async for row in AsyncIter(row_result):
output.append(self.fetch_result(*row))
return output
async def _fetch_random(
self, values: MutableMapping
) -> Optional[
Union[LavalinkCacheFetchResult, SpotifyCacheFetchResult, YouTubeCacheFetchResult]
]:
"""Get a random entry from the local cache"""
row = None
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[
executor.submit(
self.database.cursor().execute, self.statement.get_random, values
)
]
):
try:
row_result = future.result()
rows = row_result.fetchall()
if rows:
row = random.choice(rows)
else:
row = None
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed random fetch from database")
if not row:
return None
if self.fetch_result is None:
return None
return self.fetch_result(*row)
class YouTubeTableWrapper(BaseWrapper):
def __init__(
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
):
super().__init__(bot, config, conn, cog)
self.statement.upsert = YOUTUBE_UPSERT
self.statement.update = YOUTUBE_UPDATE
self.statement.get_one = YOUTUBE_QUERY
self.statement.get_all = YOUTUBE_QUERY_ALL
self.statement.get_random = YOUTUBE_QUERY_LAST_FETCHED_RANDOM
self.fetch_result = YouTubeCacheFetchResult
async def fetch_one(
self, values: MutableMapping
) -> Tuple[Optional[str], Optional[datetime.datetime]]:
"""Get an entry from the Youtube table"""
result = await self._fetch_one(values)
if not result or not isinstance(result.query, str):
return None, None
return result.query, result.updated_on
async def fetch_all(self, values: MutableMapping) -> List[YouTubeCacheFetchResult]:
"""Get all entries from the Youtube table"""
result = await self._fetch_all(values)
if result and isinstance(result[0], YouTubeCacheFetchResult):
return result
return []
async def fetch_random(self, values: MutableMapping) -> Optional[str]:
"""Get a random entry from the Youtube table"""
result = await self._fetch_random(values)
if not result or not isinstance(result.query, str):
return None
return result.query
class SpotifyTableWrapper(BaseWrapper):
def __init__(
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
):
super().__init__(bot, config, conn, cog)
self.statement.upsert = SPOTIFY_UPSERT
self.statement.update = SPOTIFY_UPDATE
self.statement.get_one = SPOTIFY_QUERY
self.statement.get_all = SPOTIFY_QUERY_ALL
self.statement.get_random = SPOTIFY_QUERY_LAST_FETCHED_RANDOM
self.fetch_result = SpotifyCacheFetchResult
async def fetch_one(
self, values: MutableMapping
) -> Tuple[Optional[str], Optional[datetime.datetime]]:
"""Get an entry from the Spotify table"""
result = await self._fetch_one(values)
if not result or not isinstance(result.query, str):
return None, None
return result.query, result.updated_on
async def fetch_all(self, values: MutableMapping) -> List[SpotifyCacheFetchResult]:
"""Get all entries from the Spotify table"""
result = await self._fetch_all(values)
if result and isinstance(result[0], SpotifyCacheFetchResult):
return result
return []
async def fetch_random(self, values: MutableMapping) -> Optional[str]:
"""Get a random entry from the Spotify table"""
result = await self._fetch_random(values)
if not result or not isinstance(result.query, str):
return None
return result.query
class LavalinkTableWrapper(BaseWrapper):
def __init__(
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
):
super().__init__(bot, config, conn, cog)
self.statement.upsert = LAVALINK_UPSERT
self.statement.update = LAVALINK_UPDATE
self.statement.get_one = LAVALINK_QUERY
self.statement.get_all = LAVALINK_QUERY_ALL
self.statement.get_random = LAVALINK_QUERY_LAST_FETCHED_RANDOM
self.statement.get_all_global = LAVALINK_FETCH_ALL_ENTRIES_GLOBAL
self.fetch_result = LavalinkCacheFetchResult
self.fetch_for_global: Optional[Callable] = None
async def fetch_one(
self, values: MutableMapping
) -> Tuple[Optional[MutableMapping], Optional[datetime.datetime]]:
"""Get an entry from the Lavalink table"""
result = await self._fetch_one(values)
if not result or not isinstance(result.query, dict):
return None, None
return result.query, result.updated_on
async def fetch_all(self, values: MutableMapping) -> List[LavalinkCacheFetchResult]:
"""Get all entries from the Lavalink table"""
result = await self._fetch_all(values)
if result and isinstance(result[0], LavalinkCacheFetchResult):
return result
return []
async def fetch_random(self, values: MutableMapping) -> Optional[MutableMapping]:
"""Get a random entry from the Lavalink table"""
result = await self._fetch_random(values)
if not result or not isinstance(result.query, dict):
return None
return result.query
async def fetch_all_for_global(self) -> List[LavalinkCacheFetchForGlobalResult]:
"""Get all entries from the Lavalink table"""
output: List[LavalinkCacheFetchForGlobalResult] = []
row_result = []
if self.fetch_for_global is None:
return []
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[executor.submit(self.database.cursor().execute, self.statement.get_all_global)]
):
try:
row_result = future.result()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed fetch from database")
async for row in AsyncIter(row_result):
output.append(self.fetch_for_global(*row))
return output
class LocalCacheWrapper:
"""Wraps all table apis into 1 object representing the local cache"""
def __init__(
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
):
self.bot = bot
self.config = config
self.database = conn
self.cog = cog
self.lavalink: LavalinkTableWrapper = LavalinkTableWrapper(bot, config, conn, self.cog)
self.spotify: SpotifyTableWrapper = SpotifyTableWrapper(bot, config, conn, self.cog)
self.youtube: YouTubeTableWrapper = YouTubeTableWrapper(bot, config, conn, self.cog)

View File

@ -1,259 +1,19 @@
import asyncio
from collections import namedtuple
from typing import List, MutableMapping, Optional, Union, TYPE_CHECKING
import logging
from typing import List, MutableMapping, Optional, Union
import discord
import lavalink
from redbot.core.utils import AsyncIter
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator
from redbot.core.utils.chat_formatting import humanize_list
from .databases import PlaylistFetchResult, PlaylistInterface
from .errors import InvalidPlaylistScope, MissingAuthor, MissingGuild, NotAllowed
from .utils import PlaylistScope
from ..errors import NotAllowed
from ..utils import PlaylistScope
from .api_utils import PlaylistFetchResult, prepare_config_scope, standardize_scope
from .playlist_wrapper import PlaylistWrapper
if TYPE_CHECKING:
database: PlaylistInterface
_bot: Red
_config: Config
else:
database = None
_bot = None
_config = None
__all__ = [
"Playlist",
"get_playlist",
"get_all_playlist",
"create_playlist",
"reset_playlist",
"delete_playlist",
"standardize_scope",
"FakePlaylist",
"get_all_playlist_for_migration23",
"database",
"get_all_playlist_converter",
"get_playlist_database",
]
FakePlaylist = namedtuple("Playlist", "author scope")
_ = Translator("Audio", __file__)
def _pass_config_to_playlist(config: Config, bot: Red):
global _config, _bot, database
if _config is None:
_config = config
if _bot is None:
_bot = bot
if database is None:
database = PlaylistInterface()
def get_playlist_database() -> Optional[PlaylistInterface]:
global database
return database
def standardize_scope(scope: str) -> str:
scope = scope.upper()
valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"]
if scope in PlaylistScope.list():
return scope
elif scope not in valid_scopes:
raise InvalidPlaylistScope(
f'"{scope}" is not a valid playlist scope.'
f" Scope needs to be one of the following: {humanize_list(valid_scopes)}"
)
if scope in ["GLOBAL", "BOT"]:
scope = PlaylistScope.GLOBAL.value
elif scope in ["GUILD", "SERVER"]:
scope = PlaylistScope.GUILD.value
elif scope in ["USER", "MEMBER", "AUTHOR"]:
scope = PlaylistScope.USER.value
return scope
def _prepare_config_scope(
scope, author: Union[discord.abc.User, int] = None, guild: Union[discord.Guild, int] = None
):
scope = standardize_scope(scope)
if scope == PlaylistScope.GLOBAL.value:
config_scope = [PlaylistScope.GLOBAL.value, _bot.user.id]
elif scope == PlaylistScope.USER.value:
if author is None:
raise MissingAuthor("Invalid author for user scope.")
config_scope = [PlaylistScope.USER.value, int(getattr(author, "id", author))]
else:
if guild is None:
raise MissingGuild("Invalid guild for guild scope.")
config_scope = [PlaylistScope.GUILD.value, int(getattr(guild, "id", guild))]
return config_scope
def _prepare_config_scope_for_migration23( # TODO: remove me in a future version ?
scope, author: Union[discord.abc.User, int] = None, guild: discord.Guild = None
):
scope = standardize_scope(scope)
if scope == PlaylistScope.GLOBAL.value:
config_scope = [PlaylistScope.GLOBAL.value]
elif scope == PlaylistScope.USER.value:
if author is None:
raise MissingAuthor("Invalid author for user scope.")
config_scope = [PlaylistScope.USER.value, str(getattr(author, "id", author))]
else:
if guild is None:
raise MissingGuild("Invalid guild for guild scope.")
config_scope = [PlaylistScope.GUILD.value, str(getattr(guild, "id", guild))]
return config_scope
class PlaylistMigration23: # TODO: remove me in a future version ?
"""A single playlist."""
def __init__(
self,
scope: str,
author: int,
playlist_id: int,
name: str,
playlist_url: Optional[str] = None,
tracks: Optional[List[MutableMapping]] = None,
guild: Union[discord.Guild, int, None] = None,
):
self.guild = guild
self.scope = standardize_scope(scope)
self.author = author
self.id = playlist_id
self.name = name
self.url = playlist_url
self.tracks = tracks or []
@classmethod
async def from_json(
cls, scope: str, playlist_number: int, data: MutableMapping, **kwargs
) -> "PlaylistMigration23":
"""Get a Playlist object from the provided information.
Parameters
----------
scope:str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
playlist_number: int
The playlist's number.
data: dict
The JSON representation of the playlist to be gotten.
**kwargs
Extra attributes for the Playlist instance which override values
in the data dict. These should be complete objects and not
IDs, where possible.
Returns
-------
Playlist
The playlist object for the requested playlist.
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
`MissingGuild`
Trying to access the Guild scope without a guild.
`MissingAuthor`
Trying to access the User scope without an user id.
"""
guild = data.get("guild") or kwargs.get("guild")
author: int = data.get("author") or 0
playlist_id = data.get("id") or playlist_number
name = data.get("name", "Unnamed")
playlist_url = data.get("playlist_url", None)
tracks = data.get("tracks", [])
return cls(
guild=guild,
scope=scope,
author=author,
playlist_id=playlist_id,
name=name,
playlist_url=playlist_url,
tracks=tracks,
)
async def save(self):
"""Saves a Playlist to SQL."""
scope, scope_id = _prepare_config_scope(self.scope, self.author, self.guild)
database.upsert(
scope,
playlist_id=int(self.id),
playlist_name=self.name,
scope_id=scope_id,
author_id=self.author,
playlist_url=self.url,
tracks=self.tracks,
)
async def get_all_playlist_for_migration23( # TODO: remove me in a future version ?
scope: str, guild: Union[discord.Guild, int] = None
) -> List[PlaylistMigration23]:
"""
Gets all playlist for the specified scope.
Parameters
----------
scope: str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
guild: discord.Guild
The guild to get the playlist from if scope is GUILDPLAYLIST.
Returns
-------
list
A list of all playlists for the specified scope
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
`MissingGuild`
Trying to access the Guild scope without a guild.
`MissingAuthor`
Trying to access the User scope without an user id.
"""
playlists = await _config.custom(scope).all()
if scope == PlaylistScope.GLOBAL.value:
return [
await PlaylistMigration23.from_json(
scope,
playlist_number,
playlist_data,
guild=guild,
author=int(playlist_data.get("author", 0)),
)
for playlist_number, playlist_data in playlists.items()
]
elif scope == PlaylistScope.USER.value:
return [
await PlaylistMigration23.from_json(
scope, playlist_number, playlist_data, guild=guild, author=int(user_id)
)
for user_id, scopedata in playlists.items()
for playlist_number, playlist_data in scopedata.items()
]
else:
return [
await PlaylistMigration23.from_json(
scope,
playlist_number,
playlist_data,
guild=int(guild_id),
author=int(playlist_data.get("author", 0)),
)
for guild_id, scopedata in playlists.items()
for playlist_number, playlist_data in scopedata.items()
]
log = logging.getLogger("red.cogs.Audio.api.PlaylistsInterface")
class Playlist:
@ -262,6 +22,7 @@ class Playlist:
def __init__(
self,
bot: Red,
playlist_api: PlaylistWrapper,
scope: str,
author: int,
playlist_id: int,
@ -273,7 +34,7 @@ class Playlist:
self.bot = bot
self.guild = guild
self.scope = standardize_scope(scope)
self.config_scope = _prepare_config_scope(self.scope, author, guild)
self.config_scope = prepare_config_scope(self.bot, self.scope, author, guild)
self.scope_id = self.config_scope[-1]
self.author = author
self.author_id = getattr(self.author, "id", self.author)
@ -285,6 +46,7 @@ class Playlist:
self.url = playlist_url
self.tracks = tracks or []
self.tracks_obj = [lavalink.Track(data=track) for track in self.tracks]
self.playlist_api = playlist_api
def __repr__(self):
return (
@ -313,7 +75,7 @@ class Playlist:
async def save(self):
"""Saves a Playlist."""
scope, scope_id = self.config_scope
database.upsert(
await self.playlist_api.upsert(
scope,
playlist_id=int(self.id),
playlist_name=self.name,
@ -343,13 +105,21 @@ class Playlist:
@classmethod
async def from_json(
cls, bot: Red, scope: str, playlist_number: int, data: PlaylistFetchResult, **kwargs
cls,
bot: Red,
playlist_api: PlaylistWrapper,
scope: str,
playlist_number: int,
data: PlaylistFetchResult,
**kwargs,
) -> "Playlist":
"""Get a Playlist object from the provided information.
Parameters
----------
bot: Red
The bot's instance. Needed to get the target user.
playlist_api: PlaylistWrapper
The Playlist API interface.
scope:str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
playlist_number: int
@ -382,6 +152,7 @@ class Playlist:
return cls(
bot=bot,
playlist_api=playlist_api,
guild=guild,
scope=scope,
author=author,
@ -392,10 +163,189 @@ class Playlist:
)
class PlaylistCompat23:
"""A single playlist, migrating from Schema 2 to Schema 3"""
def __init__(
self,
bot: Red,
playlist_api: PlaylistWrapper,
scope: str,
author: int,
playlist_id: int,
name: str,
playlist_url: Optional[str] = None,
tracks: Optional[List[MutableMapping]] = None,
guild: Union[discord.Guild, int, None] = None,
):
self.bot = bot
self.guild = guild
self.scope = standardize_scope(scope)
self.author = author
self.id = playlist_id
self.name = name
self.url = playlist_url
self.tracks = tracks or []
self.playlist_api = playlist_api
@classmethod
async def from_json(
cls,
bot: Red,
playlist_api: PlaylistWrapper,
scope: str,
playlist_number: int,
data: MutableMapping,
**kwargs,
) -> "PlaylistCompat23":
"""Get a Playlist object from the provided information.
Parameters
----------
bot: Red
The Bot instance.
playlist_api: PlaylistWrapper
The Playlist API interface.
scope:str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
playlist_number: int
The playlist's number.
data: MutableMapping
The JSON representation of the playlist to be gotten.
**kwargs
Extra attributes for the Playlist instance which override values
in the data dict. These should be complete objects and not
IDs, where possible.
Returns
-------
Playlist
The playlist object for the requested playlist.
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
`MissingGuild`
Trying to access the Guild scope without a guild.
`MissingAuthor`
Trying to access the User scope without an user id.
"""
guild = data.get("guild") or kwargs.get("guild")
author: int = data.get("author") or 0
playlist_id = data.get("id") or playlist_number
name = data.get("name", "Unnamed")
playlist_url = data.get("playlist_url", None)
tracks = data.get("tracks", [])
return cls(
bot=bot,
playlist_api=playlist_api,
guild=guild,
scope=scope,
author=author,
playlist_id=playlist_id,
name=name,
playlist_url=playlist_url,
tracks=tracks,
)
async def save(self):
"""Saves a Playlist to SQL."""
scope, scope_id = prepare_config_scope(self.bot, self.scope, self.author, self.guild)
await self.playlist_api.upsert(
scope,
playlist_id=int(self.id),
playlist_name=self.name,
scope_id=scope_id,
author_id=self.author,
playlist_url=self.url,
tracks=self.tracks,
)
async def get_all_playlist_for_migration23(
bot: Red,
playlist_api: PlaylistWrapper,
config: Config,
scope: str,
guild: Union[discord.Guild, int] = None,
) -> List[PlaylistCompat23]:
"""
Gets all playlist for the specified scope.
Parameters
----------
bot: Red
The Bot instance.
playlist_api: PlaylistWrapper
The Playlist API interface.
config: Config
The Audio cog Config instance.
scope: str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
guild: discord.Guild
The guild to get the playlist from if scope is GUILDPLAYLIST.
Returns
-------
list
A list of all playlists for the specified scope
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
`MissingGuild`
Trying to access the Guild scope without a guild.
`MissingAuthor`
Trying to access the User scope without an user id.
"""
playlists = await config.custom(scope).all()
if scope == PlaylistScope.GLOBAL.value:
return [
await PlaylistCompat23.from_json(
bot,
playlist_api,
scope,
playlist_number,
playlist_data,
guild=guild,
author=int(playlist_data.get("author", 0)),
)
async for playlist_number, playlist_data in AsyncIter(playlists.items())
]
elif scope == PlaylistScope.USER.value:
return [
await PlaylistCompat23.from_json(
bot,
playlist_api,
scope,
playlist_number,
playlist_data,
guild=guild,
author=int(user_id),
)
async for user_id, scopedata in AsyncIter(playlists.items())
async for playlist_number, playlist_data in AsyncIter(scopedata.items())
]
else:
return [
await PlaylistCompat23.from_json(
bot,
playlist_api,
scope,
playlist_number,
playlist_data,
guild=int(guild_id),
author=int(playlist_data.get("author", 0)),
)
async for guild_id, scopedata in AsyncIter(playlists.items())
async for playlist_number, playlist_data in AsyncIter(scopedata.items())
]
async def get_playlist(
playlist_number: int,
scope: str,
bot: Red,
playlist_api: PlaylistWrapper,
guild: Union[discord.Guild, int] = None,
author: Union[discord.abc.User, int] = None,
) -> Playlist:
@ -405,6 +355,8 @@ async def get_playlist(
----------
playlist_number: int
The playlist number for the playlist to get.
playlist_api: PlaylistWrapper
The Playlist API interface.
scope: str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
guild: discord.Guild
@ -428,19 +380,26 @@ async def get_playlist(
`MissingAuthor`
Trying to access the User scope without an user id.
"""
scope_standard, scope_id = _prepare_config_scope(scope, author, guild)
playlist_data = database.fetch(scope_standard, playlist_number, scope_id)
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
playlist_data = await playlist_api.fetch(scope_standard, playlist_number, scope_id)
if not (playlist_data and playlist_data.playlist_id):
raise RuntimeError(f"That playlist does not exist for the following scope: {scope}")
return await Playlist.from_json(
bot, scope_standard, playlist_number, playlist_data, guild=guild, author=author
bot,
playlist_api,
scope_standard,
playlist_number,
playlist_data,
guild=guild,
author=author,
)
async def get_all_playlist(
scope: str,
bot: Red,
playlist_api: PlaylistWrapper,
guild: Union[discord.Guild, int] = None,
author: Union[discord.abc.User, int] = None,
specified_user: bool = False,
@ -457,13 +416,15 @@ async def get_all_playlist(
The ID of the user to get the playlist from if scope is USERPLAYLIST.
bot: Red
The bot's instance
playlist_api: PlaylistWrapper
The Playlist API interface.
specified_user:bool
Whether or not user ID was passed as an argparse.
Returns
-------
list
A list of all playlists for the specified scope
Raises
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
@ -472,28 +433,34 @@ async def get_all_playlist(
`MissingAuthor`
Trying to access the User scope without an user id.
"""
scope_standard, scope_id = _prepare_config_scope(scope, author, guild)
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
if specified_user:
user_id = getattr(author, "id", author)
playlists = await database.fetch_all(scope_standard, scope_id, author_id=user_id)
playlists = await playlist_api.fetch_all(scope_standard, scope_id, author_id=user_id)
else:
playlists = await database.fetch_all(scope_standard, scope_id)
playlists = await playlist_api.fetch_all(scope_standard, scope_id)
playlist_list = []
for playlist in playlists:
async for playlist in AsyncIter(playlists):
playlist_list.append(
await Playlist.from_json(
bot, scope, playlist.playlist_id, playlist, guild=guild, author=author
bot,
playlist_api,
scope,
playlist.playlist_id,
playlist,
guild=guild,
author=author,
)
)
await asyncio.sleep(0)
return playlist_list
async def get_all_playlist_converter(
scope: str,
bot: Red,
playlist_api: PlaylistWrapper,
arg: str,
guild: Union[discord.Guild, int] = None,
author: Union[discord.abc.User, int] = None,
@ -512,11 +479,13 @@ async def get_all_playlist_converter(
The bot's instance
arg:str
The value to lookup.
playlist_api: PlaylistWrapper
The Playlist API interface.
Returns
-------
list
A list of all playlists for the specified scope
Raises
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
@ -525,23 +494,29 @@ async def get_all_playlist_converter(
`MissingAuthor`
Trying to access the User scope without an user id.
"""
scope_standard, scope_id = _prepare_config_scope(scope, author, guild)
playlists = await database.fetch_all_converter(
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
playlists = await playlist_api.fetch_all_converter(
scope_standard, playlist_name=arg, playlist_id=arg
)
playlist_list = []
for playlist in playlists:
async for playlist in AsyncIter(playlists):
playlist_list.append(
await Playlist.from_json(
bot, scope, playlist.playlist_id, playlist, guild=guild, author=author
bot,
playlist_api,
scope,
playlist.playlist_id,
playlist,
guild=guild,
author=author,
)
)
await asyncio.sleep(0)
return playlist_list
async def create_playlist(
ctx: commands.Context,
playlist_api: PlaylistWrapper,
scope: str,
playlist_name: str,
playlist_url: Optional[str] = None,
@ -570,6 +545,8 @@ async def create_playlist(
guild: discord.Guild
The guild to create this playlist under.
This is only used when creating a playlist in the Guild scope
playlist_api: PlaylistWrapper
The Playlist API interface.
Raises
------
@ -583,6 +560,7 @@ async def create_playlist(
playlist = Playlist(
ctx.bot,
playlist_api,
scope,
author.id if author else None,
ctx.message.id,
@ -596,6 +574,8 @@ async def create_playlist(
async def reset_playlist(
bot: Red,
playlist_api: PlaylistWrapper,
scope: str,
guild: Union[discord.Guild, int] = None,
author: Union[discord.abc.User, int] = None,
@ -604,14 +584,18 @@ async def reset_playlist(
Parameters
----------
bot: Red
The bot's instance
scope: str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
guild: discord.Guild
The guild to get the playlist from if scope is GUILDPLAYLIST.
author: int
The ID of the user to get the playlist from if scope is USERPLAYLIST.
playlist_api: PlaylistWrapper
The Playlist API interface.
Raises
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
@ -620,12 +604,14 @@ async def reset_playlist(
`MissingAuthor`
Trying to access the User scope without an user id.
"""
scope, scope_id = _prepare_config_scope(scope, author, guild)
database.drop(scope)
database.create_table(scope)
scope, scope_id = prepare_config_scope(bot, scope, author, guild)
await playlist_api.drop(scope)
await playlist_api.create_table()
async def delete_playlist(
bot: Red,
playlist_api: PlaylistWrapper,
scope: str,
playlist_id: Union[str, int],
guild: discord.Guild,
@ -635,6 +621,8 @@ async def delete_playlist(
Parameters
----------
bot: Red
The bot's instance
scope: str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
playlist_id: Union[str, int]
@ -643,8 +631,10 @@ async def delete_playlist(
The guild to get the playlist from if scope is GUILDPLAYLIST.
author: int
The ID of the user to get the playlist from if scope is USERPLAYLIST.
playlist_api: PlaylistWrapper
The Playlist API interface.
Raises
Raises
------
`InvalidPlaylistScope`
Passing a scope that is not supported.
@ -653,5 +643,5 @@ async def delete_playlist(
`MissingAuthor`
Trying to access the User scope without an user id.
"""
scope, scope_id = _prepare_config_scope(scope, author, guild)
database.delete(scope, int(playlist_id), scope_id)
scope, scope_id = prepare_config_scope(bot, scope, author, guild)
await playlist_api.delete(scope, int(playlist_id), scope_id)

View File

@ -0,0 +1,249 @@
import concurrent
import json
import logging
from types import SimpleNamespace
from typing import List, MutableMapping, Optional
from redbot.core.utils import AsyncIter
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.utils.dbtools import APSWConnectionWrapper
from ..audio_logging import debug_exc_log
from ..sql_statements import (
PLAYLIST_CREATE_INDEX,
PLAYLIST_CREATE_TABLE,
PLAYLIST_DELETE,
PLAYLIST_DELETE_SCHEDULED,
PLAYLIST_DELETE_SCOPE,
PLAYLIST_FETCH,
PLAYLIST_FETCH_ALL,
PLAYLIST_FETCH_ALL_CONVERTER,
PLAYLIST_FETCH_ALL_WITH_FILTER,
PLAYLIST_UPSERT,
PRAGMA_FETCH_user_version,
PRAGMA_SET_journal_mode,
PRAGMA_SET_read_uncommitted,
PRAGMA_SET_temp_store,
PRAGMA_SET_user_version,
)
from ..utils import PlaylistScope
from .api_utils import PlaylistFetchResult
log = logging.getLogger("red.cogs.Audio.api.Playlists")
class PlaylistWrapper:
def __init__(self, bot: Red, config: Config, conn: APSWConnectionWrapper):
self.bot = bot
self.database = conn
self.config = config
self.statement = SimpleNamespace()
self.statement.pragma_temp_store = PRAGMA_SET_temp_store
self.statement.pragma_journal_mode = PRAGMA_SET_journal_mode
self.statement.pragma_read_uncommitted = PRAGMA_SET_read_uncommitted
self.statement.set_user_version = PRAGMA_SET_user_version
self.statement.get_user_version = PRAGMA_FETCH_user_version
self.statement.create_table = PLAYLIST_CREATE_TABLE
self.statement.create_index = PLAYLIST_CREATE_INDEX
self.statement.upsert = PLAYLIST_UPSERT
self.statement.delete = PLAYLIST_DELETE
self.statement.delete_scope = PLAYLIST_DELETE_SCOPE
self.statement.delete_scheduled = PLAYLIST_DELETE_SCHEDULED
self.statement.get_one = PLAYLIST_FETCH
self.statement.get_all = PLAYLIST_FETCH_ALL
self.statement.get_all_with_filter = PLAYLIST_FETCH_ALL_WITH_FILTER
self.statement.get_all_converter = PLAYLIST_FETCH_ALL_CONVERTER
async def init(self) -> None:
"""Initialize the Playlist table"""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.cursor().execute, self.statement.pragma_temp_store)
executor.submit(self.database.cursor().execute, self.statement.pragma_journal_mode)
executor.submit(self.database.cursor().execute, self.statement.pragma_read_uncommitted)
executor.submit(self.database.cursor().execute, self.statement.create_table)
executor.submit(self.database.cursor().execute, self.statement.create_index)
@staticmethod
def get_scope_type(scope: str) -> int:
"""Convert a scope to a numerical identifier"""
if scope == PlaylistScope.GLOBAL.value:
table = 1
elif scope == PlaylistScope.USER.value:
table = 3
else:
table = 2
return table
async def fetch(self, scope: str, playlist_id: int, scope_id: int) -> PlaylistFetchResult:
"""Fetch a single playlist"""
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[
executor.submit(
self.database.cursor().execute,
self.statement.get_one,
(
{
"playlist_id": playlist_id,
"scope_id": scope_id,
"scope_type": scope_type,
}
),
)
]
):
try:
row_result = future.result()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed playlist fetch from database")
row = row_result.fetchone()
if row:
row = PlaylistFetchResult(*row)
return row
async def fetch_all(
self, scope: str, scope_id: int, author_id=None
) -> List[PlaylistFetchResult]:
"""Fetch all playlists"""
scope_type = self.get_scope_type(scope)
output = []
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
if author_id is not None:
for future in concurrent.futures.as_completed(
[
executor.submit(
self.database.cursor().execute,
self.statement.get_all_with_filter,
(
{
"scope_type": scope_type,
"scope_id": scope_id,
"author_id": author_id,
}
),
)
]
):
try:
row_result = future.result()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed playlist fetch from database")
return []
else:
for future in concurrent.futures.as_completed(
[
executor.submit(
self.database.cursor().execute,
self.statement.get_all,
({"scope_type": scope_type, "scope_id": scope_id}),
)
]
):
try:
row_result = future.result()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed playlist fetch from database")
return []
async for row in AsyncIter(row_result):
output.append(PlaylistFetchResult(*row))
return output
async def fetch_all_converter(
self, scope: str, playlist_name, playlist_id
) -> List[PlaylistFetchResult]:
"""Fetch all playlists with the specified filter"""
scope_type = self.get_scope_type(scope)
try:
playlist_id = int(playlist_id)
except Exception as exc:
debug_exc_log(log, exc, "Failed converting playlist_id to int")
playlist_id = -1
output = []
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[
executor.submit(
self.database.cursor().execute,
self.statement.get_all_converter,
(
{
"scope_type": scope_type,
"playlist_name": playlist_name,
"playlist_id": playlist_id,
}
),
)
]
):
try:
row_result = future.result()
except Exception as exc:
debug_exc_log(log, exc, "Failed to completed fetch from database")
async for row in AsyncIter(row_result):
output.append(PlaylistFetchResult(*row))
return output
async def delete(self, scope: str, playlist_id: int, scope_id: int):
"""Deletes a single playlists"""
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(
self.database.cursor().execute,
self.statement.delete,
({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}),
)
async def delete_scheduled(self):
"""Clean up database from all deleted playlists"""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.cursor().execute, self.statement.delete_scheduled)
async def drop(self, scope: str):
"""Delete all playlists in a scope"""
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(
self.database.cursor().execute,
self.statement.delete_scope,
({"scope_type": scope_type}),
)
async def create_table(self):
"""Create the playlist table"""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.cursor().execute, PLAYLIST_CREATE_TABLE)
async def upsert(
self,
scope: str,
playlist_id: int,
playlist_name: str,
scope_id: int,
author_id: int,
playlist_url: Optional[str],
tracks: List[MutableMapping],
):
"""Insert or update a playlist into the database"""
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(
self.database.cursor().execute,
self.statement.upsert,
{
"scope_type": str(scope_type),
"playlist_id": int(playlist_id),
"playlist_name": str(playlist_name),
"scope_id": int(scope_id),
"author_id": int(author_id),
"playlist_url": playlist_url,
"tracks": json.dumps(tracks),
},
)

View File

@ -0,0 +1,189 @@
import base64
import contextlib
import logging
import time
from typing import List, Mapping, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union
import aiohttp
from redbot.core.i18n import Translator
from redbot.core.utils import AsyncIter
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.commands import Cog, Context
from ..errors import SpotifyFetchError
if TYPE_CHECKING:
from .. import Audio
_ = Translator("Audio", __file__)
log = logging.getLogger("red.cogs.Audio.api.Spotify")
CATEGORY_ENDPOINT = "https://api.spotify.com/v1/browse/categories"
TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"
ALBUMS_ENDPOINT = "https://api.spotify.com/v1/albums"
TRACKS_ENDPOINT = "https://api.spotify.com/v1/tracks"
PLAYLISTS_ENDPOINT = "https://api.spotify.com/v1/playlists"
class SpotifyWrapper:
"""Wrapper for the Spotify API."""
def __init__(
self, bot: Red, config: Config, session: aiohttp.ClientSession, cog: Union["Audio", Cog]
):
self.bot = bot
self.config = config
self.session = session
self.spotify_token: Optional[MutableMapping] = None
self.client_id: Optional[str] = None
self.client_secret: Optional[str] = None
self._token: Mapping[str, str] = {}
self.cog = cog
@staticmethod
def spotify_format_call(query_type: str, key: str) -> Tuple[str, MutableMapping]:
"""Format the spotify endpoint"""
params: MutableMapping = {}
if query_type == "album":
query = f"{ALBUMS_ENDPOINT}/{key}/tracks"
elif query_type == "track":
query = f"{TRACKS_ENDPOINT}/{key}"
else:
query = f"{PLAYLISTS_ENDPOINT}/{key}/tracks"
return query, params
async def get_spotify_track_info(
self, track_data: MutableMapping, ctx: Context
) -> Tuple[str, ...]:
"""Extract track info from spotify response"""
prefer_lyrics = await self.cog.get_lyrics_status(ctx)
track_name = track_data["name"]
if prefer_lyrics:
track_name = f"{track_name} - lyrics"
artist_name = track_data["artists"][0]["name"]
track_info = f"{track_name} {artist_name}"
song_url = track_data.get("external_urls", {}).get("spotify")
uri = track_data["uri"]
_id = track_data["id"]
_type = track_data["type"]
return song_url, track_info, uri, artist_name, track_name, _id, _type
@staticmethod
async def is_access_token_valid(token: MutableMapping) -> bool:
"""Check if current token is not too old"""
return (token["expires_at"] - int(time.time())) < 60
@staticmethod
def make_auth_header(
client_id: Optional[str], client_secret: Optional[str]
) -> MutableMapping[str, Union[str, int]]:
"""Make Authorization header for spotify token"""
if client_id is None:
client_id = ""
if client_secret is None:
client_secret = ""
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode("ascii"))
return {"Authorization": f"Basic {auth_header.decode('ascii')}"}
async def get(
self, url: str, headers: MutableMapping = None, params: MutableMapping = None
) -> MutableMapping[str, str]:
"""Make a GET request to the spotify API"""
if params is None:
params = {}
async with self.session.request("GET", url, params=params, headers=headers) as r:
data = await r.json()
if r.status != 200:
log.debug(f"Issue making GET request to {url}: [{r.status}] {data}")
return data
def update_token(self, new_token: Mapping[str, str]):
self._token = new_token
async def get_token(self) -> None:
"""Get the stored spotify tokens"""
if not self._token:
self._token = await self.bot.get_shared_api_tokens("spotify")
self.client_id = self._token.get("client_id", "")
self.client_secret = self._token.get("client_secret", "")
async def get_country_code(self, ctx: Context = None) -> str:
return await self.config.guild(ctx.guild).country_code() if ctx else "US"
async def request_access_token(self) -> MutableMapping:
"""Make a spotify call to get the auth token"""
await self.get_token()
payload = {"grant_type": "client_credentials"}
headers = self.make_auth_header(self.client_id, self.client_secret)
r = await self.post(TOKEN_ENDPOINT, payload=payload, headers=headers)
return r
async def get_access_token(self) -> Optional[str]:
"""Get the access_token"""
if self.spotify_token and not await self.is_access_token_valid(self.spotify_token):
return self.spotify_token["access_token"]
token = await self.request_access_token()
if token is None:
log.debug("Requested a token from Spotify, did not end up getting one.")
try:
token["expires_at"] = int(time.time()) + int(token["expires_in"])
except KeyError:
return None
self.spotify_token = token
log.debug(f"Created a new access token for Spotify: {token}")
return self.spotify_token["access_token"]
async def post(
self, url: str, payload: MutableMapping, headers: MutableMapping = None
) -> MutableMapping:
"""Make a POST call to spotify"""
async with self.session.post(url, data=payload, headers=headers) as r:
data = await r.json()
if r.status != 200:
log.debug(f"Issue making POST request to {url}: [{r.status}] {data}")
return data
async def make_get_call(self, url: str, params: MutableMapping) -> MutableMapping:
"""Make a Get call to spotify"""
token = await self.get_access_token()
return await self.get(url, params=params, headers={"Authorization": f"Bearer {token}"})
async def get_categories(self, ctx: Context = None) -> List[MutableMapping]:
"""Get the spotify categories"""
country_code = await self.get_country_code(ctx=ctx)
params: MutableMapping = {"country": country_code} if country_code else {}
result = await self.make_get_call(CATEGORY_ENDPOINT, params=params)
with contextlib.suppress(KeyError):
if result["error"]["status"] == 401:
raise SpotifyFetchError(
message=_(
"The Spotify API key or client secret has not been set properly. "
"\nUse `{prefix}audioset spotifyapi` for instructions."
)
)
categories = result.get("categories", {}).get("items", [])
return [{c["name"]: c["id"]} for c in categories if c]
async def get_playlist_from_category(self, category: str, ctx: Context = None):
"""Get spotify playlists for the specified category"""
url = f"{CATEGORY_ENDPOINT}/{category}/playlists"
country_code = await self.get_country_code(ctx=ctx)
params: MutableMapping = {"country": country_code} if country_code else {}
result = await self.make_get_call(url, params=params)
playlists = result.get("playlists", {}).get("items", [])
return [
{
"name": c["name"],
"uri": c["uri"],
"url": c.get("external_urls", {}).get("spotify"),
"tracks": c.get("tracks", {}).get("total", "Unknown"),
}
async for c in AsyncIter(playlists)
if c
]

View File

@ -0,0 +1,65 @@
import logging
from typing import Mapping, Optional, TYPE_CHECKING, Union
import aiohttp
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.commands import Cog
from ..errors import YouTubeApiError
if TYPE_CHECKING:
from .. import Audio
log = logging.getLogger("red.cogs.Audio.api.YouTube")
SEARCH_ENDPOINT = "https://www.googleapis.com/youtube/v3/search"
class YouTubeWrapper:
"""Wrapper for the YouTube Data API."""
def __init__(
self, bot: Red, config: Config, session: aiohttp.ClientSession, cog: Union["Audio", Cog]
):
self.bot = bot
self.config = config
self.session = session
self.api_key: Optional[str] = None
self._token: Mapping[str, str] = {}
self.cog = cog
def update_token(self, new_token: Mapping[str, str]):
self._token = new_token
async def _get_api_key(self,) -> str:
"""Get the stored youtube token"""
if not self._token:
self._token = await self.bot.get_shared_api_tokens("youtube")
self.api_key = self._token.get("api_key", "")
return self.api_key if self.api_key is not None else ""
async def get_call(self, query: str) -> Optional[str]:
"""Make a Get call to youtube data api"""
params = {
"q": query,
"part": "id",
"key": await self._get_api_key(),
"maxResults": 1,
"type": "video",
}
async with self.session.request("GET", SEARCH_ENDPOINT, params=params) as r:
if r.status in [400, 404]:
return None
elif r.status in [403, 429]:
if r.reason == "quotaExceeded":
raise YouTubeApiError("Your YouTube Data API quota has been reached.")
return None
else:
search_response = await r.json()
for search_result in search_response.get("items", []):
if search_result["id"]["kind"] == "youtube#video":
return f"https://www.youtube.com/watch?v={search_result['id']['videoId']}"
return None

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,38 @@
import asyncio
import contextlib
import glob
import logging
import ntpath
import os
import posixpath
import re
from pathlib import Path, PosixPath, WindowsPath
from typing import List, Optional, Union, MutableMapping, Iterator, AsyncIterator
from typing import (
AsyncIterator,
Final,
Iterator,
MutableMapping,
Optional,
Tuple,
Union,
Callable,
Pattern,
)
from urllib.parse import urlparse
import lavalink
from redbot.core.utils import AsyncIter
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.i18n import Translator
_RE_REMOVE_START: Final[Pattern] = re.compile(r"^(sc|list) ")
_RE_YOUTUBE_TIMESTAMP: Final[Pattern] = re.compile(r"[&|?]t=(\d+)s?")
_RE_YOUTUBE_INDEX: Final[Pattern] = re.compile(r"&index=(\d+)")
_RE_SPOTIFY_URL: Final[Pattern] = re.compile(r"(http[s]?://)?(open.spotify.com)/")
_RE_SPOTIFY_TIMESTAMP: Final[Pattern] = re.compile(r"#(\d+):(\d+)")
_RE_SOUNDCLOUD_TIMESTAMP: Final[Pattern] = re.compile(r"#t=(\d+):(\d+)s?")
_RE_TWITCH_TIMESTAMP: Final[Pattern] = re.compile(r"\?t=(\d+)h(\d+)m(\d+)s")
_PATH_SEPS: Final[Tuple[str, str]] = (posixpath.sep, ntpath.sep)
_config: Optional[Config] = None
_bot: Optional[Red] = None
_localtrack_folder: Optional[str] = None
_ = Translator("Audio", __file__)
_RE_REMOVE_START = re.compile(r"^(sc|list) ")
_RE_YOUTUBE_TIMESTAMP = re.compile(r"&t=(\d+)s?")
_RE_YOUTUBE_INDEX = re.compile(r"&index=(\d+)")
_RE_SPOTIFY_URL = re.compile(r"(http[s]?://)?(open.spotify.com)/")
_RE_SPOTIFY_TIMESTAMP = re.compile(r"#(\d+):(\d+)")
_RE_SOUNDCLOUD_TIMESTAMP = re.compile(r"#t=(\d+):(\d+)s?")
_RE_TWITCH_TIMESTAMP = re.compile(r"\?t=(\d+)h(\d+)m(\d+)s")
_PATH_SEPS = [posixpath.sep, ntpath.sep]
_FULLY_SUPPORTED_MUSIC_EXT = (".mp3", ".flac", ".ogg")
_PARTIALLY_SUPPORTED_MUSIC_EXT = (
_FULLY_SUPPORTED_MUSIC_EXT: Final[Tuple[str, ...]] = (".mp3", ".flac", ".ogg")
_PARTIALLY_SUPPORTED_MUSIC_EXT: Tuple[str, ...] = (
".m3u",
".m4a",
".aac",
@ -49,7 +51,7 @@ _PARTIALLY_SUPPORTED_MUSIC_EXT = (
# ".voc",
# ".dsf",
)
_PARTIALLY_SUPPORTED_VIDEO_EXT = (
_PARTIALLY_SUPPORTED_VIDEO_EXT: Tuple[str, ...] = (
".mp4",
".mov",
".flv",
@ -72,25 +74,20 @@ _PARTIALLY_SUPPORTED_VIDEO_EXT = (
_PARTIALLY_SUPPORTED_MUSIC_EXT += _PARTIALLY_SUPPORTED_VIDEO_EXT
def _pass_config_to_dataclasses(config: Config, bot: Red, folder: str):
global _config, _bot, _localtrack_folder
if _config is None:
_config = config
if _bot is None:
_bot = bot
_localtrack_folder = folder
log = logging.getLogger("red.cogs.Audio.audio_dataclasses")
class LocalPath:
"""Local tracks class.
Used to handle system dir trees in a cross system manner. The only use of this class is for
`localtracks`.
Used to handle system dir trees in a cross system manner.
The only use of this class is for `localtracks`.
"""
_all_music_ext = _FULLY_SUPPORTED_MUSIC_EXT + _PARTIALLY_SUPPORTED_MUSIC_EXT
def __init__(self, path, **kwargs):
def __init__(self, path, localtrack_folder, **kwargs):
self._localtrack_folder = localtrack_folder
self._path = path
if isinstance(path, (Path, WindowsPath, PosixPath, LocalPath)):
path = str(path.absolute())
@ -98,9 +95,8 @@ class LocalPath:
path = str(path)
self.cwd = Path.cwd()
_lt_folder = Path(_localtrack_folder) if _localtrack_folder else self.cwd
_lt_folder = Path(self._localtrack_folder) if self._localtrack_folder else self.cwd
_path = Path(path) if path else self.cwd
if _lt_folder.parts[-1].lower() == "localtracks" and not kwargs.get("forced"):
self.localtrack_folder = _lt_folder
elif kwargs.get("forced"):
@ -165,46 +161,44 @@ class LocalPath:
return self._path
@classmethod
def joinpath(cls, *args):
modified = cls(None)
def joinpath(cls, localpath, *args):
modified = cls(None, localpath)
modified.path = modified.path.joinpath(*args)
return modified
def rglob(self, pattern, folder=False) -> Iterator[str]:
if folder:
return glob.iglob(f"{self.path}{os.sep}**{os.sep}", recursive=True)
return glob.iglob(f"{glob.escape(self.path)}{os.sep}**{os.sep}", recursive=True)
else:
return glob.iglob(f"{self.path}{os.sep}**{os.sep}{pattern}", recursive=True)
return glob.iglob(
f"{glob.escape(self.path)}{os.sep}**{os.sep}*{pattern}", recursive=True
)
def glob(self, pattern, folder=False) -> Iterator[str]:
if folder:
return glob.iglob(f"{self.path}{os.sep}*{os.sep}", recursive=False)
return glob.iglob(f"{glob.escape(self.path)}{os.sep}*{os.sep}", recursive=False)
else:
return glob.iglob(f"{self.path}{os.sep}*{pattern}", recursive=False)
return glob.iglob(f"{glob.escape(self.path)}{os.sep}*{pattern}", recursive=False)
async def _multiglob(self, pattern: str, folder: bool, method: Callable):
async for rp in AsyncIter(method(pattern)):
rp_local = LocalPath(rp, self._localtrack_folder)
if (
(folder and rp_local.is_dir() and rp_local.exists())
or (not folder and rp_local.suffix in self._all_music_ext and rp_local.is_file())
and rp_local.exists()
):
yield rp_local
async def multiglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
for p in patterns:
for rp in self.glob(p):
rp = LocalPath(rp)
if folder and rp.is_dir() and rp.exists():
yield rp
await asyncio.sleep(0)
else:
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
yield rp
await asyncio.sleep(0)
async for p in AsyncIter(patterns):
async for path in self._multiglob(p, folder, self.glob):
yield path
async def multirglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
for p in patterns:
for rp in self.rglob(p):
rp = LocalPath(rp)
if folder and rp.is_dir() and rp.exists():
yield rp
await asyncio.sleep(0)
else:
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
yield rp
await asyncio.sleep(0)
async for p in AsyncIter(patterns):
async for path in self._multiglob(p, folder, self.rglob):
yield path
def __str__(self):
return self.to_string()
@ -238,7 +232,7 @@ class LocalPath:
if track.path.parent != self.localtrack_folder and track.path.relative_to(
self.path
):
tracks.append(Query.process_input(track))
tracks.append(Query.process_input(track, self._localtrack_folder))
return sorted(tracks, key=lambda x: x.to_string_user().lower())
async def subfolders_in_tree(self):
@ -247,6 +241,7 @@ class LocalPath:
with contextlib.suppress(ValueError):
if (
f not in return_folders
and f.is_dir()
and f.path != self.localtrack_folder
and f.path.relative_to(self.path)
):
@ -260,7 +255,7 @@ class LocalPath:
if track.path.parent != self.localtrack_folder and track.path.relative_to(
self.path
):
tracks.append(Query.process_input(track))
tracks.append(Query.process_input(track, self._localtrack_folder))
return sorted(tracks, key=lambda x: x.to_string_user().lower())
async def subfolders(self):
@ -321,18 +316,14 @@ class LocalPath:
class Query:
"""Query data class.
Use: Query.process_input(query) to generate the Query object.
Use: Query.process_input(query, localtrack_folder) to generate the Query object.
"""
def __init__(self, query: Union[LocalPath, str], **kwargs):
def __init__(self, query: Union[LocalPath, str], local_folder_current_path: Path, **kwargs):
query = kwargs.get("queryforced", query)
self._raw: Union[LocalPath, str] = query
_localtrack: LocalPath = LocalPath(query)
self.track: Union[LocalPath, str] = _localtrack if (
(_localtrack.is_file() or _localtrack.is_dir()) and _localtrack.exists()
) else query
self._local_folder_current_path = local_folder_current_path
_localtrack: LocalPath = LocalPath(query, local_folder_current_path)
self.valid: bool = query != "InvalidQueryPlaceHolderName"
self.is_local: bool = kwargs.get("local", False)
@ -364,6 +355,15 @@ class Query:
self.is_youtube = False
self.is_soundcloud = True
if (_localtrack.is_file() or _localtrack.is_dir()) and _localtrack.exists():
self.local_track_path: Optional[LocalPath] = _localtrack
self.track: str = str(_localtrack.absolute())
self.is_local: bool = True
self.uri = self.track
else:
self.local_track_path: Optional[LocalPath] = None
self.track: str = str(query)
self.lavalink_query: str = self._get_query()
if self.is_playlist or self.is_album:
@ -397,14 +397,21 @@ class Query:
return str(self.lavalink_query)
@classmethod
def process_input(cls, query: Union[LocalPath, lavalink.Track, "Query", str], **kwargs):
"""A replacement for :code:`lavalink.Player.load_tracks`. This will try to get a valid
cached entry first if not found or if in valid it will then call the lavalink API.
def process_input(
cls,
query: Union[LocalPath, lavalink.Track, "Query", str],
_local_folder_current_path: Path,
**kwargs,
) -> "Query":
"""
Process the input query into its type
Parameters
----------
query : Union[Query, LocalPath, lavalink.Track, str]
The query string or LocalPath object.
_local_folder_current_path: Path
The Current Local Track folder
Returns
-------
Query
@ -430,12 +437,13 @@ class Query:
query = query.uri
possible_values.update(dict(**kwargs))
possible_values.update(cls._parse(query, **kwargs))
return cls(query, **possible_values)
possible_values.update(cls._parse(query, _local_folder_current_path, **kwargs))
return cls(query, _local_folder_current_path, **possible_values)
@staticmethod
def _parse(track, **kwargs) -> MutableMapping:
returning = {}
def _parse(track, _local_folder_current_path: Path, **kwargs) -> MutableMapping:
"""Parse a track into all the relevant metadata"""
returning: MutableMapping = {}
if (
type(track) == type(LocalPath)
and (track.is_file() or track.is_dir())
@ -475,7 +483,7 @@ class Query:
track = _RE_REMOVE_START.sub("", track, 1)
returning["queryforced"] = track
_localtrack = LocalPath(track)
_localtrack = LocalPath(track, _local_folder_current_path)
if _localtrack.exists():
if _localtrack.is_file():
returning["local"] = True
@ -498,7 +506,7 @@ class Query:
if url_domain in ["youtube.com", "youtu.be"]:
returning["youtube"] = True
_has_index = "&index=" in track
if "&t=" in track:
if "&t=" in track or "?t=" in track:
match = re.search(_RE_YOUTUBE_TIMESTAMP, track)
if match:
returning["start_time"] = int(match.group(1))
@ -599,7 +607,7 @@ class Query:
def _get_query(self):
if self.is_local:
return self.track.to_string()
return self.local_track_path.to_string()
elif self.is_spotify:
return self.spotify_uri
elif self.is_search and self.is_youtube:
@ -610,13 +618,13 @@ class Query:
def to_string_user(self):
if self.is_local:
return str(self.track.to_string_user())
return str(self.local_track_path.to_string_user())
return str(self._raw)
@property
def suffix(self):
if self.is_local:
return self.track.suffix
return self.local_track_path.suffix
return None
def __eq__(self, other):

View File

@ -0,0 +1,17 @@
import logging
import sys
from typing import Final
IS_DEBUG: Final[bool] = "--debug" in sys.argv
def is_debug() -> bool:
return IS_DEBUG
def debug_exc_log(lg: logging.Logger, exc: Exception, msg: str = None) -> None:
"""Logs an exception if logging is set to DEBUG level"""
if lg.getEffectiveLevel() <= logging.DEBUG:
if msg is None:
msg = f"{exc}"
lg.exception(msg, exc_info=exc)

View File

@ -1,31 +0,0 @@
from typing import TYPE_CHECKING
from redbot.core import Config, commands
if TYPE_CHECKING:
_config: Config
else:
_config = None
def _pass_config_to_checks(config: Config):
global _config
if _config is None:
_config = config
def roomlocked():
"""Deny the command if the bot has been room locked."""
async def predicate(ctx: commands.Context):
if ctx.guild is None:
return False
if await ctx.bot.is_mod(member=ctx.author):
return True
room_id = await _config.guild(ctx.guild).room_lock()
if room_id is None or ctx.channel.id == room_id:
return True
return False
return commands.check(predicate)

View File

@ -1,18 +0,0 @@
from redbot.core import Config
from redbot.core.bot import Red
from .apis import _pass_config_to_apis
from .audio_dataclasses import _pass_config_to_dataclasses
from .converters import _pass_config_to_converters
from .databases import _pass_config_to_databases
from .playlists import _pass_config_to_playlist
from .utils import _pass_config_to_utils
def pass_config_to_dependencies(config: Config, bot: Red, localtracks_folder: str):
_pass_config_to_databases(config, bot)
_pass_config_to_utils(config, bot)
_pass_config_to_dataclasses(config, bot, localtracks_folder)
_pass_config_to_apis(config, bot)
_pass_config_to_playlist(config, bot)
_pass_config_to_converters(config, bot)

View File

@ -1,16 +1,18 @@
import argparse
import functools
import re
from typing import Optional, Tuple, Union, MutableMapping, TYPE_CHECKING
from typing import Final, MutableMapping, Optional, Tuple, Union, Pattern
import discord
from redbot.core.utils import AsyncIter
from redbot.core import Config, commands
from redbot.core import commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator
from .apis.api_utils import standardize_scope
from .apis.playlist_interface import get_all_playlist_converter
from .errors import NoMatchesFound, TooManyMatches
from .playlists import get_all_playlist_converter, standardize_scope
from .utils import PlaylistScope
_ = Translator("Audio", __file__)
@ -25,50 +27,47 @@ __all__ = [
"get_playlist_converter",
]
if TYPE_CHECKING:
_bot: Red
_config: Config
else:
_bot = None
_config = None
T_ = _
_ = lambda s: s
_SCOPE_HELP = """
_SCOPE_HELP: Final[str] = _(
"""
Scope must be a valid version of one of the following:
Global
Guild
User
"""
_USER_HELP = """
)
_USER_HELP: Final[str] = _(
"""
Author must be a valid version of one of the following:
User ID
User Mention
User Name#123
"""
_GUILD_HELP = """
)
_GUILD_HELP: Final[str] = _(
"""
Guild must be a valid version of one of the following:
Guild ID
Exact guild name
"""
)
MENTION_RE = re.compile(r"^<?(?:(?:@[!&]?)?|#)(\d{15,21})>?$")
_ = T_
def _pass_config_to_converters(config: Config, bot: Red):
global _config, _bot
if _config is None:
_config = config
if _bot is None:
_bot = bot
MENTION_RE: Final[Pattern] = re.compile(r"^<?(?:(?:@[!&]?)?|#)(\d{15,21})>?$")
def _match_id(arg: str) -> Optional[int]:
m = MENTION_RE.match(arg)
if m:
return int(m.group(1))
return None
async def global_unique_guild_finder(ctx: commands.Context, arg: str) -> discord.Guild:
bot: commands.Bot = ctx.bot
bot: Red = ctx.bot
_id = _match_id(arg)
if _id is not None:
@ -77,7 +76,7 @@ async def global_unique_guild_finder(ctx: commands.Context, arg: str) -> discord
return guild
maybe_matches = []
for obj in bot.guilds:
async for obj in AsyncIter(bot.guilds):
if obj.name == arg or str(obj) == arg:
maybe_matches.append(obj)
@ -102,7 +101,7 @@ async def global_unique_guild_finder(ctx: commands.Context, arg: str) -> discord
async def global_unique_user_finder(
ctx: commands.Context, arg: str, guild: discord.guild = None
) -> discord.abc.User:
bot: commands.Bot = ctx.bot
bot: Red = ctx.bot
guild = guild or ctx.guild
_id = _match_id(arg)
@ -111,17 +110,15 @@ async def global_unique_user_finder(
if user is not None:
return user
objects = bot.users
maybe_matches = []
for obj in objects:
if obj.name == arg or str(obj) == arg:
maybe_matches.append(obj)
async for user in AsyncIter(bot.users).filter(lambda u: u.name == arg or f"{u}" == arg):
maybe_matches.append(user)
if guild is not None:
for member in guild.members:
if member.nick == arg and not any(obj.id == member.id for obj in maybe_matches):
maybe_matches.append(member)
async for member in AsyncIter(guild.members).filter(
lambda m: m.nick == arg and not any(obj.id == m.id for obj in maybe_matches)
):
maybe_matches.append(member)
if not maybe_matches:
raise NoMatchesFound(
@ -143,15 +140,36 @@ async def global_unique_user_finder(
class PlaylistConverter(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> MutableMapping:
global_matches = await get_all_playlist_converter(
PlaylistScope.GLOBAL.value, _bot, arg, guild=ctx.guild, author=ctx.author
)
guild_matches = await get_all_playlist_converter(
PlaylistScope.GUILD.value, _bot, arg, guild=ctx.guild, author=ctx.author
)
user_matches = await get_all_playlist_converter(
PlaylistScope.USER.value, _bot, arg, guild=ctx.guild, author=ctx.author
)
"""Get playlist for all scopes that match the argument user provided"""
cog = ctx.cog
user_matches = []
guild_matches = []
global_matches = []
if cog:
global_matches = await get_all_playlist_converter(
PlaylistScope.GLOBAL.value,
ctx.bot,
cog.playlist_api,
arg,
guild=ctx.guild,
author=ctx.author,
)
guild_matches = await get_all_playlist_converter(
PlaylistScope.GUILD.value,
ctx.bot,
cog.playlist_api,
arg,
guild=ctx.guild,
author=ctx.author,
)
user_matches = await get_all_playlist_converter(
PlaylistScope.USER.value,
ctx.bot,
cog.playlist_api,
arg,
guild=ctx.guild,
author=ctx.author,
)
if not user_matches and not guild_matches and not global_matches:
raise commands.BadArgument(_("Could not match '{}' to a playlist.").format(arg))
return {
@ -184,7 +202,7 @@ class ScopeParser(commands.Converter):
if arguments:
argument = " -- ".join(arguments)
else:
command = None
command = ""
parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False)
parser.add_argument("--scope", nargs="*", dest="scope", default=[])
@ -215,10 +233,10 @@ class ScopeParser(commands.Converter):
"BOT",
]
if scope not in valid_scopes:
raise commands.ArgParserFailure("--scope", scope_raw, custom_help=_SCOPE_HELP)
raise commands.ArgParserFailure("--scope", scope_raw, custom_help=_(_SCOPE_HELP))
target_scope = standardize_scope(scope)
elif "--scope" in argument and not vals["scope"]:
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_SCOPE_HELP)
raise commands.ArgParserFailure("--scope", _("Nothing"), custom_help=_(_SCOPE_HELP))
is_owner = await ctx.bot.is_owner(ctx.author)
guild = vals.get("guild", None) or vals.get("server", None)
@ -234,13 +252,13 @@ class ScopeParser(commands.Converter):
server_error = f"{err}\n"
if target_guild is None:
raise commands.ArgParserFailure(
"--guild", guild_raw, custom_help=f"{server_error}{_GUILD_HELP}"
"--guild", guild_raw, custom_help=f"{server_error}{_(_GUILD_HELP)}"
)
elif not is_owner and (guild or any(x in argument for x in ["--guild", "--server"])):
raise commands.BadArgument("You cannot use `--guild`")
raise commands.BadArgument(_("You cannot use `--guild`"))
elif any(x in argument for x in ["--guild", "--server"]):
raise commands.ArgParserFailure("--guild", "Nothing", custom_help=_GUILD_HELP)
raise commands.ArgParserFailure("--guild", _("Nothing"), custom_help=_(_GUILD_HELP))
author = vals.get("author", None) or vals.get("user", None) or vals.get("member", None)
if author:
@ -257,12 +275,12 @@ class ScopeParser(commands.Converter):
if target_user is None:
raise commands.ArgParserFailure(
"--author", user_raw, custom_help=f"{user_error}{_USER_HELP}"
"--author", user_raw, custom_help=f"{user_error}{_(_USER_HELP)}"
)
elif any(x in argument for x in ["--author", "--user", "--member"]):
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP)
raise commands.ArgParserFailure("--scope", _("Nothing"), custom_help=_(_USER_HELP))
target_scope: str = target_scope or None
target_scope: Optional[str] = target_scope or None
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
target_guild: discord.Guild = target_guild or ctx.guild
@ -299,7 +317,7 @@ class ComplexScopeParser(commands.Converter):
if arguments:
argument = " -- ".join(arguments)
else:
command = None
command = ""
parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False)
@ -345,7 +363,7 @@ class ComplexScopeParser(commands.Converter):
)
target_scope = standardize_scope(to_scope)
elif "--to-scope" in argument and not vals["to_scope"]:
raise commands.ArgParserFailure("--to-scope", "Nothing", custom_help=_SCOPE_HELP)
raise commands.ArgParserFailure("--to-scope", _("Nothing"), custom_help=_(_SCOPE_HELP))
if vals["from_scope"]:
from_scope_raw = " ".join(vals["from_scope"]).strip()
@ -357,7 +375,7 @@ class ComplexScopeParser(commands.Converter):
)
source_scope = standardize_scope(from_scope)
elif "--from-scope" in argument and not vals["to_scope"]:
raise commands.ArgParserFailure("--to-scope", "Nothing", custom_help=_SCOPE_HELP)
raise commands.ArgParserFailure("--to-scope", _("Nothing"), custom_help=_(_SCOPE_HELP))
to_guild = vals.get("to_guild", None) or vals.get("to_server", None)
if is_owner and to_guild:
@ -372,20 +390,24 @@ class ComplexScopeParser(commands.Converter):
target_server_error = f"{err}\n"
if target_guild is None:
raise commands.ArgParserFailure(
"--to-guild", to_guild_raw, custom_help=f"{target_server_error}{_GUILD_HELP}"
"--to-guild",
to_guild_raw,
custom_help=f"{target_server_error}{_(_GUILD_HELP)}",
)
elif not is_owner and (
to_guild or any(x in argument for x in ["--to-guild", "--to-server"])
):
raise commands.BadArgument("You cannot use `--to-server`")
raise commands.BadArgument(_("You cannot use `--to-server`"))
elif any(x in argument for x in ["--to-guild", "--to-server"]):
raise commands.ArgParserFailure("--to-server", "Nothing", custom_help=_GUILD_HELP)
raise commands.ArgParserFailure(
"--to-server", _("Nothing"), custom_help=_(_GUILD_HELP)
)
from_guild = vals.get("from_guild", None) or vals.get("from_server", None)
if is_owner and from_guild:
source_server_error = ""
source_guild = None
from_guild_raw = " ".join(to_guild).strip()
from_guild_raw = " ".join(from_guild).strip()
try:
source_guild = await global_unique_guild_finder(ctx, from_guild_raw)
except TooManyMatches as err:
@ -396,14 +418,16 @@ class ComplexScopeParser(commands.Converter):
raise commands.ArgParserFailure(
"--from-guild",
from_guild_raw,
custom_help=f"{source_server_error}{_GUILD_HELP}",
custom_help=f"{source_server_error}{_(_GUILD_HELP)}",
)
elif not is_owner and (
from_guild or any(x in argument for x in ["--from-guild", "--from-server"])
):
raise commands.BadArgument("You cannot use `--from-server`")
raise commands.BadArgument(_("You cannot use `--from-server`"))
elif any(x in argument for x in ["--from-guild", "--from-server"]):
raise commands.ArgParserFailure("--from-server", "Nothing", custom_help=_GUILD_HELP)
raise commands.ArgParserFailure(
"--from-server", _("Nothing"), custom_help=_(_GUILD_HELP)
)
to_author = (
vals.get("to_author", None) or vals.get("to_user", None) or vals.get("to_member", None)
@ -421,10 +445,10 @@ class ComplexScopeParser(commands.Converter):
target_user_error = f"{err}\n"
if target_user is None:
raise commands.ArgParserFailure(
"--to-author", to_user_raw, custom_help=f"{target_user_error}{_USER_HELP}"
"--to-author", to_user_raw, custom_help=f"{target_user_error}{_(_USER_HELP)}"
)
elif any(x in argument for x in ["--to-author", "--to-user", "--to-member"]):
raise commands.ArgParserFailure("--to-user", "Nothing", custom_help=_USER_HELP)
raise commands.ArgParserFailure("--to-user", _("Nothing"), custom_help=_(_USER_HELP))
from_author = (
vals.get("from_author", None)
@ -434,7 +458,7 @@ class ComplexScopeParser(commands.Converter):
if from_author:
source_user_error = ""
source_user = None
from_user_raw = " ".join(to_author).strip()
from_user_raw = " ".join(from_author).strip()
try:
source_user = await global_unique_user_finder(
ctx, from_user_raw, guild=target_guild
@ -446,18 +470,20 @@ class ComplexScopeParser(commands.Converter):
source_user_error = f"{err}\n"
if source_user is None:
raise commands.ArgParserFailure(
"--from-author", from_user_raw, custom_help=f"{source_user_error}{_USER_HELP}"
"--from-author",
from_user_raw,
custom_help=f"{source_user_error}{_(_USER_HELP)}",
)
elif any(x in argument for x in ["--from-author", "--from-user", "--from-member"]):
raise commands.ArgParserFailure("--from-user", "Nothing", custom_help=_USER_HELP)
raise commands.ArgParserFailure("--from-user", _("Nothing"), custom_help=_(_USER_HELP))
target_scope: str = target_scope or PlaylistScope.GUILD.value
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
target_guild: discord.Guild = target_guild or ctx.guild
target_scope = target_scope or PlaylistScope.GUILD.value
target_user = target_user or ctx.author
target_guild = target_guild or ctx.guild
source_scope: str = source_scope or PlaylistScope.GUILD.value
source_user: Union[discord.Member, discord.User] = source_user or ctx.author
source_guild: discord.Guild = source_guild or ctx.guild
source_scope = source_scope or PlaylistScope.GUILD.value
source_user = source_user or ctx.author
source_guild = source_guild or ctx.guild
return (
source_scope,

View File

@ -0,0 +1,121 @@
import asyncio
from collections import Counter
from typing import Mapping
import aiohttp
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.commands import Cog
from redbot.core.data_manager import cog_data_path
from redbot.core.i18n import cog_i18n
from ..utils import PlaylistScope
from . import abc, cog_utils, commands, events, tasks, utilities
from .cog_utils import CompositeMetaClass, _
@cog_i18n(_)
class Audio(
commands.Commands,
events.Events,
tasks.Tasks,
utilities.Utilities,
Cog,
metaclass=CompositeMetaClass,
):
"""Play audio through voice channels."""
_default_lavalink_settings = {
"host": "localhost",
"rest_port": 2333,
"ws_port": 2333,
"password": "youshallnotpass",
}
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(self, 2711759130, force_registration=True)
self.api_interface = None
self.player_manager = None
self.playlist_api = None
self.local_folder_current_path = None
self.db_conn = None
self._error_counter = Counter()
self._error_timer = {}
self._disconnected_players = {}
self._daily_playlist_cache = {}
self._daily_global_playlist_cache = {}
self._dj_status_cache = {}
self._dj_role_cache = {}
self.skip_votes = {}
self.play_lock = {}
self.lavalink_connect_task = None
self.player_automated_timer_task = None
self.cog_cleaned_up = False
self.lavalink_connection_aborted = False
self.session = aiohttp.ClientSession()
self.cog_ready_event = asyncio.Event()
self.cog_init_task = None
default_global = dict(
schema_version=1,
cache_level=0,
cache_age=365,
daily_playlists=False,
global_db_enabled=False,
global_db_get_timeout=5, # Here as a placeholder in case we want to enable the command
status=False,
use_external_lavalink=False,
restrict=True,
localpath=str(cog_data_path(raw_name="Audio")),
url_keyword_blacklist=[],
url_keyword_whitelist=[],
**self._default_lavalink_settings,
)
default_guild = dict(
auto_play=False,
autoplaylist={"enabled": False, "id": None, "name": None, "scope": None},
disconnect=False,
dj_enabled=False,
dj_role=None,
daily_playlists=False,
emptydc_enabled=False,
emptydc_timer=0,
emptypause_enabled=False,
emptypause_timer=0,
jukebox=False,
jukebox_price=0,
maxlength=0,
notify=False,
prefer_lyrics=False,
repeat=False,
shuffle=False,
shuffle_bumped=True,
thumbnail=False,
volume=100,
vote_enabled=False,
vote_percent=0,
room_lock=None,
url_keyword_blacklist=[],
url_keyword_whitelist=[],
country_code="US",
)
_playlist: Mapping = dict(id=None, author=None, name=None, playlist_url=None, tracks=[])
self.config.init_custom("EQUALIZER", 1)
self.config.register_custom("EQUALIZER", eq_bands=[], eq_presets={})
self.config.init_custom(PlaylistScope.GLOBAL.value, 1)
self.config.register_custom(PlaylistScope.GLOBAL.value, **_playlist)
self.config.init_custom(PlaylistScope.GUILD.value, 2)
self.config.register_custom(PlaylistScope.GUILD.value, **_playlist)
self.config.init_custom(PlaylistScope.USER.value, 2)
self.config.register_custom(PlaylistScope.USER.value, **_playlist)
self.config.register_guild(**default_guild)
self.config.register_global(**default_global)

View File

@ -0,0 +1,504 @@
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections import Counter
from pathlib import Path
from typing import Any, List, Mapping, MutableMapping, Optional, Tuple, Union, TYPE_CHECKING
import aiohttp
import discord
import lavalink
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.commands import Context
from redbot.core.utils.dbtools import APSWConnectionWrapper
if TYPE_CHECKING:
from ..apis.interface import AudioAPIInterface
from ..apis.playlist_interface import Playlist
from ..apis.playlist_wrapper import PlaylistWrapper
from ..audio_dataclasses import LocalPath, Query
from ..equalizer import Equalizer
from ..manager import ServerManager
class MixinMeta(ABC):
"""
Base class for well behaved type hint detection with composite class.
Basically, to keep developers sane when not all attributes are defined in each mixin.
"""
bot: Red
config: Config
api_interface: Optional["AudioAPIInterface"]
player_manager: Optional["ServerManager"]
playlist_api: Optional["PlaylistWrapper"]
local_folder_current_path: Optional[Path]
db_conn: Optional[APSWConnectionWrapper]
session: aiohttp.ClientSession
skip_votes: MutableMapping[discord.Guild, List[discord.Member]]
play_lock: MutableMapping[int, bool]
_daily_playlist_cache: MutableMapping[int, bool]
_daily_global_playlist_cache: MutableMapping[int, bool]
_dj_status_cache: MutableMapping[int, Optional[bool]]
_dj_role_cache: MutableMapping[int, Optional[int]]
_error_timer: MutableMapping[int, float]
_disconnected_players: MutableMapping[int, bool]
cog_cleaned_up: bool
lavalink_connection_aborted: bool
_error_counter: Counter
lavalink_connect_task: Optional[asyncio.Task]
player_automated_timer_task: Optional[asyncio.Task]
cog_init_task: Optional[asyncio.Task]
cog_ready_event: asyncio.Event
_default_lavalink_settings: Mapping
@abstractmethod
async def command_llsetup(self, ctx: commands.Context):
raise NotImplementedError()
@abstractmethod
async def maybe_reset_error_counter(self, player: lavalink.Player) -> None:
raise NotImplementedError()
@abstractmethod
async def update_bot_presence(self, track: lavalink.Track, playing_servers: int) -> None:
raise NotImplementedError()
@abstractmethod
def get_active_player_count(self) -> Tuple[str, int]:
raise NotImplementedError()
@abstractmethod
async def increase_error_counter(self, player: lavalink.Player) -> bool:
raise NotImplementedError()
@abstractmethod
async def _close_database(self) -> None:
raise NotImplementedError()
@abstractmethod
async def maybe_run_pending_db_tasks(self, ctx: commands.Context) -> None:
raise NotImplementedError()
@abstractmethod
def update_player_lock(self, ctx: commands.Context, true_or_false: bool) -> None:
raise NotImplementedError()
@abstractmethod
async def initialize(self) -> None:
raise NotImplementedError()
@abstractmethod
async def data_schema_migration(self, from_version: int, to_version: int) -> None:
raise NotImplementedError()
@abstractmethod
def lavalink_restart_connect(self) -> None:
raise NotImplementedError()
@abstractmethod
async def lavalink_attempt_connect(self, timeout: int = 50) -> None:
raise NotImplementedError()
@abstractmethod
async def player_automated_timer(self) -> None:
raise NotImplementedError()
@abstractmethod
async def lavalink_event_handler(
self, player: lavalink.Player, event_type: lavalink.LavalinkEvents, extra
) -> None:
raise NotImplementedError()
@abstractmethod
async def _clear_react(
self, message: discord.Message, emoji: MutableMapping = None
) -> asyncio.Task:
raise NotImplementedError()
@abstractmethod
async def remove_react(
self,
message: discord.Message,
react_emoji: Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str],
react_user: discord.abc.User,
) -> None:
raise NotImplementedError()
@abstractmethod
async def command_equalizer(self, ctx: commands.Context):
raise NotImplementedError()
@abstractmethod
async def _eq_msg_clear(self, eq_message: discord.Message) -> None:
raise NotImplementedError()
@abstractmethod
def _player_check(self, ctx: commands.Context) -> bool:
raise NotImplementedError()
@abstractmethod
async def maybe_charge_requester(self, ctx: commands.Context, jukebox_price: int) -> bool:
raise NotImplementedError()
@abstractmethod
async def _can_instaskip(self, ctx: commands.Context, member: discord.Member) -> bool:
raise NotImplementedError()
@abstractmethod
async def command_search(self, ctx: commands.Context, *, query: str):
raise NotImplementedError()
@abstractmethod
async def is_query_allowed(
self, config: Config, guild: discord.Guild, query: str, query_obj: "Query" = None
) -> bool:
raise NotImplementedError()
@abstractmethod
def is_track_too_long(self, track: Union[lavalink.Track, int], maxlength: int) -> bool:
raise NotImplementedError()
@abstractmethod
def get_track_description(
self,
track: Union[lavalink.rest_api.Track, "Query"],
local_folder_current_path: Path,
shorten: bool = False,
) -> Optional[str]:
raise NotImplementedError()
@abstractmethod
def get_track_description_unformatted(
self, track: Union[lavalink.rest_api.Track, "Query"], local_folder_current_path: Path
) -> Optional[str]:
raise NotImplementedError()
@abstractmethod
def humanize_scope(
self, scope: str, ctx: Union[discord.Guild, discord.abc.User, str] = None, the: bool = None
) -> Optional[str]:
raise NotImplementedError()
@abstractmethod
async def draw_time(self, ctx) -> str:
raise NotImplementedError()
@abstractmethod
def rsetattr(self, obj, attr, val) -> None:
raise NotImplementedError()
@abstractmethod
def rgetattr(self, obj, attr, *args) -> Any:
raise NotImplementedError()
@abstractmethod
async def _check_api_tokens(self) -> MutableMapping:
raise NotImplementedError()
@abstractmethod
async def send_embed_msg(
self, ctx: commands.Context, author: Mapping[str, str] = None, **kwargs
) -> discord.Message:
raise NotImplementedError()
@abstractmethod
async def update_external_status(self) -> bool:
raise NotImplementedError()
@abstractmethod
def get_track_json(
self,
player: lavalink.Player,
position: Union[int, str] = None,
other_track: lavalink.Track = None,
) -> MutableMapping:
raise NotImplementedError()
@abstractmethod
def track_to_json(self, track: lavalink.Track) -> MutableMapping:
raise NotImplementedError()
@abstractmethod
def time_convert(self, length: Union[int, str]) -> int:
raise NotImplementedError()
@abstractmethod
async def queue_duration(self, ctx: commands.Context) -> int:
raise NotImplementedError()
@abstractmethod
async def track_remaining_duration(self, ctx: commands.Context) -> int:
raise NotImplementedError()
@abstractmethod
def get_time_string(self, seconds: int) -> str:
raise NotImplementedError()
@abstractmethod
async def set_player_settings(self, ctx: commands.Context) -> None:
raise NotImplementedError()
@abstractmethod
async def get_playlist_match(
self,
context: commands.Context,
matches: MutableMapping,
scope: str,
author: discord.User,
guild: discord.Guild,
specified_user: bool = False,
) -> Tuple[Optional["Playlist"], str, str]:
raise NotImplementedError()
@abstractmethod
async def is_requester_alone(self, ctx: commands.Context) -> bool:
raise NotImplementedError()
@abstractmethod
async def is_requester(self, ctx: commands.Context, member: discord.Member) -> bool:
raise NotImplementedError()
@abstractmethod
async def _skip_action(self, ctx: commands.Context, skip_to_track: int = None) -> None:
raise NotImplementedError()
@abstractmethod
def is_vc_full(self, channel: discord.VoiceChannel) -> bool:
raise NotImplementedError()
@abstractmethod
async def _has_dj_role(self, ctx: commands.Context, member: discord.Member) -> bool:
raise NotImplementedError()
@abstractmethod
def match_url(self, url: str) -> bool:
raise NotImplementedError()
@abstractmethod
async def _playlist_check(self, ctx: commands.Context) -> bool:
raise NotImplementedError()
@abstractmethod
async def can_manage_playlist(
self, scope: str, playlist: "Playlist", ctx: commands.Context, user, guild
) -> bool:
raise NotImplementedError()
@abstractmethod
async def _maybe_update_playlist(
self, ctx: commands.Context, player: lavalink.player_manager.Player, playlist: "Playlist"
) -> Tuple[List[lavalink.Track], List[lavalink.Track], "Playlist"]:
raise NotImplementedError()
@abstractmethod
def is_url_allowed(self, url: str) -> bool:
raise NotImplementedError()
@abstractmethod
async def _eq_check(self, ctx: commands.Context, player: lavalink.Player) -> None:
raise NotImplementedError()
@abstractmethod
async def _enqueue_tracks(
self, ctx: commands.Context, query: Union["Query", list], enqueue: bool = True
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
raise NotImplementedError()
@abstractmethod
async def _eq_interact(
self,
ctx: commands.Context,
player: lavalink.Player,
eq: "Equalizer",
message: discord.Message,
selected: int,
) -> None:
raise NotImplementedError()
@abstractmethod
async def _apply_gains(self, guild_id: int, gains: List[float]) -> None:
NotImplementedError()
@abstractmethod
async def _apply_gain(self, guild_id: int, band: int, gain: float) -> None:
raise NotImplementedError()
@abstractmethod
async def _get_spotify_tracks(
self, ctx: commands.Context, query: "Query", forced: bool = False
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
raise NotImplementedError()
@abstractmethod
async def _genre_search_button_action(
self, ctx: commands.Context, options: List, emoji: str, page: int, playlist: bool = False
) -> str:
raise NotImplementedError()
@abstractmethod
async def _build_genre_search_page(
self,
ctx: commands.Context,
tracks: List,
page_num: int,
title: str,
playlist: bool = False,
) -> discord.Embed:
raise NotImplementedError()
@abstractmethod
async def command_audioset_autoplay_toggle(self, ctx: commands.Context):
raise NotImplementedError()
@abstractmethod
async def _search_button_action(
self, ctx: commands.Context, tracks: List, emoji: str, page: int
):
raise NotImplementedError()
@abstractmethod
async def get_localtrack_folder_tracks(
self, ctx, player: lavalink.player_manager.Player, query: "Query"
) -> List[lavalink.rest_api.Track]:
raise NotImplementedError()
@abstractmethod
async def get_localtrack_folder_list(
self, ctx: commands.Context, query: "Query"
) -> List["Query"]:
raise NotImplementedError()
@abstractmethod
async def _local_play_all(
self, ctx: commands.Context, query: "Query", from_search: bool = False
) -> None:
raise NotImplementedError()
@abstractmethod
async def _build_search_page(
self, ctx: commands.Context, tracks: List, page_num: int
) -> discord.Embed:
raise NotImplementedError()
@abstractmethod
async def command_play(self, ctx: commands.Context, *, query: str):
raise NotImplementedError()
@abstractmethod
async def localtracks_folder_exists(self, ctx: commands.Context) -> bool:
raise NotImplementedError()
@abstractmethod
async def get_localtracks_folders(
self, ctx: commands.Context, search_subfolders: bool = False
) -> List[Union[Path, "LocalPath"]]:
raise NotImplementedError()
@abstractmethod
async def _build_local_search_list(
self, to_search: List["Query"], search_words: str
) -> List[str]:
raise NotImplementedError()
@abstractmethod
async def command_stop(self, ctx: commands.Context):
raise NotImplementedError()
@abstractmethod
async def _build_queue_page(
self,
ctx: commands.Context,
queue: list,
player: lavalink.player_manager.Player,
page_num: int,
) -> discord.Embed:
raise NotImplementedError()
@abstractmethod
async def command_pause(self, ctx: commands.Context):
raise NotImplementedError()
@abstractmethod
async def _build_queue_search_list(
self, queue_list: List[lavalink.Track], search_words: str
) -> List[Tuple[int, str]]:
raise NotImplementedError()
@abstractmethod
async def _build_queue_search_page(
self, ctx: commands.Context, page_num: int, search_list: List[Tuple[int, str]]
) -> discord.Embed:
raise NotImplementedError()
@abstractmethod
async def fetch_playlist_tracks(
self,
ctx: commands.Context,
player: lavalink.player_manager.Player,
query: "Query",
skip_cache: bool = False,
) -> Union[discord.Message, None, List[MutableMapping]]:
raise NotImplementedError()
@abstractmethod
async def _build_playlist_list_page(
self, ctx: commands.Context, page_num: int, abc_names: List, scope: Optional[str]
) -> discord.Embed:
raise NotImplementedError()
@abstractmethod
def match_yt_playlist(self, url: str) -> bool:
raise NotImplementedError()
@abstractmethod
async def _load_v3_playlist(
self,
ctx: commands.Context,
scope: str,
uploaded_playlist_name: str,
uploaded_playlist_url: str,
track_list: List,
author: Union[discord.User, discord.Member],
guild: Union[discord.Guild],
) -> None:
raise NotImplementedError()
@abstractmethod
async def _load_v2_playlist(
self,
ctx: commands.Context,
uploaded_track_list,
player: lavalink.player_manager.Player,
playlist_url: str,
uploaded_playlist_name: str,
scope: str,
author: Union[discord.User, discord.Member],
guild: Union[discord.Guild],
):
raise NotImplementedError()
@abstractmethod
def format_time(self, time: int) -> str:
raise NotImplementedError()
@abstractmethod
async def get_lyrics_status(self, ctx: Context) -> bool:
raise NotImplementedError()
@abstractmethod
async def command_skip(self, ctx: commands.Context, skip_to_track: int = None):
raise NotImplementedError()
@abstractmethod
async def command_prev(self, ctx: commands.Context):
raise NotImplementedError()

View File

@ -0,0 +1,28 @@
from abc import ABC
from pathlib import Path
from typing import Final
from redbot import VersionInfo
from redbot.core import commands
from redbot.core.i18n import Translator
from ..converters import get_lazy_converter, get_playlist_converter
__version__ = VersionInfo.from_json({"major": 2, "minor": 0, "micro": 0, "releaselevel": "final"})
__author__ = ["aikaterna", "Draper"]
_ = Translator("Audio", Path(__file__).parent)
_SCHEMA_VERSION: Final[int] = 3
LazyGreedyConverter = get_lazy_converter("--")
PlaylistConverter = get_playlist_converter()
class CompositeMetaClass(type(commands.Cog), type(ABC)):
"""
This allows the metaclass used for proper type detection to
coexist with discord.py's metaclass
"""
pass

View File

@ -0,0 +1,25 @@
from ..cog_utils import CompositeMetaClass
from .audioset import AudioSetCommands
from .controller import PlayerControllerCommands
from .equalizer import EqualizerCommands
from .llset import LavalinkSetupCommands
from .localtracks import LocalTrackCommands
from .miscellaneous import MiscellaneousCommands
from .player import PlayerCommands
from .playlists import PlaylistCommands
from .queue import QueueCommands
class Commands(
AudioSetCommands,
PlayerControllerCommands,
EqualizerCommands,
LavalinkSetupCommands,
LocalTrackCommands,
MiscellaneousCommands,
PlayerCommands,
PlaylistCommands,
QueueCommands,
metaclass=CompositeMetaClass,
):
"""Class joining all command subclasses"""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,841 @@
import asyncio
import contextlib
import datetime
import logging
from typing import Optional, Tuple, Union
import discord
import lavalink
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.chat_formatting import humanize_number
from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.predicates import ReactionPredicate
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.player_controller")
class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="disconnect")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_disconnect(self, ctx: commands.Context):
"""Disconnect from the voice channel."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
else:
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
player = lavalink.get_player(ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
if (
(vote_enabled or (vote_enabled and dj_enabled))
and not can_skip
and not await self.is_requester_alone(ctx)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Disconnect"),
description=_("There are other people listening - vote to skip instead."),
)
if dj_enabled and not vote_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Disconnect"),
description=_("You need the DJ role to disconnect."),
)
if dj_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable to Disconnect"),
description=_("You need the DJ role to disconnect."),
)
await self.send_embed_msg(ctx, title=_("Disconnecting..."))
self.bot.dispatch("red_audio_audio_disconnect", ctx.guild)
self.update_player_lock(ctx, False)
eq = player.fetch("eq")
player.queue = []
player.store("playing_song", None)
if eq:
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
await player.stop()
await player.disconnect()
@commands.command(name="now")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
async def command_now(self, ctx: commands.Context):
"""Now playing."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
expected: Union[Tuple[str, ...]] = ("", "", "", "", "\N{CROSS MARK}")
emoji = {"prev": "", "stop": "", "pause": "", "next": "", "close": "\N{CROSS MARK}"}
player = lavalink.get_player(ctx.guild.id)
if player.current:
arrow = await self.draw_time(ctx)
pos = self.format_time(player.position)
if player.current.is_stream:
dur = "LIVE"
else:
dur = self.format_time(player.current.length)
song = self.get_track_description(player.current, self.local_folder_current_path) or ""
song += _("\n Requested by: **{track.requester}**")
song += "\n\n{arrow}`{pos}`/`{dur}`"
song = song.format(track=player.current, arrow=arrow, pos=pos, dur=dur)
else:
song = _("Nothing.")
if player.fetch("np_message") is not None:
with contextlib.suppress(discord.HTTPException):
await player.fetch("np_message").delete()
embed = discord.Embed(title=_("Now Playing"), description=song)
guild_data = await self.config.guild(ctx.guild).all()
if guild_data["thumbnail"] and player.current and player.current.thumbnail:
embed.set_thumbnail(url=player.current.thumbnail)
shuffle = guild_data["shuffle"]
repeat = guild_data["repeat"]
autoplay = guild_data["auto_play"]
text = ""
text += (
_("Auto-Play")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}")
)
text += (
(" | " if text else "")
+ _("Shuffle")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}")
)
text += (
(" | " if text else "")
+ _("Repeat")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}")
)
message = await self.send_embed_msg(ctx, embed=embed, footer=text)
player.store("np_message", message)
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
if (
(dj_enabled or vote_enabled)
and not await self._can_instaskip(ctx, ctx.author)
and not await self.is_requester_alone(ctx)
):
return
if not player.queue and not autoplay:
expected = ("", "", "\N{CROSS MARK}")
task: Optional[asyncio.Task]
if player.current:
task = start_adding_reactions(message, expected[:5])
else:
task = None
try:
(r, u) = await self.bot.wait_for(
"reaction_add",
check=ReactionPredicate.with_emojis(expected, message, ctx.author),
timeout=30.0,
)
except asyncio.TimeoutError:
return await self._clear_react(message, emoji)
else:
if task is not None:
task.cancel()
reacts = {v: k for k, v in emoji.items()}
react = reacts[r.emoji]
if react == "prev":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_prev)
elif react == "stop":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_stop)
elif react == "pause":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_pause)
elif react == "next":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_skip)
elif react == "close":
await message.delete()
@commands.command(name="pause")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_pause(self, ctx: commands.Context):
"""Pause or resume a playing track."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Manage Tracks"),
description=_("You must be in the voice channel to pause or resume."),
)
if dj_enabled and not can_skip and not await self.is_requester_alone(ctx):
return await self.send_embed_msg(
ctx,
title=_("Unable To Manage Tracks"),
description=_("You need the DJ role to pause or resume tracks."),
)
if not player.current:
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
description = self.get_track_description(player.current, self.local_folder_current_path)
if player.current and not player.paused:
await player.pause()
return await self.send_embed_msg(ctx, title=_("Track Paused"), description=description)
if player.current and player.paused:
await player.pause(False)
return await self.send_embed_msg(
ctx, title=_("Track Resumed"), description=description
)
await self.send_embed_msg(ctx, title=_("Nothing playing."))
@commands.command(name="prev")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_prev(self, ctx: commands.Context):
"""Skip to the start of the previously played track."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
is_alone = await self.is_requester_alone(ctx)
is_requester = await self.is_requester(ctx, ctx.author)
can_skip = await self._can_instaskip(ctx, ctx.author)
player = lavalink.get_player(ctx.guild.id)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_("You must be in the voice channel to skip the track."),
)
if (vote_enabled or (vote_enabled and dj_enabled)) and not can_skip and not is_alone:
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_("There are other people listening - vote to skip instead."),
)
if dj_enabled and not vote_enabled and not (can_skip or is_requester) and not is_alone:
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_(
"You need the DJ role or be the track requester "
"to enqueue the previous song tracks."
),
)
if player.fetch("prev_song") is None:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("No previous track.")
)
else:
track = player.fetch("prev_song")
player.add(player.fetch("prev_requester"), track)
self.bot.dispatch("red_audio_track_enqueue", player.channel.guild, track, ctx.author)
queue_len = len(player.queue)
bump_song = player.queue[-1]
player.queue.insert(0, bump_song)
player.queue.pop(queue_len)
await player.skip()
description = self.get_track_description(
player.current, self.local_folder_current_path
)
embed = discord.Embed(title=_("Replaying Track"), description=description)
await self.send_embed_msg(ctx, embed=embed)
@commands.command(name="seek")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_seek(self, ctx: commands.Context, seconds: Union[int, str]):
"""Seek ahead or behind on a track by seconds or a to a specific time.
Accepts seconds or a value formatted like 00:00:00 (`hh:mm:ss`) or 00:00 (`mm:ss`).
"""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
is_alone = await self.is_requester_alone(ctx)
is_requester = await self.is_requester(ctx, ctx.author)
can_skip = await self._can_instaskip(ctx, ctx.author)
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Seek Tracks"),
description=_("You must be in the voice channel to use seek."),
)
if vote_enabled and not can_skip and not is_alone:
return await self.send_embed_msg(
ctx,
title=_("Unable To Seek Tracks"),
description=_("There are other people listening - vote to skip instead."),
)
if dj_enabled and not (can_skip or is_requester) and not is_alone:
return await self.send_embed_msg(
ctx,
title=_("Unable To Seek Tracks"),
description=_("You need the DJ role or be the track requester to use seek."),
)
if player.current:
if player.current.is_stream:
return await self.send_embed_msg(
ctx, title=_("Unable To Seek Tracks"), description=_("Can't seek on a stream.")
)
else:
try:
int(seconds)
abs_position = False
except ValueError:
abs_position = True
seconds = self.time_convert(seconds)
if seconds == 0:
return await self.send_embed_msg(
ctx,
title=_("Unable To Seek Tracks"),
description=_("Invalid input for the time to seek."),
)
if not abs_position:
time_sec = int(seconds) * 1000
seek = player.position + time_sec
if seek <= 0:
await self.send_embed_msg(
ctx,
title=_("Moved {num_seconds}s to 00:00:00").format(
num_seconds=seconds
),
)
else:
await self.send_embed_msg(
ctx,
title=_("Moved {num_seconds}s to {time}").format(
num_seconds=seconds, time=self.format_time(seek)
),
)
await player.seek(seek)
else:
await self.send_embed_msg(
ctx,
title=_("Moved to {time}").format(time=self.format_time(seconds * 1000)),
)
await player.seek(seconds * 1000)
else:
await self.send_embed_msg(ctx, title=_("Nothing playing."))
@commands.group(name="shuffle", autohelp=False)
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_shuffle(self, ctx: commands.Context):
"""Toggle shuffle."""
if ctx.invoked_subcommand is None:
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
can_skip = await self._can_instaskip(ctx, ctx.author)
if dj_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Toggle Shuffle"),
description=_("You need the DJ role to toggle shuffle."),
)
if self._player_check(ctx):
await self.set_player_settings(ctx)
player = lavalink.get_player(ctx.guild.id)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Toggle Shuffle"),
description=_("You must be in the voice channel to toggle shuffle."),
)
shuffle = await self.config.guild(ctx.guild).shuffle()
await self.config.guild(ctx.guild).shuffle.set(not shuffle)
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("Shuffle tracks: {true_or_false}.").format(
true_or_false=_("Enabled") if not shuffle else _("Disabled")
),
)
if self._player_check(ctx):
await self.set_player_settings(ctx)
@command_shuffle.command(name="bumped")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_shuffle_bumpped(self, ctx: commands.Context):
"""Toggle bumped track shuffle.
Set this to disabled if you wish to avoid bumped songs being shuffled.
This takes priority over `[p]shuffle`.
"""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
can_skip = await self._can_instaskip(ctx, ctx.author)
if dj_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Toggle Shuffle"),
description=_("You need the DJ role to toggle shuffle."),
)
if self._player_check(ctx):
await self.set_player_settings(ctx)
player = lavalink.get_player(ctx.guild.id)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Toggle Shuffle"),
description=_("You must be in the voice channel to toggle shuffle."),
)
bumped = await self.config.guild(ctx.guild).shuffle_bumped()
await self.config.guild(ctx.guild).shuffle_bumped.set(not bumped)
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("Shuffle bumped tracks: {true_or_false}.").format(
true_or_false=_("Enabled") if not bumped else _("Disabled")
),
)
if self._player_check(ctx):
await self.set_player_settings(ctx)
@commands.command(name="skip")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_skip(self, ctx: commands.Context, skip_to_track: int = None):
"""Skip to the next track, or to a given track number."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_("You must be in the voice channel to skip the music."),
)
if not player.current:
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
is_alone = await self.is_requester_alone(ctx)
is_requester = await self.is_requester(ctx, ctx.author)
if dj_enabled and not vote_enabled:
if not (can_skip or is_requester) and not is_alone:
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_(
"You need the DJ role or be the track requester to skip tracks."
),
)
if (
is_requester
and not can_skip
and isinstance(skip_to_track, int)
and skip_to_track > 1
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_("You can only skip the current track."),
)
if vote_enabled:
if not can_skip:
if skip_to_track is not None:
return await self.send_embed_msg(
ctx,
title=_("Unable To Skip Tracks"),
description=_(
"Can't skip to a specific track in vote mode without the DJ role."
),
)
if ctx.author.id in self.skip_votes[ctx.message.guild]:
self.skip_votes[ctx.message.guild].remove(ctx.author.id)
reply = _("I removed your vote to skip.")
else:
self.skip_votes[ctx.message.guild].append(ctx.author.id)
reply = _("You voted to skip.")
num_votes = len(self.skip_votes[ctx.message.guild])
vote_mods = []
for member in player.channel.members:
can_skip = await self._can_instaskip(ctx, member)
if can_skip:
vote_mods.append(member)
num_members = len(player.channel.members) - len(vote_mods)
vote = int(100 * num_votes / num_members)
percent = await self.config.guild(ctx.guild).vote_percent()
if vote >= percent:
self.skip_votes[ctx.message.guild] = []
await self.send_embed_msg(ctx, title=_("Vote threshold met."))
return await self._skip_action(ctx)
else:
reply += _(
" Votes: {num_votes}/{num_members}"
" ({cur_percent}% out of {required_percent}% needed)"
).format(
num_votes=humanize_number(num_votes),
num_members=humanize_number(num_members),
cur_percent=vote,
required_percent=percent,
)
return await self.send_embed_msg(ctx, title=reply)
else:
return await self._skip_action(ctx, skip_to_track)
else:
return await self._skip_action(ctx, skip_to_track)
@commands.command(name="stop")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_stop(self, ctx: commands.Context):
"""Stop playback and clear the queue."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
is_alone = await self.is_requester_alone(ctx)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Stop Player"),
description=_("You must be in the voice channel to stop the music."),
)
if (vote_enabled or (vote_enabled and dj_enabled)) and not can_skip and not is_alone:
return await self.send_embed_msg(
ctx,
title=_("Unable To Stop Player"),
description=_("There are other people listening - vote to skip instead."),
)
if dj_enabled and not vote_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Stop Player"),
description=_("You need the DJ role to stop the music."),
)
if (
player.is_playing
or (not player.is_playing and player.paused)
or player.queue
or getattr(player.current, "extras", {}).get("autoplay")
):
eq = player.fetch("eq")
if eq:
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
player.queue = []
player.store("playing_song", None)
player.store("prev_requester", None)
player.store("prev_song", None)
player.store("requester", None)
await player.stop()
await self.send_embed_msg(ctx, title=_("Stopping..."))
@commands.command(name="summon")
@commands.guild_only()
@commands.cooldown(1, 15, commands.BucketType.guild)
@commands.bot_has_permissions(embed_links=True)
async def command_summon(self, ctx: commands.Context):
"""Summon the bot to a voice channel."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
is_alone = await self.is_requester_alone(ctx)
is_requester = await self.is_requester(ctx, ctx.author)
can_skip = await self._can_instaskip(ctx, ctx.author)
if (vote_enabled or (vote_enabled and dj_enabled)) and not can_skip and not is_alone:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Join Voice Channel"),
description=_("There are other people listening."),
)
if dj_enabled and not vote_enabled and not (can_skip or is_requester) and not is_alone:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Join Voice Channel"),
description=_("You need the DJ role to summon the bot."),
)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Join Voice Channel"),
description=_("I don't have permission to connect to your channel."),
)
if not self._player_check(ctx):
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
else:
player = lavalink.get_player(ctx.guild.id)
if ctx.author.voice.channel == player.channel:
ctx.command.reset_cooldown(ctx)
return
await player.move_to(ctx.author.voice.channel)
except AttributeError:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Join Voice Channel"),
description=_("Connect to a voice channel first."),
)
except IndexError:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Join Voice Channel"),
description=_("Connection to Lavalink has not yet been established."),
)
@commands.command(name="volume")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_volume(self, ctx: commands.Context, vol: int = None):
"""Set the volume, 1% - 150%."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
can_skip = await self._can_instaskip(ctx, ctx.author)
if not vol:
vol = await self.config.guild(ctx.guild).volume()
embed = discord.Embed(title=_("Current Volume:"), description=str(vol) + "%")
if not self._player_check(ctx):
embed.set_footer(text=_("Nothing playing."))
return await self.send_embed_msg(ctx, embed=embed)
if self._player_check(ctx):
player = lavalink.get_player(ctx.guild.id)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Change Volume"),
description=_("You must be in the voice channel to change the volume."),
)
if dj_enabled and not can_skip and not await self._has_dj_role(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Change Volume"),
description=_("You need the DJ role to change the volume."),
)
if vol < 0:
vol = 0
if vol > 150:
vol = 150
await self.config.guild(ctx.guild).volume.set(vol)
if self._player_check(ctx):
await lavalink.get_player(ctx.guild.id).set_volume(vol)
else:
await self.config.guild(ctx.guild).volume.set(vol)
if self._player_check(ctx):
await lavalink.get_player(ctx.guild.id).set_volume(vol)
embed = discord.Embed(title=_("Volume:"), description=str(vol) + "%")
if not self._player_check(ctx):
embed.set_footer(text=_("Nothing playing."))
await self.send_embed_msg(ctx, embed=embed)
@commands.command(name="repeat")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_repeat(self, ctx: commands.Context):
"""Toggle repeat."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
can_skip = await self._can_instaskip(ctx, ctx.author)
if dj_enabled and not can_skip and not await self._has_dj_role(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Toggle Repeat"),
description=_("You need the DJ role to toggle repeat."),
)
if self._player_check(ctx):
await self.set_player_settings(ctx)
player = lavalink.get_player(ctx.guild.id)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Toggle Repeat"),
description=_("You must be in the voice channel to toggle repeat."),
)
autoplay = await self.config.guild(ctx.guild).auto_play()
repeat = await self.config.guild(ctx.guild).repeat()
msg = ""
msg += _("Repeat tracks: {true_or_false}.").format(
true_or_false=_("Enabled") if not repeat else _("Disabled")
)
await self.config.guild(ctx.guild).repeat.set(not repeat)
if repeat is not True and autoplay is True:
msg += _("\nAuto-play has been disabled.")
await self.config.guild(ctx.guild).auto_play.set(False)
embed = discord.Embed(title=_("Setting Changed"), description=msg)
await self.send_embed_msg(ctx, embed=embed)
if self._player_check(ctx):
await self.set_player_settings(ctx)
@commands.command(name="remove")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_remove(self, ctx: commands.Context, index_or_url: Union[int, str]):
"""Remove a specific track number from the queue."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
if not player.queue:
return await self.send_embed_msg(ctx, title=_("Nothing queued."))
if dj_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Modify Queue"),
description=_("You need the DJ role to remove tracks."),
)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Modify Queue"),
description=_("You must be in the voice channel to manage the queue."),
)
if isinstance(index_or_url, int):
if index_or_url > len(player.queue) or index_or_url < 1:
return await self.send_embed_msg(
ctx,
title=_("Unable To Modify Queue"),
description=_(
"Song number must be greater than 1 and within the queue limit."
),
)
index_or_url -= 1
removed = player.queue.pop(index_or_url)
removed_title = self.get_track_description(removed, self.local_folder_current_path)
await self.send_embed_msg(
ctx,
title=_("Removed track from queue"),
description=_("Removed {track} from the queue.").format(track=removed_title),
)
else:
clean_tracks = []
removed_tracks = 0
async for track in AsyncIter(player.queue):
if track.uri != index_or_url:
clean_tracks.append(track)
else:
removed_tracks += 1
player.queue = clean_tracks
if removed_tracks == 0:
await self.send_embed_msg(
ctx,
title=_("Unable To Modify Queue"),
description=_("Removed 0 tracks, nothing matches the URL provided."),
)
else:
await self.send_embed_msg(
ctx,
title=_("Removed track from queue"),
description=_(
"Removed {removed_tracks} tracks from queue "
"which matched the URL provided."
).format(removed_tracks=removed_tracks),
)
@commands.command(name="bump")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_bump(self, ctx: commands.Context, index: int):
"""Bump a track number to the top of the queue."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Bump Track"),
description=_("You must be in the voice channel to bump a track."),
)
if dj_enabled and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Bump Track"),
description=_("You need the DJ role to bump tracks."),
)
if index > len(player.queue) or index < 1:
return await self.send_embed_msg(
ctx,
title=_("Unable To Bump Track"),
description=_("Song number must be greater than 1 and within the queue limit."),
)
bump_index = index - 1
bump_song = player.queue[bump_index]
bump_song.extras["bumped"] = True
player.queue.insert(0, bump_song)
removed = player.queue.pop(index)
description = self.get_track_description(removed, self.local_folder_current_path)
await self.send_embed_msg(
ctx, title=_("Moved track to the top of the queue."), description=description
)

View File

@ -0,0 +1,385 @@
import asyncio
import contextlib
import logging
import re
import discord
import lavalink
from redbot.core import commands
from redbot.core.utils.chat_formatting import box, humanize_number, pagify
from redbot.core.utils.menus import DEFAULT_CONTROLS, menu, start_adding_reactions
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate
from ...equalizer import Equalizer
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.equalizer")
class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="eq", invoke_without_command=True)
@commands.guild_only()
@commands.cooldown(1, 15, commands.BucketType.guild)
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
async def command_equalizer(self, ctx: commands.Context):
"""Equalizer management."""
if not self._player_check(ctx):
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
player = lavalink.get_player(ctx.guild.id)
eq = player.fetch("eq", Equalizer())
reactions = [
"\N{BLACK LEFT-POINTING TRIANGLE}",
"\N{LEFTWARDS BLACK ARROW}",
"\N{BLACK UP-POINTING DOUBLE TRIANGLE}",
"\N{UP-POINTING SMALL RED TRIANGLE}",
"\N{DOWN-POINTING SMALL RED TRIANGLE}",
"\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}",
"\N{BLACK RIGHTWARDS ARROW}",
"\N{BLACK RIGHT-POINTING TRIANGLE}",
"\N{BLACK CIRCLE FOR RECORD}",
"\N{INFORMATION SOURCE}",
]
await self._eq_msg_clear(player.fetch("eq_message"))
eq_message = await ctx.send(box(eq.visualise(), lang="ini"))
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
with contextlib.suppress(discord.HTTPException):
await eq_message.add_reaction("\N{INFORMATION SOURCE}")
else:
start_adding_reactions(eq_message, reactions)
eq_msg_with_reacts = await ctx.fetch_message(eq_message.id)
player.store("eq_message", eq_msg_with_reacts)
await self._eq_interact(ctx, player, eq, eq_msg_with_reacts, 0)
@command_equalizer.command(name="delete", aliases=["del", "remove"])
async def command_equalizer_delete(self, ctx: commands.Context, eq_preset: str):
"""Delete a saved eq preset."""
async with self.config.custom("EQUALIZER", ctx.guild.id).eq_presets() as eq_presets:
eq_preset = eq_preset.lower()
try:
if eq_presets[eq_preset][
"author"
] != ctx.author.id and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Delete Preset"),
description=_("You are not the author of that preset setting."),
)
del eq_presets[eq_preset]
except KeyError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Delete Preset"),
description=_(
"{eq_preset} is not in the eq preset list.".format(
eq_preset=eq_preset.capitalize()
)
),
)
except TypeError:
if await self._can_instaskip(ctx, ctx.author):
del eq_presets[eq_preset]
else:
return await self.send_embed_msg(
ctx,
title=_("Unable To Delete Preset"),
description=_("You are not the author of that preset setting."),
)
await self.send_embed_msg(
ctx, title=_("The {preset_name} preset was deleted.".format(preset_name=eq_preset))
)
@command_equalizer.command(name="list")
async def command_equalizer_list(self, ctx: commands.Context):
"""List saved eq presets."""
eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets()
if not eq_presets.keys():
return await self.send_embed_msg(ctx, title=_("No saved equalizer presets."))
space = "\N{EN SPACE}"
header_name = _("Preset Name")
header_author = _("Author")
header = box(
"[{header_name}]{space}[{header_author}]\n".format(
header_name=header_name, space=space * 9, header_author=header_author
),
lang="ini",
)
preset_list = ""
for preset, bands in eq_presets.items():
try:
author = self.bot.get_user(bands["author"])
except TypeError:
author = "None"
msg = f"{preset}{space * (22 - len(preset))}{author}\n"
preset_list += msg
page_list = []
colour = await ctx.embed_colour()
for page in pagify(preset_list, delims=[", "], page_length=1000):
formatted_page = box(page, lang="ini")
embed = discord.Embed(colour=colour, description=f"{header}\n{formatted_page}")
embed.set_footer(
text=_("{num} preset(s)").format(num=humanize_number(len(list(eq_presets.keys()))))
)
page_list.append(embed)
await menu(ctx, page_list, DEFAULT_CONTROLS)
@command_equalizer.command(name="load")
async def command_equalizer_load(self, ctx: commands.Context, eq_preset: str):
"""Load a saved eq preset."""
eq_preset = eq_preset.lower()
eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets()
try:
eq_values = eq_presets[eq_preset]["bands"]
except KeyError:
return await self.send_embed_msg(
ctx,
title=_("No Preset Found"),
description=_(
"Preset named {eq_preset} does not exist.".format(eq_preset=eq_preset)
),
)
except TypeError:
eq_values = eq_presets[eq_preset]
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
player = lavalink.get_player(ctx.guild.id)
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Load Preset"),
description=_("You need the DJ role to load equalizer presets."),
)
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq_values)
await self._eq_check(ctx, player)
eq = player.fetch("eq", Equalizer())
await self._eq_msg_clear(player.fetch("eq_message"))
message = await ctx.send(
content=box(eq.visualise(), lang="ini"),
embed=discord.Embed(
colour=await ctx.embed_colour(),
title=_("The {eq_preset} preset was loaded.".format(eq_preset=eq_preset)),
),
)
player.store("eq_message", message)
@command_equalizer.command(name="reset")
async def command_equalizer_reset(self, ctx: commands.Context):
"""Reset the eq to 0 across all bands."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Modify Preset"),
description=_("You need the DJ role to reset the equalizer."),
)
player = lavalink.get_player(ctx.guild.id)
eq = player.fetch("eq", Equalizer())
for band in range(eq.band_count):
eq.set_gain(band, 0.0)
await self._apply_gains(ctx.guild.id, eq.bands)
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
player.store("eq", eq)
await self._eq_msg_clear(player.fetch("eq_message"))
message = await ctx.send(
content=box(eq.visualise(), lang="ini"),
embed=discord.Embed(
colour=await ctx.embed_colour(), title=_("Equalizer values have been reset.")
),
)
player.store("eq_message", message)
@command_equalizer.command(name="save")
@commands.cooldown(1, 15, commands.BucketType.guild)
async def command_equalizer_save(self, ctx: commands.Context, eq_preset: str = None):
"""Save the current eq settings to a preset."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Save Preset"),
description=_("You need the DJ role to save equalizer presets."),
)
if not eq_preset:
await self.send_embed_msg(
ctx, title=_("Please enter a name for this equalizer preset.")
)
try:
eq_name_msg = await self.bot.wait_for(
"message",
timeout=15.0,
check=MessagePredicate.regex(fr"^(?!{re.escape(ctx.prefix)})", ctx),
)
eq_preset = eq_name_msg.content.split(" ")[0].strip('"').lower()
except asyncio.TimeoutError:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Save Preset"),
description=_(
"No equalizer preset name entered, try the command again later."
),
)
eq_preset = eq_preset or ""
eq_exists_msg = None
eq_preset = eq_preset.lower().lstrip(ctx.prefix)
eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets()
eq_list = list(eq_presets.keys())
if len(eq_preset) > 20:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Save Preset"),
description=_("Try the command again with a shorter name."),
)
if eq_preset in eq_list:
eq_exists_msg = await self.send_embed_msg(
ctx, title=_("Preset name already exists, do you want to replace it?")
)
start_adding_reactions(eq_exists_msg, ReactionPredicate.YES_OR_NO_EMOJIS)
pred = ReactionPredicate.yes_or_no(eq_exists_msg, ctx.author)
await self.bot.wait_for("reaction_add", check=pred)
if not pred.result:
await self._clear_react(eq_exists_msg)
embed2 = discord.Embed(
colour=await ctx.embed_colour(), title=_("Not saving preset.")
)
ctx.command.reset_cooldown(ctx)
return await eq_exists_msg.edit(embed=embed2)
player = lavalink.get_player(ctx.guild.id)
eq = player.fetch("eq", Equalizer())
to_append = {eq_preset: {"author": ctx.author.id, "bands": eq.bands}}
new_eq_presets = {**eq_presets, **to_append}
await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets.set(new_eq_presets)
embed3 = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Current equalizer saved to the {preset_name} preset.").format(
preset_name=eq_preset
),
)
if eq_exists_msg:
await self._clear_react(eq_exists_msg)
await eq_exists_msg.edit(embed=embed3)
else:
await self.send_embed_msg(ctx, embed=embed3)
@command_equalizer.command(name="set")
async def command_equalizer_set(
self, ctx: commands.Context, band_name_or_position, band_value: float
):
"""Set an eq band with a band number or name and value.
Band positions are 1-15 and values have a range of -0.25 to 1.0.
Band names are 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k,
6.3k, 10k, and 16k Hz.
Setting a band value to -0.25 nullifies it while +0.25 is double.
"""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Set Preset"),
description=_("You need the DJ role to set equalizer presets."),
)
player = lavalink.get_player(ctx.guild.id)
band_names = [
"25",
"40",
"63",
"100",
"160",
"250",
"400",
"630",
"1k",
"1.6k",
"2.5k",
"4k",
"6.3k",
"10k",
"16k",
]
eq = player.fetch("eq", Equalizer())
bands_num = eq.band_count
if band_value > 1:
band_value = 1
elif band_value <= -0.25:
band_value = -0.25
else:
band_value = round(band_value, 1)
try:
band_number = int(band_name_or_position) - 1
except ValueError:
band_number = 1000
if band_number not in range(0, bands_num) and band_name_or_position not in band_names:
return await self.send_embed_msg(
ctx,
title=_("Invalid Band"),
description=_(
"Valid band numbers are 1-15 or the band names listed in "
"the help for this command."
),
)
if band_name_or_position in band_names:
band_pos = band_names.index(band_name_or_position)
band_int = False
eq.set_gain(int(band_pos), band_value)
await self._apply_gain(ctx.guild.id, int(band_pos), band_value)
else:
band_int = True
eq.set_gain(band_number, band_value)
await self._apply_gain(ctx.guild.id, band_number, band_value)
await self._eq_msg_clear(player.fetch("eq_message"))
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
player.store("eq", eq)
band_name = band_names[band_number] if band_int else band_name_or_position
message = await ctx.send(
content=box(eq.visualise(), lang="ini"),
embed=discord.Embed(
colour=await ctx.embed_colour(),
title=_("Preset Modified"),
description=_("The {band_name}Hz band has been set to {band_value}.").format(
band_name=band_name, band_value=band_value
),
),
)
player.store("eq_message", message)

View File

@ -0,0 +1,168 @@
import logging
import discord
from redbot.core import commands
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.lavalink_setup")
class LavalinkSetupCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="llsetup", aliases=["llset"])
@commands.is_owner()
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_llsetup(self, ctx: commands.Context):
"""Lavalink server configuration options."""
@command_llsetup.command(name="external")
async def command_llsetup_external(self, ctx: commands.Context):
"""Toggle using external Lavalink servers."""
external = await self.config.use_external_lavalink()
await self.config.use_external_lavalink.set(not external)
if external:
embed = discord.Embed(
title=_("Setting Changed"),
description=_("External Lavalink server: {true_or_false}.").format(
true_or_false=_("Enabled") if not external else _("Disabled")
),
)
await self.send_embed_msg(ctx, embed=embed)
else:
try:
if self.player_manager is not None:
await self.player_manager.shutdown()
except ProcessLookupError:
await self.send_embed_msg(
ctx,
title=_("Failed To Shutdown Lavalink"),
description=_(
"External Lavalink server: {true_or_false}\n"
"For it to take effect please reload "
"Audio (`{prefix}reload audio`)."
).format(
true_or_false=_("Enabled") if not external else _("Disabled"),
prefix=ctx.prefix,
),
)
else:
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("External Lavalink server: {true_or_false}.").format(
true_or_false=_("Enabled") if not external else _("Disabled")
),
)
try:
self.lavalink_restart_connect()
except ProcessLookupError:
await self.send_embed_msg(
ctx,
title=_("Failed To Shutdown Lavalink"),
description=_("Please reload Audio (`{prefix}reload audio`).").format(
prefix=ctx.prefix
),
)
@command_llsetup.command(name="host")
async def command_llsetup_host(self, ctx: commands.Context, host: str):
"""Set the Lavalink server host."""
await self.config.host.set(host)
footer = None
if await self.update_external_status():
footer = _("External Lavalink server set to True.")
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("Host set to {host}.").format(host=host),
footer=footer,
)
try:
self.lavalink_restart_connect()
except ProcessLookupError:
await self.send_embed_msg(
ctx,
title=_("Failed To Shutdown Lavalink"),
description=_("Please reload Audio (`{prefix}reload audio`).").format(
prefix=ctx.prefix
),
)
@command_llsetup.command(name="password")
async def command_llsetup_password(self, ctx: commands.Context, password: str):
"""Set the Lavalink server password."""
await self.config.password.set(str(password))
footer = None
if await self.update_external_status():
footer = _("External Lavalink server set to True.")
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("Server password set to {password}.").format(password=password),
footer=footer,
)
try:
self.lavalink_restart_connect()
except ProcessLookupError:
await self.send_embed_msg(
ctx,
title=_("Failed To Shutdown Lavalink"),
description=_("Please reload Audio (`{prefix}reload audio`).").format(
prefix=ctx.prefix
),
)
@command_llsetup.command(name="restport")
async def command_llsetup_restport(self, ctx: commands.Context, rest_port: int):
"""Set the Lavalink REST server port."""
await self.config.rest_port.set(rest_port)
footer = None
if await self.update_external_status():
footer = _("External Lavalink server set to True.")
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("REST port set to {port}.").format(port=rest_port),
footer=footer,
)
try:
self.lavalink_restart_connect()
except ProcessLookupError:
await self.send_embed_msg(
ctx,
title=_("Failed To Shutdown Lavalink"),
description=_("Please reload Audio (`{prefix}reload audio`).").format(
prefix=ctx.prefix
),
)
@command_llsetup.command(name="wsport")
async def command_llsetup_wsport(self, ctx: commands.Context, ws_port: int):
"""Set the Lavalink websocket server port."""
await self.config.ws_port.set(ws_port)
footer = None
if await self.update_external_status():
footer = _("External Lavalink server set to True.")
await self.send_embed_msg(
ctx,
title=_("Setting Changed"),
description=_("Websocket port set to {port}.").format(port=ws_port),
footer=footer,
)
try:
self.lavalink_restart_connect()
except ProcessLookupError:
await self.send_embed_msg(
ctx,
title=_("Failed To Shutdown Lavalink"),
description=_("Please reload Audio (`{prefix}reload audio`).").format(
prefix=ctx.prefix
),
)

View File

@ -0,0 +1,118 @@
import contextlib
import logging
import math
from pathlib import Path
from typing import MutableMapping
import discord
from redbot.core import commands
from redbot.core.utils.menus import DEFAULT_CONTROLS, close_menu, menu, next_page, prev_page
from ...audio_dataclasses import LocalPath, Query
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.local_track")
class LocalTrackCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="local")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
async def command_local(self, ctx: commands.Context):
"""Local playback commands."""
@command_local.command(name="folder", aliases=["start"])
async def command_local_folder(self, ctx: commands.Context, *, folder: str = None):
"""Play all songs in a localtracks folder."""
if not await self.localtracks_folder_exists(ctx):
return
if not folder:
await ctx.invoke(self.command_local_play)
else:
folder = folder.strip()
_dir = LocalPath.joinpath(self.local_folder_current_path, folder)
if not _dir.exists():
return await self.send_embed_msg(
ctx,
title=_("Folder Not Found"),
description=_("Localtracks folder named {name} does not exist.").format(
name=folder
),
)
query = Query.process_input(
_dir, self.local_folder_current_path, search_subfolders=True
)
await self._local_play_all(ctx, query, from_search=False if not folder else True)
@command_local.command(name="play")
async def command_local_play(self, ctx: commands.Context):
"""Play a local track."""
if not await self.localtracks_folder_exists(ctx):
return
localtracks_folders = await self.get_localtracks_folders(ctx, search_subfolders=True)
if not localtracks_folders:
return await self.send_embed_msg(ctx, title=_("No album folders found."))
async with ctx.typing():
len_folder_pages = math.ceil(len(localtracks_folders) / 5)
folder_page_list = []
for page_num in range(1, len_folder_pages + 1):
embed = await self._build_search_page(ctx, localtracks_folders, page_num)
folder_page_list.append(embed)
async def _local_folder_menu(
ctx: commands.Context,
pages: list,
controls: MutableMapping,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
if message:
with contextlib.suppress(discord.HTTPException):
await message.delete()
await self._search_button_action(ctx, localtracks_folders, emoji, page)
return None
local_folder_controls = {
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
"\N{LEFTWARDS BLACK ARROW}": prev_page,
"\N{CROSS MARK}": close_menu,
"\N{BLACK RIGHTWARDS ARROW}": next_page,
}
dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
return await menu(ctx, folder_page_list, DEFAULT_CONTROLS)
else:
await menu(ctx, folder_page_list, local_folder_controls)
@command_local.command(name="search")
async def command_local_search(self, ctx: commands.Context, *, search_words):
"""Search for songs across all localtracks folders."""
if not await self.localtracks_folder_exists(ctx):
return
all_tracks = await self.get_localtrack_folder_list(
ctx,
(
Query.process_input(
Path(await self.config.localpath()).absolute(),
self.local_folder_current_path,
search_subfolders=True,
)
),
)
if not all_tracks:
return await self.send_embed_msg(ctx, title=_("No album folders found."))
async with ctx.typing():
search_list = await self._build_local_search_list(all_tracks, search_words)
if not search_list:
return await self.send_embed_msg(ctx, title=_("No matches."))
return await ctx.invoke(self.command_search, query=search_list)

View File

@ -0,0 +1,138 @@
import datetime
import heapq
import logging
import math
import random
import discord
import lavalink
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.chat_formatting import humanize_number, pagify
from redbot.core.utils.menus import DEFAULT_CONTROLS, menu
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.miscellaneous")
class MiscellaneousCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="sing")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_sing(self, ctx: commands.Context):
"""Make Red sing one of her songs."""
ids = (
"zGTkAVsrfg8",
"cGMWL8cOeAU",
"vFrjMq4aL-g",
"WROI5WYBU_A",
"41tIUr_ex3g",
"f9O2Rjn1azc",
)
url = f"https://www.youtube.com/watch?v={random.choice(ids)}"
await ctx.invoke(self.command_play, query=url)
@commands.command(name="audiostats")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
async def command_audiostats(self, ctx: commands.Context):
"""Audio stats."""
server_num = len(lavalink.active_players())
total_num = len(lavalink.all_players())
msg = ""
async for p in AsyncIter(lavalink.all_players()):
connect_start = p.fetch("connect")
connect_dur = self.get_time_string(
int((datetime.datetime.utcnow() - connect_start).total_seconds())
)
try:
if not p.current:
raise AttributeError
current_title = self.get_track_description(
p.current, self.local_folder_current_path
)
msg += "{} [`{}`]: {}\n".format(p.channel.guild.name, connect_dur, current_title)
except AttributeError:
msg += "{} [`{}`]: **{}**\n".format(
p.channel.guild.name, connect_dur, _("Nothing playing.")
)
if total_num == 0:
return await self.send_embed_msg(ctx, title=_("Not connected anywhere."))
servers_embed = []
pages = 1
for page in pagify(msg, delims=["\n"], page_length=1500):
em = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Playing in {num}/{total} servers:").format(
num=humanize_number(server_num), total=humanize_number(total_num)
),
description=page,
)
em.set_footer(
text=_("Page {}/{}").format(
humanize_number(pages), humanize_number((math.ceil(len(msg) / 1500)))
)
)
pages += 1
servers_embed.append(em)
await menu(ctx, servers_embed, DEFAULT_CONTROLS)
@commands.command(name="percent")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_percent(self, ctx: commands.Context):
"""Queue percentage."""
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
player = lavalink.get_player(ctx.guild.id)
queue_tracks = player.queue
requesters = {"total": 0, "users": {}}
async def _usercount(req_username):
if req_username in requesters["users"]:
requesters["users"][req_username]["songcount"] += 1
requesters["total"] += 1
else:
requesters["users"][req_username] = {}
requesters["users"][req_username]["songcount"] = 1
requesters["total"] += 1
async for track in AsyncIter(queue_tracks):
req_username = "{}#{}".format(track.requester.name, track.requester.discriminator)
await _usercount(req_username)
try:
req_username = "{}#{}".format(
player.current.requester.name, player.current.requester.discriminator
)
await _usercount(req_username)
except AttributeError:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
async for req_username in AsyncIter(requesters["users"]):
percentage = float(requesters["users"][req_username]["songcount"]) / float(
requesters["total"]
)
requesters["users"][req_username]["percent"] = round(percentage * 100, 1)
top_queue_users = heapq.nlargest(
20,
[
(x, requesters["users"][x][y])
for x in requesters["users"]
for y in requesters["users"][x]
if y == "percent"
],
key=lambda x: x[1],
)
queue_user = ["{}: {:g}%".format(x[0], x[1]) for x in top_queue_users]
queue_user_list = "\n".join(queue_user)
await self.send_embed_msg(
ctx, title=_("Queued and playing tracks:"), description=queue_user_list
)

View File

@ -0,0 +1,861 @@
import contextlib
import datetime
import logging
import math
from typing import MutableMapping, Optional
import discord
import lavalink
from discord.embeds import EmptyEmbed
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.menus import DEFAULT_CONTROLS, close_menu, menu, next_page, prev_page
from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query
from ...audio_logging import IS_DEBUG
from ...errors import DatabaseError, QueryUnauthorized, SpotifyFetchError, TrackEnqueueError
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.player")
class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="play")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_play(self, ctx: commands.Context, *, query: str):
"""Play a URL or search for a track."""
query = Query.process_input(query, self.local_folder_current_path)
guild_data = await self.config.guild(ctx.guild).all()
restrict = await self.config.restrict()
if restrict and self.match_url(str(query)):
valid_url = self.is_url_allowed(str(query))
if not valid_url:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("That URL is not allowed."),
)
elif not await self.is_query_allowed(self.config, ctx.guild, f"{query}", query_obj=query):
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.")
)
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("I don't have permission to connect to your channel."),
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connect to a voice channel first."),
)
except IndexError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connection to Lavalink has not yet been established."),
)
can_skip = await self._can_instaskip(ctx, ctx.author)
if guild_data["dj_enabled"] and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You need the DJ role to queue tracks."),
)
player = lavalink.get_player(ctx.guild.id)
player.store("channel", ctx.channel.id)
player.store("guild", ctx.guild.id)
await self._eq_check(ctx, player)
await self.set_player_settings(ctx)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You must be in the voice channel to use the play command."),
)
if not query.valid:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("No tracks found for `{query}`.").format(
query=query.to_string_user()
),
)
if len(player.queue) >= 10000:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.")
)
if not await self.maybe_charge_requester(ctx, guild_data["jukebox_price"]):
return
if query.is_spotify:
return await self._get_spotify_tracks(ctx, query)
try:
await self._enqueue_tracks(ctx, query)
except QueryUnauthorized as err:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=err.message
)
@commands.command(name="bumpplay")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_bumpplay(
self, ctx: commands.Context, play_now: Optional[bool] = False, *, query: str
):
"""Force play a URL or search for a track."""
query = Query.process_input(query, self.local_folder_current_path)
if not query.single_track:
return await self.send_embed_msg(
ctx,
title=_("Unable To Bump Track"),
description=_("Only single tracks work with bump play."),
)
guild_data = await self.config.guild(ctx.guild).all()
restrict = await self.config.restrict()
if restrict and self.match_url(str(query)):
valid_url = self.is_url_allowed(str(query))
if not valid_url:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("That URL is not allowed."),
)
elif not await self.is_query_allowed(self.config, ctx.guild, f"{query}", query_obj=query):
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.")
)
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("I don't have permission to connect to your channel."),
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connect to a voice channel first."),
)
except IndexError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connection to Lavalink has not yet been established."),
)
can_skip = await self._can_instaskip(ctx, ctx.author)
if guild_data["dj_enabled"] and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You need the DJ role to queue tracks."),
)
player = lavalink.get_player(ctx.guild.id)
player.store("channel", ctx.channel.id)
player.store("guild", ctx.guild.id)
await self._eq_check(ctx, player)
await self.set_player_settings(ctx)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You must be in the voice channel to use the play command."),
)
if not query.valid:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("No tracks found for `{query}`.").format(
query=query.to_string_user()
),
)
if len(player.queue) >= 10000:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.")
)
if not await self.maybe_charge_requester(ctx, guild_data["jukebox_price"]):
return
try:
if query.is_spotify:
tracks = await self._get_spotify_tracks(ctx, query)
else:
tracks = await self._enqueue_tracks(ctx, query, enqueue=False)
except QueryUnauthorized as err:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=err.message
)
if isinstance(tracks, discord.Message):
return
elif not tracks:
self.update_player_lock(ctx, False)
title = _("Unable To Play Tracks")
desc = _("No tracks found for `{query}`.").format(query=query.to_string_user())
embed = discord.Embed(title=title, description=desc)
if await self.config.use_external_lavalink() and query.is_local:
embed.description = _(
"Local tracks will not work "
"if the `Lavalink.jar` cannot see the track.\n"
"This may be due to permissions or because Lavalink.jar is being run "
"in a different machine than the local tracks."
)
elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
title = _("Track is not playable.")
embed = discord.Embed(title=title)
embed.description = _(
"**{suffix}** is not a fully supported format and some " "tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
queue_dur = await self.track_remaining_duration(ctx)
index = query.track_index
seek = 0
if query.start_time:
seek = query.start_time
single_track = (
tracks
if isinstance(tracks, lavalink.rest_api.Track)
else tracks[index]
if index
else tracks[0]
)
if seek and seek > 0:
single_track.start_timestamp = seek * 1000
if not await self.is_query_allowed(
self.config,
ctx.guild,
(
f"{single_track.title} {single_track.author} {single_track.uri} "
f"{str(Query.process_input(single_track, self.local_folder_current_path))}"
),
):
if IS_DEBUG:
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("This track is not allowed in this server."),
)
elif guild_data["maxlength"] > 0:
if self.is_track_too_long(single_track, guild_data["maxlength"]):
single_track.requester = ctx.author
player.queue.insert(0, single_track)
player.maybe_shuffle()
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, single_track, ctx.author
)
else:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Track exceeds maximum length."),
)
else:
single_track.requester = ctx.author
single_track.extras["bumped"] = True
player.queue.insert(0, single_track)
player.maybe_shuffle()
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, single_track, ctx.author
)
description = self.get_track_description(single_track, self.local_folder_current_path)
footer = None
if not play_now and not guild_data["shuffle"] and queue_dur > 0:
footer = _("{time} until track playback: #1 in queue").format(
time=self.format_time(queue_dur)
)
await self.send_embed_msg(
ctx, title=_("Track Enqueued"), description=description, footer=footer
)
if not player.current:
await player.play()
elif play_now:
await player.skip()
self.update_player_lock(ctx, False)
@commands.command(name="genre")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def command_genre(self, ctx: commands.Context):
"""Pick a Spotify playlist from a list of categories to start playing."""
async def _category_search_menu(
ctx: commands.Context,
pages: list,
controls: MutableMapping,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
if message:
output = await self._genre_search_button_action(ctx, category_list, emoji, page)
with contextlib.suppress(discord.HTTPException):
await message.delete()
return output
async def _playlist_search_menu(
ctx: commands.Context,
pages: list,
controls: MutableMapping,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
if message:
output = await self._genre_search_button_action(
ctx, playlists_list, emoji, page, playlist=True
)
with contextlib.suppress(discord.HTTPException):
await message.delete()
return output
category_search_controls = {
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
"\N{LEFTWARDS BLACK ARROW}": prev_page,
"\N{CROSS MARK}": close_menu,
"\N{BLACK RIGHTWARDS ARROW}": next_page,
}
playlist_search_controls = {
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
"\N{LEFTWARDS BLACK ARROW}": prev_page,
"\N{CROSS MARK}": close_menu,
"\N{BLACK RIGHTWARDS ARROW}": next_page,
}
api_data = await self._check_api_tokens()
if any([not api_data["spotify_client_id"], not api_data["spotify_client_secret"]]):
return await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=_(
"The owner needs to set the Spotify client ID and Spotify client secret, "
"before Spotify URLs or codes can be used. "
"\nSee `{prefix}audioset spotifyapi` for instructions."
).format(prefix=ctx.prefix),
)
elif not api_data["youtube_api"]:
return await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=_(
"The owner needs to set the YouTube API key before Spotify URLs or "
"codes can be used.\nSee `{prefix}audioset youtubeapi` for instructions."
).format(prefix=ctx.prefix),
)
guild_data = await self.config.guild(ctx.guild).all()
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("I don't have permission to connect to your channel."),
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connect to a voice channel first."),
)
except IndexError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connection to Lavalink has not yet been established."),
)
if guild_data["dj_enabled"] and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You need the DJ role to queue tracks."),
)
player = lavalink.get_player(ctx.guild.id)
player.store("channel", ctx.channel.id)
player.store("guild", ctx.guild.id)
await self._eq_check(ctx, player)
await self.set_player_settings(ctx)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You must be in the voice channel to use the genre command."),
)
try:
category_list = await self.api_interface.spotify_api.get_categories(ctx=ctx)
except SpotifyFetchError as error:
return await self.send_embed_msg(
ctx,
title=_("No categories found"),
description=error.message.format(prefix=ctx.prefix),
)
if not category_list:
return await self.send_embed_msg(ctx, title=_("No categories found, try again later."))
len_folder_pages = math.ceil(len(category_list) / 5)
category_search_page_list = []
async for page_num in AsyncIter(range(1, len_folder_pages + 1)):
embed = await self._build_genre_search_page(
ctx, category_list, page_num, _("Categories")
)
category_search_page_list.append(embed)
cat_menu_output = await menu(ctx, category_search_page_list, category_search_controls)
if not cat_menu_output:
return await self.send_embed_msg(
ctx, title=_("No categories selected, try again later.")
)
category_name, category_pick = cat_menu_output
playlists_list = await self.api_interface.spotify_api.get_playlist_from_category(
category_pick, ctx=ctx
)
if not playlists_list:
return await self.send_embed_msg(ctx, title=_("No categories found, try again later."))
len_folder_pages = math.ceil(len(playlists_list) / 5)
playlists_search_page_list = []
async for page_num in AsyncIter(range(1, len_folder_pages + 1)):
embed = await self._build_genre_search_page(
ctx,
playlists_list,
page_num,
_("Playlists for {friendly_name}").format(friendly_name=category_name),
playlist=True,
)
playlists_search_page_list.append(embed)
playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls)
query = Query.process_input(playlists_pick, self.local_folder_current_path)
if not query.valid:
return await self.send_embed_msg(ctx, title=_("No tracks to play."))
if len(player.queue) >= 10000:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.")
)
if not await self.maybe_charge_requester(ctx, guild_data["jukebox_price"]):
return
if query.is_spotify:
return await self._get_spotify_tracks(ctx, query)
return await self.send_embed_msg(
ctx, title=_("Couldn't find tracks for the selected playlist.")
)
@commands.command(name="autoplay")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
@commands.mod_or_permissions(manage_guild=True)
async def command_autoplay(self, ctx: commands.Context):
"""Starts auto play."""
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("I don't have permission to connect to your channel."),
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connect to a voice channel first."),
)
except IndexError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("Connection to Lavalink has not yet been established."),
)
guild_data = await self.config.guild(ctx.guild).all()
if guild_data["dj_enabled"] and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You need the DJ role to queue tracks."),
)
player = lavalink.get_player(ctx.guild.id)
player.store("channel", ctx.channel.id)
player.store("guild", ctx.guild.id)
await self._eq_check(ctx, player)
await self.set_player_settings(ctx)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not await self._can_instaskip(ctx, ctx.author):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You must be in the voice channel to use the autoplay command."),
)
if len(player.queue) >= 10000:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.")
)
if not await self.maybe_charge_requester(ctx, guild_data["jukebox_price"]):
return
try:
await self.api_interface.autoplay(player, self.playlist_api)
except DatabaseError:
notify_channel = player.fetch("channel")
if notify_channel:
notify_channel = self.bot.get_channel(notify_channel)
await self.send_embed_msg(notify_channel, title=_("Couldn't get a valid track."))
return
if not guild_data["auto_play"]:
await ctx.invoke(self.command_audioset_autoplay_toggle)
if not guild_data["notify"] and (
(player.current and not player.current.extras.get("autoplay")) or not player.current
):
await self.send_embed_msg(ctx, title=_("Auto play started."))
elif player.current:
await self.send_embed_msg(ctx, title=_("Adding a track to queue."))
@commands.command(name="search")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
async def command_search(self, ctx: commands.Context, *, query: str):
"""Pick a track with a search.
Use `[p]search list <search term>` to queue all tracks found on YouTube.
Use `[p]search sc<search term>` will search SoundCloud instead of YouTube.
"""
async def _search_menu(
ctx: commands.Context,
pages: list,
controls: MutableMapping,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
if message:
await self._search_button_action(ctx, tracks, emoji, page)
with contextlib.suppress(discord.HTTPException):
await message.delete()
return None
search_controls = {
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
"\N{LEFTWARDS BLACK ARROW}": prev_page,
"\N{CROSS MARK}": close_menu,
"\N{BLACK RIGHTWARDS ARROW}": next_page,
}
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Search For Tracks"),
description=_("I don't have permission to connect to your channel."),
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Search For Tracks"),
description=_("Connect to a voice channel first."),
)
except IndexError:
return await self.send_embed_msg(
ctx,
title=_("Unable To Search For Tracks"),
description=_("Connection to Lavalink has not yet been established."),
)
player = lavalink.get_player(ctx.guild.id)
guild_data = await self.config.guild(ctx.guild).all()
player.store("channel", ctx.channel.id)
player.store("guild", ctx.guild.id)
can_skip = await self._can_instaskip(ctx, ctx.author)
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Search For Tracks"),
description=_("You must be in the voice channel to enqueue tracks."),
)
await self._eq_check(ctx, player)
await self.set_player_settings(ctx)
before_queue_length = len(player.queue)
if not isinstance(query, list):
query = Query.process_input(query, self.local_folder_current_path)
restrict = await self.config.restrict()
if restrict and self.match_url(str(query)):
valid_url = self.is_url_allowed(str(query))
if not valid_url:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("That URL is not allowed."),
)
if not await self.is_query_allowed(
self.config, ctx.guild, f"{query}", query_obj=query
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("That track is not allowed."),
)
if query.invoked_from == "search list" or query.invoked_from == "local folder":
if query.invoked_from == "search list" and not query.is_local:
try:
result, called_api = await self.api_interface.fetch_track(
ctx, player, query
)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, "
"try again in a few minutes."
),
)
tracks = result.tracks
else:
try:
query.search_subfolders = True
tracks = await self.get_localtrack_folder_tracks(ctx, player, query)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, "
"try again in a few minutes."
),
)
if not tracks:
embed = discord.Embed(title=_("Nothing found."))
if await self.config.use_external_lavalink() and query.is_local:
embed.description = _(
"Local tracks will not work "
"if the `Lavalink.jar` cannot see the track.\n"
"This may be due to permissions or because Lavalink.jar is being run "
"in a different machine than the local tracks."
)
elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
embed = discord.Embed(title=_("Track is not playable."))
embed.description = _(
"**{suffix}** is not a fully supported format and some "
"tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
queue_dur = await self.queue_duration(ctx)
queue_total_duration = self.format_time(queue_dur)
if guild_data["dj_enabled"] and not can_skip:
return await self.send_embed_msg(
ctx,
title=_("Unable To Play Tracks"),
description=_("You need the DJ role to queue tracks."),
)
track_len = 0
empty_queue = not player.queue
async for track in AsyncIter(tracks):
if len(player.queue) >= 10000:
continue
if not await self.is_query_allowed(
self.config,
ctx.guild,
(
f"{track.title} {track.author} {track.uri} "
f"{str(Query.process_input(track, self.local_folder_current_path))}"
),
):
if IS_DEBUG:
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
continue
elif guild_data["maxlength"] > 0:
if self.is_track_too_long(track, guild_data["maxlength"]):
track_len += 1
player.add(ctx.author, track)
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, track, ctx.author
)
else:
track_len += 1
player.add(ctx.author, track)
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, track, ctx.author
)
if not player.current:
await player.play()
player.maybe_shuffle(0 if empty_queue else 1)
if len(tracks) > track_len:
maxlength_msg = _(" {bad_tracks} tracks cannot be queued.").format(
bad_tracks=(len(tracks) - track_len)
)
else:
maxlength_msg = ""
songembed = discord.Embed(
title=_("Queued {num} track(s).{maxlength_msg}").format(
num=track_len, maxlength_msg=maxlength_msg
)
)
if not guild_data["shuffle"] and queue_dur > 0:
if query.is_local and query.is_album:
footer = _("folder")
else:
footer = _("search")
songembed.set_footer(
text=_(
"{time} until start of {type} playback: starts at #{position} in queue"
).format(
time=queue_total_duration,
position=before_queue_length + 1,
type=footer,
)
)
return await self.send_embed_msg(ctx, embed=songembed)
elif query.is_local and query.single_track:
tracks = await self.get_localtrack_folder_list(ctx, query)
elif query.is_local and query.is_album:
if ctx.invoked_with == "folder":
return await self._local_play_all(ctx, query, from_search=True)
else:
tracks = await self.get_localtrack_folder_list(ctx, query)
else:
try:
result, called_api = await self.api_interface.fetch_track(ctx, player, query)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment,"
"try again in a few minutes."
),
)
tracks = result.tracks
if not tracks:
embed = discord.Embed(title=_("Nothing found."))
if await self.config.use_external_lavalink() and query.is_local:
embed.description = _(
"Local tracks will not work "
"if the `Lavalink.jar` cannot see the track.\n"
"This may be due to permissions or because Lavalink.jar is being run "
"in a different machine than the local tracks."
)
elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
embed = discord.Embed(title=_("Track is not playable."))
embed.description = _(
"**{suffix}** is not a fully supported format and some "
"tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
else:
tracks = query
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
len_search_pages = math.ceil(len(tracks) / 5)
search_page_list = []
async for page_num in AsyncIter(range(1, len_search_pages + 1)):
embed = await self._build_search_page(ctx, tracks, page_num)
search_page_list.append(embed)
if dj_enabled and not can_skip:
return await menu(ctx, search_page_list, DEFAULT_CONTROLS)
await menu(ctx, search_page_list, search_controls)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,359 @@
import asyncio
import contextlib
import datetime
import logging
import math
from typing import MutableMapping, Optional, Union, Tuple
import discord
import lavalink
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.menus import (
DEFAULT_CONTROLS,
close_menu,
menu,
next_page,
prev_page,
start_adding_reactions,
)
from redbot.core.utils.predicates import ReactionPredicate
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Commands.queue")
class QueueCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="queue", invoke_without_command=True)
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
async def command_queue(self, ctx: commands.Context, *, page: int = 1):
"""List the songs in the queue."""
async def _queue_menu(
ctx: commands.Context,
pages: list,
controls: MutableMapping,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
if message:
await ctx.send_help(self.command_queue)
with contextlib.suppress(discord.HTTPException):
await message.delete()
return None
queue_controls = {
"\N{LEFTWARDS BLACK ARROW}": prev_page,
"\N{CROSS MARK}": close_menu,
"\N{BLACK RIGHTWARDS ARROW}": next_page,
"\N{INFORMATION SOURCE}": _queue_menu,
}
if not self._player_check(ctx):
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
player = lavalink.get_player(ctx.guild.id)
if player.current and not player.queue:
arrow = await self.draw_time(ctx)
pos = self.format_time(player.position)
if player.current.is_stream:
dur = "LIVE"
else:
dur = self.format_time(player.current.length)
song = self.get_track_description(player.current, self.local_folder_current_path) or ""
song += _("\n Requested by: **{track.requester}**")
song += "\n\n{arrow}`{pos}`/`{dur}`"
song = song.format(track=player.current, arrow=arrow, pos=pos, dur=dur)
embed = discord.Embed(title=_("Now Playing"), description=song)
guild_data = await self.config.guild(ctx.guild).all()
if guild_data["thumbnail"] and player.current and player.current.thumbnail:
embed.set_thumbnail(url=player.current.thumbnail)
shuffle = guild_data["shuffle"]
repeat = guild_data["repeat"]
autoplay = guild_data["auto_play"]
text = ""
text += (
_("Auto-Play")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}")
)
text += (
(" | " if text else "")
+ _("Shuffle")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}")
)
text += (
(" | " if text else "")
+ _("Repeat")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}")
)
embed.set_footer(text=text)
message = await self.send_embed_msg(ctx, embed=embed)
dj_enabled = self._dj_status_cache.setdefault(ctx.guild.id, guild_data["dj_enabled"])
vote_enabled = guild_data["vote_enabled"]
if (
(dj_enabled or vote_enabled)
and not await self._can_instaskip(ctx, ctx.author)
and not await self.is_requester_alone(ctx)
):
return
expected: Union[Tuple[str, ...]] = ("", "", "", "", "\N{CROSS MARK}")
emoji = {
"prev": "",
"stop": "",
"pause": "",
"next": "",
"close": "\N{CROSS MARK}",
}
if not player.queue and not autoplay:
expected = ("", "", "\N{CROSS MARK}")
if player.current:
task: Optional[asyncio.Task] = start_adding_reactions(message, expected[:5])
else:
task: Optional[asyncio.Task] = None
try:
(r, u) = await self.bot.wait_for(
"reaction_add",
check=ReactionPredicate.with_emojis(expected, message, ctx.author),
timeout=30.0,
)
except asyncio.TimeoutError:
return await self._clear_react(message, emoji)
else:
if task is not None:
task.cancel()
reacts = {v: k for k, v in emoji.items()}
react = reacts[r.emoji]
if react == "prev":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_prev)
elif react == "stop":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_stop)
elif react == "pause":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_pause)
elif react == "next":
await self._clear_react(message, emoji)
await ctx.invoke(self.command_skip)
elif react == "close":
await message.delete()
return
elif not player.current and not player.queue:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
async with ctx.typing():
limited_queue = player.queue[:500] # TODO: Improve when Toby menu's are merged
len_queue_pages = math.ceil(len(limited_queue) / 10)
queue_page_list = []
async for page_num in AsyncIter(range(1, len_queue_pages + 1)):
embed = await self._build_queue_page(ctx, limited_queue, player, page_num)
queue_page_list.append(embed)
if page > len_queue_pages:
page = len_queue_pages
return await menu(ctx, queue_page_list, queue_controls, page=(page - 1))
@command_queue.command(name="clear")
async def command_queue_clear(self, ctx: commands.Context):
"""Clears the queue."""
try:
player = lavalink.get_player(ctx.guild.id)
except KeyError:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if not self._player_check(ctx) or not player.queue:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
if (
dj_enabled
and not await self._can_instaskip(ctx, ctx.author)
and not await self.is_requester_alone(ctx)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Clear Queue"),
description=_("You need the DJ role to clear the queue."),
)
player.queue.clear()
await self.send_embed_msg(
ctx, title=_("Queue Modified"), description=_("The queue has been cleared.")
)
@command_queue.command(name="clean")
async def command_queue_clean(self, ctx: commands.Context):
"""Removes songs from the queue if the requester is not in the voice channel."""
try:
player = lavalink.get_player(ctx.guild.id)
except KeyError:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if not self._player_check(ctx) or not player.queue:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
if (
dj_enabled
and not await self._can_instaskip(ctx, ctx.author)
and not await self.is_requester_alone(ctx)
):
return await self.send_embed_msg(
ctx,
title=_("Unable To Clean Queue"),
description=_("You need the DJ role to clean the queue."),
)
clean_tracks = []
removed_tracks = 0
listeners = player.channel.members
async for track in AsyncIter(player.queue):
if track.requester in listeners:
clean_tracks.append(track)
else:
removed_tracks += 1
player.queue = clean_tracks
if removed_tracks == 0:
await self.send_embed_msg(ctx, title=_("Removed 0 tracks."))
else:
await self.send_embed_msg(
ctx,
title=_("Removed Tracks From The Queue"),
description=_(
"Removed {removed_tracks} tracks queued by members "
"outside of the voice channel."
).format(removed_tracks=removed_tracks),
)
@command_queue.command(name="cleanself")
async def command_queue_cleanself(self, ctx: commands.Context):
"""Removes all tracks you requested from the queue."""
try:
player = lavalink.get_player(ctx.guild.id)
except KeyError:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
if not self._player_check(ctx) or not player.queue:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
clean_tracks = []
removed_tracks = 0
async for track in AsyncIter(player.queue):
if track.requester != ctx.author:
clean_tracks.append(track)
else:
removed_tracks += 1
player.queue = clean_tracks
if removed_tracks == 0:
await self.send_embed_msg(ctx, title=_("Removed 0 tracks."))
else:
await self.send_embed_msg(
ctx,
title=_("Removed Tracks From The Queue"),
description=_(
"Removed {removed_tracks} tracks queued by {member.display_name}."
).format(removed_tracks=removed_tracks, member=ctx.author),
)
@command_queue.command(name="search")
async def command_queue_search(self, ctx: commands.Context, *, search_words: str):
"""Search the queue."""
try:
player = lavalink.get_player(ctx.guild.id)
except KeyError:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
if not self._player_check(ctx) or not player.queue:
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
search_list = await self._build_queue_search_list(player.queue, search_words)
if not search_list:
return await self.send_embed_msg(ctx, title=_("No matches."))
len_search_pages = math.ceil(len(search_list) / 10)
search_page_list = []
async for page_num in AsyncIter(range(1, len_search_pages + 1)):
embed = await self._build_queue_search_page(ctx, page_num, search_list)
search_page_list.append(embed)
await menu(ctx, search_page_list, DEFAULT_CONTROLS)
@command_queue.command(name="shuffle")
@commands.cooldown(1, 30, commands.BucketType.guild)
async def command_queue_shuffle(self, ctx: commands.Context):
"""Shuffles the queue."""
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if (
dj_enabled
and not await self._can_instaskip(ctx, ctx.author)
and not await self.is_requester_alone(ctx)
):
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("You need the DJ role to shuffle the queue."),
)
if not self._player_check(ctx):
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("There's nothing in the queue."),
)
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("I don't have permission to connect to your channel."),
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("Connect to a voice channel first."),
)
except IndexError:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("Connection to Lavalink has not yet been established."),
)
except KeyError:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("There's nothing in the queue."),
)
if not self._player_check(ctx) or not player.queue:
ctx.command.reset_cooldown(ctx)
return await self.send_embed_msg(
ctx,
title=_("Unable To Shuffle Queue"),
description=_("There's nothing in the queue."),
)
player.force_shuffle(0)
return await self.send_embed_msg(ctx, title=_("Queue has been shuffled."))

View File

@ -0,0 +1,13 @@
import logging
from ..cog_utils import CompositeMetaClass
from .cog import AudioEvents
from .dpy import DpyEvents
from .lavalink import LavalinkEvents
from .red import RedEvents
log = logging.getLogger("red.cogs.Audio.cog.Events")
class Events(AudioEvents, DpyEvents, LavalinkEvents, RedEvents, metaclass=CompositeMetaClass):
"""Class joining all event subclasses"""

View File

@ -0,0 +1,147 @@
import asyncio
import datetime
import logging
import time
from typing import Optional
import discord
import lavalink
from redbot.core import commands
from ...apis.playlist_interface import Playlist, delete_playlist, get_playlist
from ...audio_logging import debug_exc_log
from ...utils import PlaylistScope
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Events.audio")
class AudioEvents(MixinMeta, metaclass=CompositeMetaClass):
@commands.Cog.listener()
async def on_red_audio_track_start(
self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member
):
if not (track and guild):
return
track_identifier = track.track_identifier
if self.playlist_api is not None:
daily_cache = self._daily_playlist_cache.setdefault(
guild.id, await self.config.guild(guild).daily_playlists()
)
global_daily_playlists = self._daily_global_playlist_cache.setdefault(
self.bot.user.id, await self.config.daily_playlists()
)
today = datetime.date.today()
midnight = datetime.datetime.combine(today, datetime.datetime.min.time())
today_id = int(time.mktime(today.timetuple()))
track = self.track_to_json(track)
if daily_cache:
name = f"Daily playlist - {today}"
playlist: Optional[Playlist]
try:
playlist = await get_playlist(
playlist_api=self.playlist_api,
playlist_number=today_id,
scope=PlaylistScope.GUILD.value,
bot=self.bot,
guild=guild,
author=self.bot.user,
)
except RuntimeError:
playlist = None
if playlist:
tracks = playlist.tracks
tracks.append(track)
await playlist.edit({"tracks": tracks})
else:
playlist = Playlist(
bot=self.bot,
scope=PlaylistScope.GUILD.value,
author=self.bot.user.id,
playlist_id=today_id,
name=name,
playlist_url=None,
tracks=[track],
guild=guild,
playlist_api=self.playlist_api,
)
await playlist.save()
if global_daily_playlists:
global_name = f"Global Daily playlist - {today}"
try:
playlist = await get_playlist(
playlist_number=today_id,
scope=PlaylistScope.GLOBAL.value,
bot=self.bot,
guild=guild,
author=self.bot.user,
playlist_api=self.playlist_api,
)
except RuntimeError:
playlist = None
if playlist:
tracks = playlist.tracks
tracks.append(track)
await playlist.edit({"tracks": tracks})
else:
playlist = Playlist(
bot=self.bot,
scope=PlaylistScope.GLOBAL.value,
author=self.bot.user.id,
playlist_id=today_id,
name=global_name,
playlist_url=None,
tracks=[track],
guild=guild,
playlist_api=self.playlist_api,
)
await playlist.save()
too_old = midnight - datetime.timedelta(days=8)
too_old_id = int(time.mktime(too_old.timetuple()))
try:
await delete_playlist(
scope=PlaylistScope.GUILD.value,
playlist_id=too_old_id,
guild=guild,
author=self.bot.user,
playlist_api=self.playlist_api,
bot=self.bot,
)
except Exception as err:
debug_exc_log(log, err, f"Failed to delete daily playlist ID: {too_old_id}")
try:
await delete_playlist(
scope=PlaylistScope.GLOBAL.value,
playlist_id=too_old_id,
guild=guild,
author=self.bot.user,
playlist_api=self.playlist_api,
bot=self.bot,
)
except Exception as err:
debug_exc_log(log, err, f"Failed to delete global daily playlist ID: {too_old_id}")
@commands.Cog.listener()
async def on_red_audio_queue_end(
self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member
):
if not (track and guild):
return
if self.api_interface is not None and self.playlist_api is not None:
await self.api_interface.local_cache_api.youtube.clean_up_old_entries()
await asyncio.sleep(5)
await self.playlist_api.delete_scheduled()
@commands.Cog.listener()
async def on_red_audio_track_end(
self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member
):
if not (track and guild):
return
if self.api_interface is not None and self.playlist_api is not None:
await self.api_interface.local_cache_api.youtube.clean_up_old_entries()
await asyncio.sleep(5)
await self.playlist_api.delete_scheduled()

View File

@ -0,0 +1,184 @@
import asyncio
import logging
import re
from pathlib import Path
from typing import Final, Pattern
import discord
import lavalink
from aiohttp import ClientConnectorError
from redbot.core import commands
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
from ...audio_logging import debug_exc_log
from ...errors import TrackEnqueueError
log = logging.getLogger("red.cogs.Audio.cog.Events.dpy")
RE_CONVERSION: Final[Pattern] = re.compile('Converting to "(.*)" failed for parameter "(.*)".')
class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
async def cog_before_invoke(self, ctx: commands.Context) -> None:
await self.cog_ready_event.wait()
# check for unsupported arch
# Check on this needs refactoring at a later date
# so that we have a better way to handle the tasks
if self.command_llsetup in [ctx.command, ctx.command.root_parent]:
pass
elif self.lavalink_connect_task and self.lavalink_connect_task.cancelled():
await ctx.send(
_(
"You have attempted to run Audio's Lavalink server on an unsupported"
" architecture. Only settings related commands will be available."
)
)
raise RuntimeError(
"Not running audio command due to invalid machine architecture for Lavalink."
)
# with contextlib.suppress(Exception):
# player = lavalink.get_player(ctx.guild.id)
# notify_channel = player.fetch("channel")
# if not notify_channel:
# player.store("channel", ctx.channel.id)
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
self._daily_playlist_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).daily_playlists()
)
self._daily_global_playlist_cache.setdefault(
self.bot.user.id, await self.config.daily_playlists()
)
if self.local_folder_current_path is None:
self.local_folder_current_path = Path(await self.config.localpath())
if dj_enabled:
dj_role = self._dj_role_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_role()
)
dj_role_obj = ctx.guild.get_role(dj_role)
if not dj_role_obj:
await self.config.guild(ctx.guild).dj_enabled.set(None)
self._dj_status_cache[ctx.guild.id] = None
await self.config.guild(ctx.guild).dj_role.set(None)
self._dj_role_cache[ctx.guild.id] = None
await self.send_embed_msg(ctx, title=_("No DJ role found. Disabling DJ mode."))
async def cog_after_invoke(self, ctx: commands.Context) -> None:
await self.maybe_run_pending_db_tasks(ctx)
async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
error = getattr(error, "original", error)
handled = False
if isinstance(error, commands.ArgParserFailure):
handled = True
msg = _("`{user_input}` is not a valid value for `{command}`").format(
user_input=error.user_input, command=error.cmd,
)
if error.custom_help_msg:
msg += f"\n{error.custom_help_msg}"
await self.send_embed_msg(
ctx, title=_("Unable To Parse Argument"), description=msg, error=True,
)
if error.send_cmd_help:
await ctx.send_help()
elif isinstance(error, commands.ConversionFailure):
handled = True
if error.args:
if match := RE_CONVERSION.search(error.args[0]):
await self.send_embed_msg(
ctx,
title=_("Invalid Argument"),
description=_(
"The argument you gave for `{}` is not valid: I was expecting a `{}`."
).format(match.group(2), match.group(1)),
error=True,
)
else:
await self.send_embed_msg(
ctx, title=_("Invalid Argument"), description=error.args[0], error=True,
)
else:
await ctx.send_help()
elif isinstance(error, (IndexError, ClientConnectorError)) and any(
e in str(error).lower() for e in ["no nodes found.", "cannot connect to host"]
):
handled = True
await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=_("Connection to Lavalink has been lost."),
error=True,
)
debug_exc_log(log, error, "This is a handled error")
elif isinstance(error, KeyError) and "such player for that guild" in str(error):
handled = True
await self.send_embed_msg(
ctx,
title=_("No Player Available"),
description=_("The bot is not connected to a voice channel."),
error=True,
)
debug_exc_log(log, error, "This is a handled error")
elif isinstance(error, (TrackEnqueueError, asyncio.exceptions.TimeoutError)):
handled = True
await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment,"
"try again in a few minutes."
),
error=True,
)
debug_exc_log(log, error, "This is a handled error")
if not isinstance(
error,
(
commands.CheckFailure,
commands.UserInputError,
commands.DisabledCommand,
commands.CommandOnCooldown,
commands.MaxConcurrencyReached,
),
):
self.update_player_lock(ctx, False)
if self.api_interface is not None:
await self.api_interface.run_tasks(ctx)
if not handled:
await self.bot.on_command_error(ctx, error, unhandled_by_cog=True)
def cog_unload(self) -> None:
if not self.cog_cleaned_up:
self.bot.dispatch("red_audio_unload", self)
self.session.detach()
self.bot.loop.create_task(self._close_database())
if self.player_automated_timer_task:
self.player_automated_timer_task.cancel()
if self.lavalink_connect_task:
self.lavalink_connect_task.cancel()
if self.cog_init_task:
self.cog_init_task.cancel()
lavalink.unregister_event_listener(self.lavalink_event_handler)
self.bot.loop.create_task(lavalink.close())
if self.player_manager is not None:
self.bot.loop.create_task(self.player_manager.shutdown())
self.cog_cleaned_up = True
@commands.Cog.listener()
async def on_voice_state_update(
self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState
) -> None:
await self.cog_ready_event.wait()
if after.channel != before.channel:
try:
self.skip_votes[before.channel.guild].remove(member.id)
except (ValueError, KeyError, AttributeError):
pass

View File

@ -0,0 +1,192 @@
import asyncio
import contextlib
import logging
import discord
import lavalink
from ...errors import DatabaseError
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Events.lavalink")
class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass):
async def lavalink_event_handler(
self, player: lavalink.Player, event_type: lavalink.LavalinkEvents, extra
) -> None:
current_track = player.current
current_channel = player.channel
guild = self.rgetattr(current_channel, "guild", None)
guild_id = self.rgetattr(guild, "id", None)
current_requester = self.rgetattr(current_track, "requester", None)
current_stream = self.rgetattr(current_track, "is_stream", None)
current_length = self.rgetattr(current_track, "length", None)
current_thumbnail = self.rgetattr(current_track, "thumbnail", None)
current_extras = self.rgetattr(current_track, "extras", {})
guild_data = await self.config.guild(guild).all()
repeat = guild_data["repeat"]
notify = guild_data["notify"]
disconnect = guild_data["disconnect"]
autoplay = guild_data["auto_play"]
description = self.get_track_description(current_track, self.local_folder_current_path)
status = await self.config.status()
log.debug(f"Received a new lavalink event for {guild_id}: {event_type}: {extra}")
prev_song: lavalink.Track = player.fetch("prev_song")
await self.maybe_reset_error_counter(player)
if event_type == lavalink.LavalinkEvents.TRACK_START:
self.skip_votes[guild] = []
playing_song = player.fetch("playing_song")
requester = player.fetch("requester")
player.store("prev_song", playing_song)
player.store("prev_requester", requester)
player.store("playing_song", current_track)
player.store("requester", current_requester)
self.bot.dispatch("red_audio_track_start", guild, current_track, current_requester)
if event_type == lavalink.LavalinkEvents.TRACK_END:
prev_requester = player.fetch("prev_requester")
self.bot.dispatch("red_audio_track_end", guild, prev_song, prev_requester)
if event_type == lavalink.LavalinkEvents.QUEUE_END:
prev_requester = player.fetch("prev_requester")
self.bot.dispatch("red_audio_queue_end", guild, prev_song, prev_requester)
if (
autoplay
and not player.queue
and player.fetch("playing_song") is not None
and self.playlist_api is not None
and self.api_interface is not None
):
try:
await self.api_interface.autoplay(player, self.playlist_api)
except DatabaseError:
notify_channel = player.fetch("channel")
if notify_channel:
notify_channel = self.bot.get_channel(notify_channel)
await self.send_embed_msg(
notify_channel, title=_("Couldn't get a valid track.")
)
return
if event_type == lavalink.LavalinkEvents.TRACK_START and notify:
notify_channel = player.fetch("channel")
if notify_channel:
notify_channel = self.bot.get_channel(notify_channel)
if player.fetch("notify_message") is not None:
with contextlib.suppress(discord.HTTPException):
await player.fetch("notify_message").delete()
if (
autoplay
and current_extras.get("autoplay")
and (
prev_song is None
or (hasattr(prev_song, "extras") and not prev_song.extras.get("autoplay"))
)
):
await self.send_embed_msg(notify_channel, title=_("Auto Play started."))
if not description:
return
if current_stream:
dur = "LIVE"
else:
dur = self.format_time(current_length)
thumb = None
if await self.config.guild(guild).thumbnail() and current_thumbnail:
thumb = current_thumbnail
notify_message = await self.send_embed_msg(
notify_channel,
title=_("Now Playing"),
description=description,
footer=_("Track length: {length} | Requested by: {user}").format(
length=dur, user=current_requester
),
thumbnail=thumb,
)
player.store("notify_message", notify_message)
if event_type == lavalink.LavalinkEvents.TRACK_START and status:
player_check = self.get_active_player_count()
await self.update_bot_presence(*player_check)
if event_type == lavalink.LavalinkEvents.TRACK_END and status:
await asyncio.sleep(1)
if not player.is_playing:
player_check = self.get_active_player_count()
await self.update_bot_presence(*player_check)
if event_type == lavalink.LavalinkEvents.QUEUE_END:
if not autoplay:
notify_channel = player.fetch("channel")
if notify_channel and notify:
notify_channel = self.bot.get_channel(notify_channel)
await self.send_embed_msg(notify_channel, title=_("Queue ended."))
if disconnect:
self.bot.dispatch("red_audio_audio_disconnect", guild)
await player.disconnect()
if status:
player_check = self.get_active_player_count()
await self.update_bot_presence(*player_check)
if event_type in [
lavalink.LavalinkEvents.TRACK_EXCEPTION,
lavalink.LavalinkEvents.TRACK_STUCK,
]:
message_channel = player.fetch("channel")
while True:
if current_track in player.queue:
player.queue.remove(current_track)
else:
break
if repeat:
player.current = None
if not guild_id:
return
self._error_counter.setdefault(guild_id, 0)
if guild_id not in self._error_counter:
self._error_counter[guild_id] = 0
early_exit = await self.increase_error_counter(player)
if early_exit:
self._disconnected_players[guild_id] = True
self.play_lock[guild_id] = False
eq = player.fetch("eq")
player.queue = []
player.store("playing_song", None)
if eq:
await self.config.custom("EQUALIZER", guild_id).eq_bands.set(eq.bands)
await player.stop()
await player.disconnect()
self.bot.dispatch("red_audio_audio_disconnect", guild)
if message_channel:
message_channel = self.bot.get_channel(message_channel)
if early_exit:
embed = discord.Embed(
colour=await self.bot.get_embed_color(message_channel),
title=_("Multiple Errors Detected"),
description=_(
"Closing the audio player "
"due to multiple errors being detected. "
"If this persists, please inform the bot owner "
"as the Audio cog may be temporally unavailable."
),
)
await message_channel.send(embed=embed)
return
else:
description = description or ""
if event_type == lavalink.LavalinkEvents.TRACK_STUCK:
embed = discord.Embed(
colour=await self.bot.get_embed_color(message_channel),
title=_("Track Stuck"),
description="{}".format(description),
)
else:
embed = discord.Embed(
title=_("Track Error"),
colour=await self.bot.get_embed_color(message_channel),
description="{}\n{}".format(extra.replace("\n", ""), description),
)
await message_channel.send(embed=embed)
await player.skip()

View File

@ -0,0 +1,21 @@
import logging
from typing import Mapping
from redbot.core import commands
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Events.red")
class RedEvents(MixinMeta, metaclass=CompositeMetaClass):
@commands.Cog.listener()
async def on_red_api_tokens_update(
self, service_name: str, api_tokens: Mapping[str, str]
) -> None:
if service_name == "youtube":
self.api_interface.youtube_api.update_token(api_tokens)
elif service_name == "spotify":
self.api_interface.spotify_api.update_token(api_tokens)
elif service_name == "audiodb":
self.api_interface.global_cache_api.update_token(api_tokens)

View File

@ -0,0 +1,12 @@
import logging
from ..cog_utils import CompositeMetaClass
from .lavalink import LavalinkTasks
from .player import PlayerTasks
from .startup import StartUpTasks
log = logging.getLogger("red.cogs.Audio.cog.Tasks")
class Tasks(LavalinkTasks, PlayerTasks, StartUpTasks, metaclass=CompositeMetaClass):
"""Class joining all task subclasses"""

View File

@ -0,0 +1,113 @@
import asyncio
import logging
import lavalink
from ...errors import LavalinkDownloadFailed
from ...manager import ServerManager
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Tasks.lavalink")
class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass):
def lavalink_restart_connect(self) -> None:
if self.lavalink_connect_task:
self.lavalink_connect_task.cancel()
self.lavalink_connect_task = self.bot.loop.create_task(self.lavalink_attempt_connect())
async def lavalink_attempt_connect(self, timeout: int = 50) -> None:
self.lavalink_connection_aborted = False
max_retries = 5
retry_count = 0
while retry_count < max_retries:
external = await self.config.use_external_lavalink()
if external is False:
settings = self._default_lavalink_settings
host = settings["host"]
password = settings["password"]
rest_port = settings["rest_port"]
ws_port = settings["ws_port"]
if self.player_manager is not None:
await self.player_manager.shutdown()
self.player_manager = ServerManager()
try:
await self.player_manager.start()
except LavalinkDownloadFailed as exc:
await asyncio.sleep(1)
if exc.should_retry:
log.exception(
"Exception whilst starting internal Lavalink server, retrying...",
exc_info=exc,
)
retry_count += 1
continue
else:
log.exception(
"Fatal exception whilst starting internal Lavalink server, "
"aborting...",
exc_info=exc,
)
self.lavalink_connection_aborted = True
raise
except asyncio.CancelledError:
log.exception("Invalid machine architecture, cannot run Lavalink.")
raise
except Exception as exc:
log.exception(
"Unhandled exception whilst starting internal Lavalink server, "
"aborting...",
exc_info=exc,
)
self.lavalink_connection_aborted = True
raise
else:
break
else:
config_data = await self.config.all()
host = config_data["host"]
password = config_data["password"]
rest_port = config_data["rest_port"]
ws_port = config_data["ws_port"]
break
else:
log.critical(
"Setting up the Lavalink server failed after multiple attempts. "
"See above tracebacks for details."
)
self.lavalink_connection_aborted = True
return
retry_count = 0
while retry_count < max_retries:
try:
await lavalink.initialize(
bot=self.bot,
host=host,
password=password,
rest_port=rest_port,
ws_port=ws_port,
timeout=timeout,
)
except asyncio.TimeoutError:
log.error("Connecting to Lavalink server timed out, retrying...")
if external is False and self.player_manager is not None:
await self.player_manager.shutdown()
retry_count += 1
await asyncio.sleep(1) # prevent busylooping
except Exception as exc:
log.exception(
"Unhandled exception whilst connecting to Lavalink, aborting...", exc_info=exc
)
self.lavalink_connection_aborted = True
raise
else:
break
else:
self.lavalink_connection_aborted = True
log.critical(
"Connecting to the Lavalink server failed after multiple attempts. "
"See above tracebacks for details."
)

View File

@ -0,0 +1,70 @@
import asyncio
import logging
import time
from typing import Dict
import lavalink
from redbot.core.utils import AsyncIter
from ...audio_logging import debug_exc_log
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Tasks.player")
class PlayerTasks(MixinMeta, metaclass=CompositeMetaClass):
async def player_automated_timer(self) -> None:
stop_times: Dict = {}
pause_times: Dict = {}
while True:
async for p in AsyncIter(lavalink.all_players()):
server = p.channel.guild
if [self.bot.user] == p.channel.members:
stop_times.setdefault(server.id, time.time())
pause_times.setdefault(server.id, time.time())
else:
stop_times.pop(server.id, None)
if p.paused and server.id in pause_times:
try:
await p.pause(False)
except Exception as err:
debug_exc_log(
log,
err,
f"Exception raised in Audio's unpausing player for {server.id}.",
)
pause_times.pop(server.id, None)
servers = stop_times.copy()
servers.update(pause_times)
async for sid in AsyncIter(servers, steps=5):
server_obj = self.bot.get_guild(sid)
if sid in stop_times and await self.config.guild(server_obj).emptydc_enabled():
emptydc_timer = await self.config.guild(server_obj).emptydc_timer()
if (time.time() - stop_times[sid]) >= emptydc_timer:
stop_times.pop(sid)
try:
player = lavalink.get_player(sid)
await player.stop()
await player.disconnect()
except Exception as err:
if "No such player for that guild" in str(err):
stop_times.pop(sid, None)
debug_exc_log(
log, err, f"Exception raised in Audio's emptydc_timer for {sid}."
)
elif (
sid in pause_times and await self.config.guild(server_obj).emptypause_enabled()
):
emptypause_timer = await self.config.guild(server_obj).emptypause_timer()
if (time.time() - pause_times.get(sid, 0)) >= emptypause_timer:
try:
await lavalink.get_player(sid).pause()
except Exception as err:
if "No such player for that guild" in str(err):
pause_times.pop(sid, None)
debug_exc_log(
log, err, f"Exception raised in Audio's pausing for {sid}."
)
await asyncio.sleep(5)

View File

@ -0,0 +1,49 @@
import logging
import lavalink
from redbot.core.data_manager import cog_data_path
from redbot.core.utils.dbtools import APSWConnectionWrapper
from ...apis.interface import AudioAPIInterface
from ...apis.playlist_wrapper import PlaylistWrapper
from ..abc import MixinMeta
from ..cog_utils import _SCHEMA_VERSION, CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Tasks.startup")
class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass):
def start_up_task(self):
# There has to be a task since this requires the bot to be ready
# If it waits for ready in startup, we cause a deadlock during initial load
# as initial load happens before the bot can ever be ready.
self.cog_init_task = self.bot.loop.create_task(self.initialize())
async def initialize(self) -> None:
await self.bot.wait_until_red_ready()
# Unlike most cases, we want the cache to exit before migration.
try:
self.db_conn = APSWConnectionWrapper(
str(cog_data_path(self.bot.get_cog("Audio")) / "Audio.db")
)
self.api_interface = AudioAPIInterface(
self.bot, self.config, self.session, self.db_conn, self.bot.get_cog("Audio")
)
self.playlist_api = PlaylistWrapper(self.bot, self.config, self.db_conn)
await self.playlist_api.init()
await self.api_interface.initialize()
await self.data_schema_migration(
from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION
)
await self.playlist_api.delete_scheduled()
self.lavalink_restart_connect()
self.player_automated_timer_task = self.bot.loop.create_task(
self.player_automated_timer()
)
lavalink.register_event_listener(self.lavalink_event_handler)
except Exception as err:
log.exception("Audio failed to start up, please report this issue.", exc_info=err)
raise err
self.cog_ready_event.set()

View File

@ -0,0 +1,23 @@
from ..cog_utils import CompositeMetaClass
from .equalizer import EqualizerUtilities
from .formatting import FormattingUtilities
from .local_tracks import LocalTrackUtilities
from .miscellaneous import MiscellaneousUtilities
from .player import PlayerUtilities
from .playlists import PlaylistUtilities
from .queue import QueueUtilities
from .validation import ValidationUtilities
class Utilities(
EqualizerUtilities,
FormattingUtilities,
LocalTrackUtilities,
MiscellaneousUtilities,
PlayerUtilities,
PlaylistUtilities,
QueueUtilities,
ValidationUtilities,
metaclass=CompositeMetaClass,
):
"""Class joining all utility subclasses"""

View File

@ -0,0 +1,174 @@
import asyncio
import contextlib
import logging
from typing import List
import discord
import lavalink
from redbot.core import commands
from redbot.core.utils.chat_formatting import box
from ...equalizer import Equalizer
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Utilities.equalizer")
class EqualizerUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def _apply_gain(self, guild_id: int, band: int, gain: float) -> None:
const = {
"op": "equalizer",
"guildId": str(guild_id),
"bands": [{"band": band, "gain": gain}],
}
try:
await lavalink.get_player(guild_id).node.send({**const})
except (KeyError, IndexError):
pass
async def _apply_gains(self, guild_id: int, gains: List[float]) -> None:
const = {
"op": "equalizer",
"guildId": str(guild_id),
"bands": [{"band": x, "gain": y} for x, y in enumerate(gains)],
}
try:
await lavalink.get_player(guild_id).node.send({**const})
except (KeyError, IndexError):
pass
async def _eq_check(self, ctx: commands.Context, player: lavalink.Player) -> None:
eq = player.fetch("eq", Equalizer())
config_bands = await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands()
if not config_bands:
config_bands = eq.bands
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
if eq.bands != config_bands:
band_num = list(range(0, eq.band_count))
band_value = config_bands
eq_dict = {}
for k, v in zip(band_num, band_value):
eq_dict[k] = v
for band, value in eq_dict.items():
eq.set_gain(band, value)
player.store("eq", eq)
await self._apply_gains(ctx.guild.id, config_bands)
async def _eq_interact(
self,
ctx: commands.Context,
player: lavalink.Player,
eq: Equalizer,
message: discord.Message,
selected: int,
) -> None:
player.store("eq", eq)
emoji = {
"far_left": "\N{BLACK LEFT-POINTING TRIANGLE}",
"one_left": "\N{LEFTWARDS BLACK ARROW}",
"max_output": "\N{BLACK UP-POINTING DOUBLE TRIANGLE}",
"output_up": "\N{UP-POINTING SMALL RED TRIANGLE}",
"output_down": "\N{DOWN-POINTING SMALL RED TRIANGLE}",
"min_output": "\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}",
"one_right": "\N{BLACK RIGHTWARDS ARROW}",
"far_right": "\N{BLACK RIGHT-POINTING TRIANGLE}",
"reset": "\N{BLACK CIRCLE FOR RECORD}",
"info": "\N{INFORMATION SOURCE}",
}
selector = f'{" " * 8}{" " * selected}^^'
try:
await message.edit(content=box(f"{eq.visualise()}\n{selector}", lang="ini"))
except discord.errors.NotFound:
return
try:
(react_emoji, react_user) = await self._get_eq_reaction(ctx, message, emoji)
except TypeError:
return
if not react_emoji:
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
await self._clear_react(message, emoji)
if react_emoji == "\N{LEFTWARDS BLACK ARROW}":
await self.remove_react(message, react_emoji, react_user)
await self._eq_interact(ctx, player, eq, message, max(selected - 1, 0))
if react_emoji == "\N{BLACK RIGHTWARDS ARROW}":
await self.remove_react(message, react_emoji, react_user)
await self._eq_interact(ctx, player, eq, message, min(selected + 1, 14))
if react_emoji == "\N{UP-POINTING SMALL RED TRIANGLE}":
await self.remove_react(message, react_emoji, react_user)
_max = float("{:.2f}".format(min(eq.get_gain(selected) + 0.1, 1.0)))
eq.set_gain(selected, _max)
await self._apply_gain(ctx.guild.id, selected, _max)
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{DOWN-POINTING SMALL RED TRIANGLE}":
await self.remove_react(message, react_emoji, react_user)
_min = float("{:.2f}".format(max(eq.get_gain(selected) - 0.1, -0.25)))
eq.set_gain(selected, _min)
await self._apply_gain(ctx.guild.id, selected, _min)
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{BLACK UP-POINTING DOUBLE TRIANGLE}":
await self.remove_react(message, react_emoji, react_user)
_max = 1.0
eq.set_gain(selected, _max)
await self._apply_gain(ctx.guild.id, selected, _max)
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}":
await self.remove_react(message, react_emoji, react_user)
_min = -0.25
eq.set_gain(selected, _min)
await self._apply_gain(ctx.guild.id, selected, _min)
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{BLACK LEFT-POINTING TRIANGLE}":
await self.remove_react(message, react_emoji, react_user)
selected = 0
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{BLACK RIGHT-POINTING TRIANGLE}":
await self.remove_react(message, react_emoji, react_user)
selected = 14
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{BLACK CIRCLE FOR RECORD}":
await self.remove_react(message, react_emoji, react_user)
for band in range(eq.band_count):
eq.set_gain(band, 0.0)
await self._apply_gains(ctx.guild.id, eq.bands)
await self._eq_interact(ctx, player, eq, message, selected)
if react_emoji == "\N{INFORMATION SOURCE}":
await self.remove_react(message, react_emoji, react_user)
await ctx.send_help(self.command_equalizer)
await self._eq_interact(ctx, player, eq, message, selected)
async def _eq_msg_clear(self, eq_message: discord.Message):
if eq_message is not None:
with contextlib.suppress(discord.HTTPException):
await eq_message.delete()
async def _get_eq_reaction(self, ctx: commands.Context, message: discord.Message, emoji):
try:
reaction, user = await self.bot.wait_for(
"reaction_add",
check=lambda r, u: r.message.id == message.id
and u.id == ctx.author.id
and r.emoji in emoji.values(),
timeout=30,
)
except asyncio.TimeoutError:
await self._clear_react(message, emoji)
return None
else:
return reaction.emoji, user

View File

@ -0,0 +1,376 @@
import datetime
import logging
import math
import re
from typing import List, Optional
import discord
import lavalink
from discord.embeds import EmptyEmbed
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.chat_formatting import box, escape
from ...audio_dataclasses import LocalPath, Query
from ...audio_logging import IS_DEBUG
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Utilities.formatting")
RE_SQUARE = re.compile(r"[\[\]]")
class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def _genre_search_button_action(
self, ctx: commands.Context, options: List, emoji: str, page: int, playlist: bool = False
) -> str:
try:
if emoji == "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = options[0 + (page * 5)]
elif emoji == "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = options[1 + (page * 5)]
elif emoji == "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = options[2 + (page * 5)]
elif emoji == "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = options[3 + (page * 5)]
elif emoji == "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = options[4 + (page * 5)]
else:
search_choice = options[0 + (page * 5)]
except IndexError:
search_choice = options[-1]
if not playlist:
return list(search_choice.items())[0]
else:
return search_choice.get("uri")
async def _build_genre_search_page(
self,
ctx: commands.Context,
tracks: List,
page_num: int,
title: str,
playlist: bool = False,
) -> discord.Embed:
search_num_pages = math.ceil(len(tracks) / 5)
search_idx_start = (page_num - 1) * 5
search_idx_end = search_idx_start + 5
search_list = ""
async for i, entry in AsyncIter(tracks[search_idx_start:search_idx_end]).enumerate(
start=search_idx_start
):
search_track_num = i + 1
if search_track_num > 5:
search_track_num = search_track_num % 5
if search_track_num == 0:
search_track_num = 5
if playlist:
name = "**[{}]({})** - {} {}".format(
entry.get("name"), entry.get("url"), str(entry.get("tracks")), _("tracks")
)
else:
name = f"{list(entry.keys())[0]}"
search_list += f"`{search_track_num}.` {name}\n"
embed = discord.Embed(
colour=await ctx.embed_colour(), title=title, description=search_list
)
embed.set_footer(
text=_("Page {page_num}/{total_pages}").format(
page_num=page_num, total_pages=search_num_pages
)
)
return embed
async def _search_button_action(
self, ctx: commands.Context, tracks: List, emoji: str, page: int
):
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed.")
description = EmptyEmbed
if await self.bot.is_owner(ctx.author):
description = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=description)
try:
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await self.send_embed_msg(ctx, title=_("Connect to a voice channel first."))
except IndexError:
return await self.send_embed_msg(
ctx, title=_("Connection to Lavalink has not yet been established.")
)
player = lavalink.get_player(ctx.guild.id)
guild_data = await self.config.guild(ctx.guild).all()
if len(player.queue) >= 10000:
return await self.send_embed_msg(
ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.")
)
if not await self.maybe_charge_requester(ctx, guild_data["jukebox_price"]):
return
try:
if emoji == "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = tracks[0 + (page * 5)]
elif emoji == "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = tracks[1 + (page * 5)]
elif emoji == "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = tracks[2 + (page * 5)]
elif emoji == "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = tracks[3 + (page * 5)]
elif emoji == "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}":
search_choice = tracks[4 + (page * 5)]
else:
search_choice = tracks[0 + (page * 5)]
except IndexError:
search_choice = tracks[-1]
if not hasattr(search_choice, "is_local") and getattr(search_choice, "uri", None):
description = self.get_track_description(search_choice, self.local_folder_current_path)
else:
search_choice = Query.process_input(search_choice, self.local_folder_current_path)
if search_choice.is_local:
if (
search_choice.local_track_path.exists()
and search_choice.local_track_path.is_dir()
):
return await ctx.invoke(self.command_search, query=search_choice)
elif (
search_choice.local_track_path.exists()
and search_choice.local_track_path.is_file()
):
search_choice.invoked_from = "localtrack"
return await ctx.invoke(self.command_play, query=search_choice)
songembed = discord.Embed(title=_("Track Enqueued"), description=description)
queue_dur = await self.queue_duration(ctx)
queue_total_duration = self.format_time(queue_dur)
before_queue_length = len(player.queue)
if not await self.is_query_allowed(
self.config,
ctx.guild,
(
f"{search_choice.title} {search_choice.author} {search_choice.uri} "
f"{str(Query.process_input(search_choice, self.local_folder_current_path))}"
),
):
if IS_DEBUG:
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx, title=_("This track is not allowed in this server.")
)
elif guild_data["maxlength"] > 0:
if self.is_track_too_long(search_choice.length, guild_data["maxlength"]):
player.add(ctx.author, search_choice)
player.maybe_shuffle()
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author
)
else:
return await self.send_embed_msg(ctx, title=_("Track exceeds maximum length."))
else:
player.add(ctx.author, search_choice)
player.maybe_shuffle()
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author
)
if not guild_data["shuffle"] and queue_dur > 0:
songembed.set_footer(
text=_("{time} until track playback: #{position} in queue").format(
time=queue_total_duration, position=before_queue_length + 1
)
)
if not player.current:
await player.play()
return await self.send_embed_msg(ctx, embed=songembed)
def _format_search_options(self, search_choice):
query = Query.process_input(search_choice, self.local_folder_current_path)
description = self.get_track_description(search_choice, self.local_folder_current_path)
return description, query
async def _build_search_page(
self, ctx: commands.Context, tracks: List, page_num: int
) -> discord.Embed:
search_num_pages = math.ceil(len(tracks) / 5)
search_idx_start = (page_num - 1) * 5
search_idx_end = search_idx_start + 5
search_list = ""
command = ctx.invoked_with
folder = False
async for i, track in AsyncIter(tracks[search_idx_start:search_idx_end]).enumerate(
start=search_idx_start
):
search_track_num = i + 1
if search_track_num > 5:
search_track_num = search_track_num % 5
if search_track_num == 0:
search_track_num = 5
try:
query = Query.process_input(track.uri, self.local_folder_current_path)
if query.is_local:
search_list += "`{0}.` **{1}**\n[{2}]\n".format(
search_track_num,
track.title,
LocalPath(track.uri, self.local_folder_current_path).to_string_user(),
)
else:
search_list += "`{0}.` **[{1}]({2})**\n".format(
search_track_num, track.title, track.uri
)
except AttributeError:
track = Query.process_input(track, self.local_folder_current_path)
if track.is_local and command != "search":
search_list += "`{}.` **{}**\n".format(
search_track_num, track.to_string_user()
)
if track.is_album:
folder = True
else:
search_list += "`{}.` **{}**\n".format(
search_track_num, track.to_string_user()
)
if hasattr(tracks[0], "uri") and hasattr(tracks[0], "track_identifier"):
title = _("Tracks Found:")
footer = _("search results")
elif folder:
title = _("Folders Found:")
footer = _("local folders")
else:
title = _("Files Found:")
footer = _("local tracks")
embed = discord.Embed(
colour=await ctx.embed_colour(), title=title, description=search_list
)
embed.set_footer(
text=(_("Page {page_num}/{total_pages}") + " | {num_results} {footer}").format(
page_num=page_num,
total_pages=search_num_pages,
num_results=len(tracks),
footer=footer,
)
)
return embed
def get_track_description(
self, track, local_folder_current_path, shorten=False
) -> Optional[str]:
"""Get the user facing formatted track name"""
string = None
if track and getattr(track, "uri", None):
query = Query.process_input(track.uri, local_folder_current_path)
if query.is_local or "localtracks/" in track.uri:
if (
hasattr(track, "title")
and track.title != "Unknown title"
and hasattr(track, "author")
and track.author != "Unknown artist"
):
if shorten:
string = f"{track.author} - {track.title}"
if len(string) > 40:
string = "{}...".format((string[:40]).rstrip(" "))
string = f'**{escape(f"{string}", formatting=True)}**'
else:
string = (
f'**{escape(f"{track.author} - {track.title}", formatting=True)}**'
+ escape(f"\n{query.to_string_user()} ", formatting=True)
)
elif hasattr(track, "title") and track.title != "Unknown title":
if shorten:
string = f"{track.title}"
if len(string) > 40:
string = "{}...".format((string[:40]).rstrip(" "))
string = f'**{escape(f"{string}", formatting=True)}**'
else:
string = f'**{escape(f"{track.title}", formatting=True)}**' + escape(
f"\n{query.to_string_user()} ", formatting=True
)
else:
string = query.to_string_user()
if shorten and len(string) > 40:
string = "{}...".format((string[:40]).rstrip(" "))
string = f'**{escape(f"{string}", formatting=True)}**'
else:
if track.author.lower() not in track.title.lower():
title = f"{track.title} - {track.author}"
else:
title = track.title
string = f"{title}"
if shorten and len(string) > 40:
string = "{}...".format((string[:40]).rstrip(" "))
string = re.sub(RE_SQUARE, "", string)
string = f"**[{escape(string, formatting=True)}]({track.uri}) **"
elif hasattr(track, "to_string_user") and track.is_local:
string = track.to_string_user() + " "
if shorten and len(string) > 40:
string = "{}...".format((string[:40]).rstrip(" "))
string = f'**{escape(f"{string}", formatting=True)}**'
return string
def get_track_description_unformatted(self, track, local_folder_current_path) -> Optional[str]:
"""Get the user facing unformatted track name"""
if track and hasattr(track, "uri"):
query = Query.process_input(track.uri, local_folder_current_path)
if query.is_local or "localtracks/" in track.uri:
if (
hasattr(track, "title")
and track.title != "Unknown title"
and hasattr(track, "author")
and track.author != "Unknown artist"
):
return f"{track.author} - {track.title}"
elif hasattr(track, "title") and track.title != "Unknown title":
return f"{track.title}"
else:
return query.to_string_user()
else:
if track.author.lower() not in track.title.lower():
title = f"{track.title} - {track.author}"
else:
title = track.title
return f"{title}"
elif hasattr(track, "to_string_user") and track.is_local:
return track.to_string_user() + " "
return None
def format_playlist_picker_data(self, pid, pname, ptracks, pauthor, scope) -> str:
"""Format the values into a pretified codeblock"""
author = self.bot.get_user(pauthor) or pauthor or _("Unknown")
line = _(
" - Name: <{pname}>\n"
" - Scope: < {scope} >\n"
" - ID: < {pid} >\n"
" - Tracks: < {ptracks} >\n"
" - Author: < {author} >\n\n"
).format(
pname=pname, scope=self.humanize_scope(scope), pid=pid, ptracks=ptracks, author=author
)
return box(line, lang="md")
async def draw_time(self, ctx) -> str:
player = lavalink.get_player(ctx.guild.id)
paused = player.paused
pos = player.position
dur = player.current.length
sections = 12
loc_time = round((pos / dur) * sections)
bar = "\N{BOX DRAWINGS HEAVY HORIZONTAL}"
seek = "\N{RADIO BUTTON}"
if paused:
msg = "\N{DOUBLE VERTICAL BAR}"
else:
msg = "\N{BLACK RIGHT-POINTING TRIANGLE}"
for i in range(sections):
if i == loc_time:
msg += seek
else:
msg += bar
return msg

View File

@ -0,0 +1,127 @@
import contextlib
import logging
from pathlib import Path
from typing import List, Union
import lavalink
from fuzzywuzzy import process
from redbot.core.utils import AsyncIter
from redbot.core import commands
from ...errors import TrackEnqueueError
from ...audio_dataclasses import LocalPath, Query
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Utilities.local_tracks")
class LocalTrackUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def get_localtracks_folders(
self, ctx: commands.Context, search_subfolders: bool = True
) -> List[Union[Path, LocalPath]]:
audio_data = LocalPath(None, self.local_folder_current_path)
if not await self.localtracks_folder_exists(ctx):
return []
return (
await audio_data.subfolders_in_tree()
if search_subfolders
else await audio_data.subfolders()
)
async def get_localtrack_folder_list(self, ctx: commands.Context, query: Query) -> List[Query]:
"""Return a list of folders per the provided query"""
if not await self.localtracks_folder_exists(ctx):
return []
query = Query.process_input(query, self.local_folder_current_path)
if not query.is_local or query.local_track_path is None:
return []
if not query.local_track_path.exists():
return []
return (
await query.local_track_path.tracks_in_tree()
if query.search_subfolders
else await query.local_track_path.tracks_in_folder()
)
async def get_localtrack_folder_tracks(
self, ctx, player: lavalink.player_manager.Player, query: Query
) -> List[lavalink.rest_api.Track]:
"""Return a list of tracks per the provided query"""
if not await self.localtracks_folder_exists(ctx) or self.api_interface is None:
return []
audio_data = LocalPath(None, self.local_folder_current_path)
try:
if query.local_track_path is not None:
query.local_track_path.path.relative_to(audio_data.to_string())
else:
return []
except ValueError:
return []
local_tracks = []
async for local_file in AsyncIter(await self.get_all_localtrack_folder_tracks(ctx, query)):
with contextlib.suppress(IndexError, TrackEnqueueError):
trackdata, called_api = await self.api_interface.fetch_track(
ctx, player, local_file
)
local_tracks.append(trackdata.tracks[0])
return local_tracks
async def _local_play_all(
self, ctx: commands.Context, query: Query, from_search: bool = False
) -> None:
if not await self.localtracks_folder_exists(ctx) or query.local_track_path is None:
return None
if from_search:
query = Query.process_input(
query.local_track_path.to_string(),
self.local_folder_current_path,
invoked_from="local folder",
)
await ctx.invoke(self.command_search, query=query)
async def get_all_localtrack_folder_tracks(
self, ctx: commands.Context, query: Query
) -> List[Query]:
if not await self.localtracks_folder_exists(ctx) or query.local_track_path is None:
return []
return (
await query.local_track_path.tracks_in_tree()
if query.search_subfolders
else await query.local_track_path.tracks_in_folder()
)
async def localtracks_folder_exists(self, ctx: commands.Context) -> bool:
folder = LocalPath(None, self.local_folder_current_path)
if folder.localtrack_folder is None:
return False
elif folder.localtrack_folder.exists():
return True
elif ctx.invoked_with != "start":
await self.send_embed_msg(
ctx, title=_("Invalid Environment"), description=_("No localtracks folder.")
)
return False
async def _build_local_search_list(
self, to_search: List[Query], search_words: str
) -> List[str]:
to_search_string = {
i.local_track_path.name for i in to_search if i.local_track_path is not None
}
search_results = process.extract(search_words, to_search_string, limit=50)
search_list = []
async for track_match, percent_match in AsyncIter(search_results):
if percent_match > 85:
search_list.extend(
[
i.to_string_user()
for i in to_search
if i.local_track_path is not None
and i.local_track_path.name == track_match
]
)
return search_list

View File

@ -0,0 +1,335 @@
import asyncio
import contextlib
import datetime
import functools
import json
import logging
import re
from typing import Any, Final, MutableMapping, Union, cast, Mapping, Pattern
import discord
import lavalink
from discord.embeds import EmptyEmbed
from redbot.core.utils import AsyncIter
from redbot.core import bank, commands
from redbot.core.commands import Context
from redbot.core.utils.chat_formatting import humanize_number
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _, _SCHEMA_VERSION
from ...apis.playlist_interface import get_all_playlist_for_migration23
from ...utils import PlaylistScope
log = logging.getLogger("red.cogs.Audio.cog.Utilities.miscellaneous")
_RE_TIME_CONVERTER: Final[Pattern] = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])")
_prefer_lyrics_cache = {}
class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def _clear_react(
self, message: discord.Message, emoji: MutableMapping = None
) -> asyncio.Task:
"""Non blocking version of clear_react."""
return self.bot.loop.create_task(self.clear_react(message, emoji))
async def maybe_charge_requester(self, ctx: commands.Context, jukebox_price: int) -> bool:
jukebox = await self.config.guild(ctx.guild).jukebox()
if jukebox and not await self._can_instaskip(ctx, ctx.author):
can_spend = await bank.can_spend(ctx.author, jukebox_price)
if can_spend:
await bank.withdraw_credits(ctx.author, jukebox_price)
else:
credits_name = await bank.get_currency_name(ctx.guild)
bal = await bank.get_balance(ctx.author)
await self.send_embed_msg(
ctx,
title=_("Not enough {currency}").format(currency=credits_name),
description=_(
"{required_credits} {currency} required, but you have {bal}."
).format(
currency=credits_name,
required_credits=humanize_number(jukebox_price),
bal=humanize_number(bal),
),
)
return can_spend
else:
return True
async def send_embed_msg(
self, ctx: commands.Context, author: Mapping[str, str] = None, **kwargs
) -> discord.Message:
colour = kwargs.get("colour") or kwargs.get("color") or await self.bot.get_embed_color(ctx)
title = kwargs.get("title", EmptyEmbed) or EmptyEmbed
_type = kwargs.get("type", "rich") or "rich"
url = kwargs.get("url", EmptyEmbed) or EmptyEmbed
description = kwargs.get("description", EmptyEmbed) or EmptyEmbed
timestamp = kwargs.get("timestamp")
footer = kwargs.get("footer")
thumbnail = kwargs.get("thumbnail")
contents = dict(title=title, type=_type, url=url, description=description)
if hasattr(kwargs.get("embed"), "to_dict"):
embed = kwargs.get("embed")
if embed is not None:
embed = embed.to_dict()
else:
embed = {}
colour = embed.get("color") if embed.get("color") else colour
contents.update(embed)
if timestamp and isinstance(timestamp, datetime.datetime):
contents["timestamp"] = timestamp
embed = discord.Embed.from_dict(contents)
embed.color = colour
if footer:
embed.set_footer(text=footer)
if thumbnail:
embed.set_thumbnail(url=thumbnail)
if author:
name = author.get("name")
url = author.get("url")
if name and url:
embed.set_author(name=name, icon_url=url)
elif name:
embed.set_author(name=name)
return await ctx.send(embed=embed)
async def maybe_run_pending_db_tasks(self, ctx: commands.Context) -> None:
if self.api_interface is not None:
await self.api_interface.run_tasks(ctx)
async def _close_database(self) -> None:
if self.api_interface is not None:
await self.api_interface.run_all_pending_tasks()
self.api_interface.close()
async def _check_api_tokens(self) -> MutableMapping:
spotify = await self.bot.get_shared_api_tokens("spotify")
youtube = await self.bot.get_shared_api_tokens("youtube")
return {
"spotify_client_id": spotify.get("client_id", ""),
"spotify_client_secret": spotify.get("client_secret", ""),
"youtube_api": youtube.get("api_key", ""),
}
async def update_external_status(self) -> bool:
external = await self.config.use_external_lavalink()
if not external:
if self.player_manager is not None:
await self.player_manager.shutdown()
await self.config.use_external_lavalink.set(True)
return True
else:
return False
def rsetattr(self, obj, attr, val) -> None:
pre, _, post = attr.rpartition(".")
setattr(self.rgetattr(obj, pre) if pre else obj, post, val)
def rgetattr(self, obj, attr, *args) -> Any:
def _getattr(obj2, attr2):
return getattr(obj2, attr2, *args)
return functools.reduce(_getattr, [obj] + attr.split("."))
async def remove_react(
self,
message: discord.Message,
react_emoji: Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str],
react_user: discord.abc.User,
) -> None:
with contextlib.suppress(discord.HTTPException):
await message.remove_reaction(react_emoji, react_user)
async def clear_react(self, message: discord.Message, emoji: MutableMapping = None) -> None:
try:
await message.clear_reactions()
except discord.Forbidden:
if not emoji:
return
with contextlib.suppress(discord.HTTPException):
async for key in AsyncIter(emoji.values(), delay=0.2):
await message.remove_reaction(key, self.bot.user)
except discord.HTTPException:
return
def get_track_json(
self,
player: lavalink.Player,
position: Union[int, str] = None,
other_track: lavalink.Track = None,
) -> MutableMapping:
if position == "np":
queued_track = player.current
elif position is None:
queued_track = other_track
else:
queued_track = player.queue[position]
return self.track_to_json(queued_track)
def track_to_json(self, track: lavalink.Track) -> MutableMapping:
track_keys = track._info.keys()
track_values = track._info.values()
track_id = track.track_identifier
track_info = {}
for k, v in zip(track_keys, track_values):
track_info[k] = v
keys = ["track", "info", "extras"]
values = [track_id, track_info]
track_obj = {}
for key, value in zip(keys, values):
track_obj[key] = value
return track_obj
def time_convert(self, length: Union[int, str]) -> int:
if isinstance(length, int):
return length
match = _RE_TIME_CONVERTER.match(length)
if match is not None:
hr = int(match.group(1)) if match.group(1) else 0
mn = int(match.group(2)) if match.group(2) else 0
sec = int(match.group(3)) if match.group(3) else 0
pos = sec + (mn * 60) + (hr * 3600)
return pos
else:
try:
return int(length)
except ValueError:
return 0
async def queue_duration(self, ctx: commands.Context) -> int:
player = lavalink.get_player(ctx.guild.id)
duration = []
async for i in AsyncIter(range(len(player.queue))):
if not player.queue[i].is_stream:
duration.append(player.queue[i].length)
queue_dur = sum(duration)
if not player.queue:
queue_dur = 0
try:
if not player.current.is_stream:
remain = player.current.length - player.position
else:
remain = 0
except AttributeError:
remain = 0
queue_total_duration = remain + queue_dur
return queue_total_duration
async def track_remaining_duration(self, ctx: commands.Context) -> int:
player = lavalink.get_player(ctx.guild.id)
if not player.current:
return 0
try:
if not player.current.is_stream:
remain = player.current.length - player.position
else:
remain = 0
except AttributeError:
remain = 0
return remain
def get_time_string(self, seconds: int) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
d, h = divmod(h, 24)
if d > 0:
msg = "{0}d {1}h"
elif d == 0 and h > 0:
msg = "{1}h {2}m"
elif d == 0 and h == 0 and m > 0:
msg = "{2}m {3}s"
elif d == 0 and h == 0 and m == 0 and s > 0:
msg = "{3}s"
else:
msg = ""
return msg.format(d, h, m, s)
def format_time(self, time: int) -> str:
""" Formats the given time into DD:HH:MM:SS """
seconds = time / 1000
days, seconds = divmod(seconds, 24 * 60 * 60)
hours, seconds = divmod(seconds, 60 * 60)
minutes, seconds = divmod(seconds, 60)
day = ""
hour = ""
if days:
day = "%02d:" % days
if hours or day:
hour = "%02d:" % hours
minutes = "%02d:" % minutes
sec = "%02d" % seconds
return f"{day}{hour}{minutes}{sec}"
async def get_lyrics_status(self, ctx: Context) -> bool:
global _prefer_lyrics_cache
prefer_lyrics = _prefer_lyrics_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).prefer_lyrics()
)
return prefer_lyrics
async def data_schema_migration(self, from_version: int, to_version: int) -> None:
database_entries = []
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
if from_version == to_version:
return
if from_version < 2 <= to_version:
all_guild_data = await self.config.all_guilds()
all_playlist = {}
async for guild_id, guild_data in AsyncIter(all_guild_data.items()):
temp_guild_playlist = guild_data.pop("playlists", None)
if temp_guild_playlist:
guild_playlist = {}
async for count, (name, data) in AsyncIter(
temp_guild_playlist.items()
).enumerate(start=1000):
if not data or not name:
continue
playlist = {"id": count, "name": name, "guild": int(guild_id)}
playlist.update(data)
guild_playlist[str(count)] = playlist
tracks_in_playlist = data.get("tracks", []) or []
async for t in AsyncIter(tracks_in_playlist):
uri = t.get("info", {}).get("uri")
if uri:
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
data = json.dumps(t)
if all(
k in data
for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
):
database_entries.append(
{
"query": uri,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
)
if guild_playlist:
all_playlist[str(guild_id)] = guild_playlist
await self.config.custom(PlaylistScope.GUILD.value).set(all_playlist)
# new schema is now in place
await self.config.schema_version.set(2)
# migration done, now let's delete all the old stuff
async for guild_id in AsyncIter(all_guild_data):
await self.config.guild(
cast(discord.Guild, discord.Object(id=guild_id))
).clear_raw("playlists")
if from_version < 3 <= to_version:
for scope in PlaylistScope.list():
scope_playlist = await get_all_playlist_for_migration23(
self.bot, self.playlist_api, self.config, scope
)
async for p in AsyncIter(scope_playlist):
await p.save()
await self.config.custom(scope).clear()
await self.config.schema_version.set(3)
if database_entries:
await self.api_interface.local_cache_api.lavalink.insert(database_entries)

View File

@ -0,0 +1,669 @@
import logging
import time
from typing import List, Optional, Tuple, Union
import aiohttp
import discord
import lavalink
from discord.embeds import EmptyEmbed
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.chat_formatting import bold, escape
from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query
from ...audio_logging import IS_DEBUG, debug_exc_log
from ...errors import QueryUnauthorized, SpotifyFetchError, TrackEnqueueError
from ...utils import Notifier
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Utilities.player")
class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def maybe_reset_error_counter(self, player: lavalink.Player) -> None:
guild = self.rgetattr(player, "channel.guild.id", None)
if not guild:
return
now = time.time()
seconds_allowed = 10
last_error = self._error_timer.setdefault(guild, now)
if now - seconds_allowed > last_error:
self._error_timer[guild] = 0
self._error_counter[guild] = 0
async def increase_error_counter(self, player: lavalink.Player) -> bool:
guild = self.rgetattr(player, "channel.guild.id", None)
if not guild:
return False
now = time.time()
self._error_counter[guild] += 1
self._error_timer[guild] = now
return self._error_counter[guild] >= 5
def get_active_player_count(self) -> Tuple[Optional[str], int]:
try:
current = next(
(
player.current
for player in lavalink.active_players()
if player.current is not None
),
None,
)
get_single_title = self.get_track_description_unformatted(
current, self.local_folder_current_path
)
playing_servers = len(lavalink.active_players())
except IndexError:
get_single_title = None
playing_servers = 0
return get_single_title, playing_servers
async def update_bot_presence(self, track: Optional[str], playing_servers: int) -> None:
if playing_servers == 0:
await self.bot.change_presence(activity=None)
elif playing_servers == 1:
await self.bot.change_presence(
activity=discord.Activity(name=track, type=discord.ActivityType.listening)
)
elif playing_servers > 1:
await self.bot.change_presence(
activity=discord.Activity(
name=_("music in {} servers").format(playing_servers),
type=discord.ActivityType.playing,
)
)
async def _can_instaskip(self, ctx: commands.Context, member: discord.Member) -> bool:
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if member.bot:
return True
if member.id == ctx.guild.owner_id:
return True
if dj_enabled and await self._has_dj_role(ctx, member):
return True
if await self.bot.is_owner(member):
return True
if await self.bot.is_mod(member):
return True
if await self.maybe_move_player(ctx):
return True
return False
async def is_requester_alone(self, ctx: commands.Context) -> bool:
channel_members = self.rgetattr(ctx, "guild.me.voice.channel.members", [])
nonbots = sum(m.id != ctx.author.id for m in channel_members if not m.bot)
return not nonbots
async def _has_dj_role(self, ctx: commands.Context, member: discord.Member) -> bool:
dj_role = self._dj_role_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_role()
)
dj_role_obj = ctx.guild.get_role(dj_role)
return dj_role_obj in ctx.guild.get_member(member.id).roles
async def is_requester(self, ctx: commands.Context, member: discord.Member) -> bool:
try:
player = lavalink.get_player(ctx.guild.id)
log.debug(f"Current requester is {player.current.requester}")
return player.current.requester.id == member.id
except Exception as err:
debug_exc_log(log, err, "Caught error in `is_requester`")
return False
async def _skip_action(self, ctx: commands.Context, skip_to_track: int = None) -> None:
player = lavalink.get_player(ctx.guild.id)
autoplay = await self.config.guild(player.channel.guild).auto_play()
if not player.current or (not player.queue and not autoplay):
try:
pos, dur = player.position, player.current.length
except AttributeError:
await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
return
time_remain = self.format_time(dur - pos)
if player.current.is_stream:
embed = discord.Embed(title=_("There's nothing in the queue."))
embed.set_footer(
text=_("Currently livestreaming {track}").format(track=player.current.title)
)
else:
embed = discord.Embed(title=_("There's nothing in the queue."))
embed.set_footer(
text=_("{time} left on {track}").format(
time=time_remain, track=player.current.title
)
)
await self.send_embed_msg(ctx, embed=embed)
return
elif autoplay and not player.queue:
embed = discord.Embed(
title=_("Track Skipped"),
description=self.get_track_description(
player.current, self.local_folder_current_path
),
)
await self.send_embed_msg(ctx, embed=embed)
await player.skip()
return
queue_to_append = []
if skip_to_track is not None and skip_to_track != 1:
if skip_to_track < 1:
await self.send_embed_msg(
ctx, title=_("Track number must be equal to or greater than 1.")
)
return
elif skip_to_track > len(player.queue):
await self.send_embed_msg(
ctx,
title=_("There are only {queuelen} songs currently queued.").format(
queuelen=len(player.queue)
),
)
return
embed = discord.Embed(
title=_("{skip_to_track} Tracks Skipped").format(skip_to_track=skip_to_track)
)
await self.send_embed_msg(ctx, embed=embed)
if player.repeat:
queue_to_append = player.queue[0 : min(skip_to_track - 1, len(player.queue) - 1)]
player.queue = player.queue[
min(skip_to_track - 1, len(player.queue) - 1) : len(player.queue)
]
else:
embed = discord.Embed(
title=_("Track Skipped"),
description=self.get_track_description(
player.current, self.local_folder_current_path
),
)
await self.send_embed_msg(ctx, embed=embed)
self.bot.dispatch("red_audio_skip_track", player.channel.guild, player.current, ctx.author)
await player.play()
player.queue += queue_to_append
def update_player_lock(self, ctx: commands.Context, true_or_false: bool) -> None:
if true_or_false:
self.play_lock[ctx.message.guild.id] = True
else:
self.play_lock[ctx.message.guild.id] = False
def _player_check(self, ctx: commands.Context) -> bool:
if self.lavalink_connection_aborted:
return False
try:
lavalink.get_player(ctx.guild.id)
return True
except (IndexError, KeyError):
return False
async def _get_spotify_tracks(
self, ctx: commands.Context, query: Query, forced: bool = False
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
if ctx.invoked_with in ["play", "genre"]:
enqueue_tracks = True
else:
enqueue_tracks = False
player = lavalink.get_player(ctx.guild.id)
api_data = await self._check_api_tokens()
if any([not api_data["spotify_client_id"], not api_data["spotify_client_secret"]]):
return await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=_(
"The owner needs to set the Spotify client ID and Spotify client secret, "
"before Spotify URLs or codes can be used. "
"\nSee `{prefix}audioset spotifyapi` for instructions."
).format(prefix=ctx.prefix),
)
elif not api_data["youtube_api"]:
return await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=_(
"The owner needs to set the YouTube API key before Spotify URLs or "
"codes can be used.\nSee `{prefix}audioset youtubeapi` for instructions."
).format(prefix=ctx.prefix),
)
try:
if self.play_lock[ctx.message.guild.id]:
return await self.send_embed_msg(
ctx,
title=_("Unable To Get Tracks"),
description=_("Wait until the playlist has finished loading."),
)
except KeyError:
pass
if query.single_track:
try:
res = await self.api_interface.spotify_query(
ctx, "track", query.id, skip_youtube=True, notifier=None
)
if not res:
title = _("Nothing found.")
embed = discord.Embed(title=title)
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
title = _("Track is not playable.")
description = _(
"**{suffix}** is not a fully supported "
"format and some tracks may not play."
).format(suffix=query.suffix)
embed = discord.Embed(title=title, description=description)
return await self.send_embed_msg(ctx, embed=embed)
except SpotifyFetchError as error:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx, title=error.message.format(prefix=ctx.prefix)
)
self.update_player_lock(ctx, False)
try:
if enqueue_tracks:
new_query = Query.process_input(res[0], self.local_folder_current_path)
new_query.start_time = query.start_time
return await self._enqueue_tracks(ctx, new_query)
else:
query = Query.process_input(res[0], self.local_folder_current_path)
try:
result, called_api = await self.api_interface.fetch_track(
ctx, player, query
)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, "
"try again in a few minutes."
),
)
tracks = result.tracks
if not tracks:
embed = discord.Embed(title=_("Nothing found."))
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
embed = discord.Embed(title=_("Track is not playable."))
embed.description = _(
"**{suffix}** is not a fully supported format and some "
"tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
single_track = tracks[0]
single_track.start_timestamp = query.start_time * 1000
single_track = [single_track]
return single_track
except KeyError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=_(
"The Spotify API key or client secret has not been set properly. "
"\nUse `{prefix}audioset spotifyapi` for instructions."
).format(prefix=ctx.prefix),
)
elif query.is_album or query.is_playlist:
self.update_player_lock(ctx, True)
track_list = await self.fetch_spotify_playlist(
ctx,
"album" if query.is_album else "playlist",
query,
enqueue_tracks,
forced=forced,
)
self.update_player_lock(ctx, False)
return track_list
else:
return await self.send_embed_msg(
ctx,
title=_("Unable To Find Tracks"),
description=_("This doesn't seem to be a supported Spotify URL or code."),
)
async def _enqueue_tracks(
self, ctx: commands.Context, query: Union[Query, list], enqueue: bool = True
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
player = lavalink.get_player(ctx.guild.id)
try:
if self.play_lock[ctx.message.guild.id]:
return await self.send_embed_msg(
ctx,
title=_("Unable To Get Tracks"),
description=_("Wait until the playlist has finished loading."),
)
except KeyError:
self.update_player_lock(ctx, True)
guild_data = await self.config.guild(ctx.guild).all()
first_track_only = False
single_track = None
index = None
playlist_data = None
playlist_url = None
seek = 0
if type(query) is not list:
if not await self.is_query_allowed(
self.config, ctx.guild, f"{query}", query_obj=query
):
raise QueryUnauthorized(
_("{query} is not an allowed query.").format(query=query.to_string_user())
)
if query.single_track:
first_track_only = True
index = query.track_index
if query.start_time:
seek = query.start_time
try:
result, called_api = await self.api_interface.fetch_track(ctx, player, query)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, "
"try again in a few minutes."
),
)
tracks = result.tracks
playlist_data = result.playlist_info
if not enqueue:
return tracks
if not tracks:
self.update_player_lock(ctx, False)
title = _("Nothing found.")
embed = discord.Embed(title=title)
if result.exception_message:
embed.set_footer(text=result.exception_message[:2000].replace("\n", ""))
if await self.config.use_external_lavalink() and query.is_local:
embed.description = _(
"Local tracks will not work "
"if the `Lavalink.jar` cannot see the track.\n"
"This may be due to permissions or because Lavalink.jar is being run "
"in a different machine than the local tracks."
)
elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
title = _("Track is not playable.")
embed = discord.Embed(title=title)
embed.description = _(
"**{suffix}** is not a fully supported format and some "
"tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
else:
tracks = query
queue_dur = await self.queue_duration(ctx)
queue_total_duration = self.format_time(queue_dur)
before_queue_length = len(player.queue)
if not first_track_only and len(tracks) > 1:
# a list of Tracks where all should be enqueued
# this is a Spotify playlist already made into a list of Tracks or a
# url where Lavalink handles providing all Track objects to use, like a
# YouTube or Soundcloud playlist
if len(player.queue) >= 10000:
return await self.send_embed_msg(ctx, title=_("Queue size limit reached."))
track_len = 0
empty_queue = not player.queue
async for track in AsyncIter(tracks):
if len(player.queue) >= 10000:
continue
if not await self.is_query_allowed(
self.config,
ctx.guild,
(
f"{track.title} {track.author} {track.uri} "
f"{str(Query.process_input(track, self.local_folder_current_path))}"
),
):
if IS_DEBUG:
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
continue
elif guild_data["maxlength"] > 0:
if self.is_track_too_long(track, guild_data["maxlength"]):
track_len += 1
player.add(ctx.author, track)
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, track, ctx.author
)
else:
track_len += 1
player.add(ctx.author, track)
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, track, ctx.author
)
player.maybe_shuffle(0 if empty_queue else 1)
if len(tracks) > track_len:
maxlength_msg = _(" {bad_tracks} tracks cannot be queued.").format(
bad_tracks=(len(tracks) - track_len)
)
else:
maxlength_msg = ""
playlist_name = escape(
playlist_data.name if playlist_data else _("No Title"), formatting=True
)
embed = discord.Embed(
description=bold(f"[{playlist_name}]({playlist_url})")
if playlist_url
else playlist_name,
title=_("Playlist Enqueued"),
)
embed.set_footer(
text=_("Added {num} tracks to the queue.{maxlength_msg}").format(
num=track_len, maxlength_msg=maxlength_msg
)
)
if not guild_data["shuffle"] and queue_dur > 0:
embed.set_footer(
text=_(
"{time} until start of playlist playback: starts at #{position} in queue"
).format(time=queue_total_duration, position=before_queue_length + 1)
)
if not player.current:
await player.play()
self.update_player_lock(ctx, False)
message = await self.send_embed_msg(ctx, embed=embed)
return tracks or message
else:
single_track = None
# a ytsearch: prefixed item where we only need the first Track returned
# this is in the case of [p]play <query>, a single Spotify url/code
# or this is a localtrack item
try:
if len(player.queue) >= 10000:
return await self.send_embed_msg(ctx, title=_("Queue size limit reached."))
single_track = (
tracks
if isinstance(tracks, lavalink.rest_api.Track)
else tracks[index]
if index
else tracks[0]
)
if seek and seek > 0:
single_track.start_timestamp = seek * 1000
if not await self.is_query_allowed(
self.config,
ctx.guild,
(
f"{single_track.title} {single_track.author} {single_track.uri} "
f"{str(Query.process_input(single_track, self.local_folder_current_path))}"
),
):
if IS_DEBUG:
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx, title=_("This track is not allowed in this server.")
)
elif guild_data["maxlength"] > 0:
if self.is_track_too_long(single_track, guild_data["maxlength"]):
player.add(ctx.author, single_track)
player.maybe_shuffle()
self.bot.dispatch(
"red_audio_track_enqueue",
player.channel.guild,
single_track,
ctx.author,
)
else:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx, title=_("Track exceeds maximum length.")
)
else:
player.add(ctx.author, single_track)
player.maybe_shuffle()
self.bot.dispatch(
"red_audio_track_enqueue", player.channel.guild, single_track, ctx.author
)
except IndexError:
self.update_player_lock(ctx, False)
title = _("Nothing found")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=title, description=desc)
description = self.get_track_description(single_track, self.local_folder_current_path)
embed = discord.Embed(title=_("Track Enqueued"), description=description)
if not guild_data["shuffle"] and queue_dur > 0:
embed.set_footer(
text=_("{time} until track playback: #{position} in queue").format(
time=queue_total_duration, position=before_queue_length + 1
)
)
if not player.current:
await player.play()
self.update_player_lock(ctx, False)
message = await self.send_embed_msg(ctx, embed=embed)
return single_track or message
async def fetch_spotify_playlist(
self,
ctx: commands.Context,
stype: str,
query: Query,
enqueue: bool = False,
forced: bool = False,
):
player = lavalink.get_player(ctx.guild.id)
try:
embed1 = discord.Embed(title=_("Please wait, finding tracks..."))
playlist_msg = await self.send_embed_msg(ctx, embed=embed1)
notifier = Notifier(
ctx,
playlist_msg,
{
"spotify": _("Getting track {num}/{total}..."),
"youtube": _("Matching track {num}/{total}..."),
"lavalink": _("Loading track {num}/{total}..."),
"lavalink_time": _("Approximate time remaining: {seconds}"),
},
)
track_list = await self.api_interface.spotify_enqueue(
ctx,
stype,
query.id,
enqueue=enqueue,
player=player,
lock=self.update_player_lock,
notifier=notifier,
forced=forced,
)
except SpotifyFetchError as error:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Invalid Environment"),
description=error.message.format(prefix=ctx.prefix),
)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment,"
"try again in a few minutes."
),
error=True,
)
except (RuntimeError, aiohttp.ServerDisconnectedError):
self.update_player_lock(ctx, False)
error_embed = discord.Embed(
title=_("The connection was reset while loading the playlist.")
)
await self.send_embed_msg(ctx, embed=error_embed)
return None
except Exception as e:
self.update_player_lock(ctx, False)
raise e
self.update_player_lock(ctx, False)
return track_list
async def set_player_settings(self, ctx: commands.Context) -> None:
player = lavalink.get_player(ctx.guild.id)
shuffle = await self.config.guild(ctx.guild).shuffle()
repeat = await self.config.guild(ctx.guild).repeat()
volume = await self.config.guild(ctx.guild).volume()
shuffle_bumped = await self.config.guild(ctx.guild).shuffle_bumped()
player.repeat = repeat
player.shuffle = shuffle
player.shuffle_bumped = shuffle_bumped
if player.volume != volume:
await player.set_volume(volume)
async def maybe_move_player(self, ctx: commands.Context) -> bool:
try:
player = lavalink.get_player(ctx.guild.id)
except KeyError:
return False
try:
in_channel = sum(
not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members
)
except AttributeError:
return False
if not ctx.author.voice:
user_channel = None
else:
user_channel = ctx.author.voice.channel
if in_channel == 0 and user_channel:
if (
(player.channel != user_channel)
and not player.current
and player.position == 0
and len(player.queue) == 0
):
await player.move_to(user_channel)
return True
else:
return False
def is_track_too_long(self, track: Union[lavalink.Track, int], maxlength: int) -> bool:
try:
length = round(track.length / 1000)
except AttributeError:
length = round(track / 1000)
if maxlength < length <= 92233720368547758070: # livestreams return 9223372036854775807ms
return False
return True

View File

@ -0,0 +1,647 @@
import asyncio
import contextlib
import datetime
import json
import logging
import math
from typing import List, MutableMapping, Optional, Tuple, Union
import discord
import lavalink
from discord.embeds import EmptyEmbed
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.chat_formatting import box
from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.predicates import ReactionPredicate
from ...apis.playlist_interface import Playlist, create_playlist
from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query
from ...audio_logging import debug_exc_log
from ...errors import TooManyMatches, TrackEnqueueError
from ...utils import Notifier, PlaylistScope
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Utilities.playlists")
class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def can_manage_playlist(
self, scope: str, playlist: Playlist, ctx: commands.Context, user, guild
) -> bool:
is_owner = await self.bot.is_owner(ctx.author)
has_perms = False
user_to_query = user
guild_to_query = guild
dj_enabled = None
playlist_author = (
guild.get_member(playlist.author)
if guild
else self.bot.get_user(playlist.author) or user
)
is_different_user = len({playlist.author, user_to_query.id, ctx.author.id}) != 1
is_different_guild = True if guild_to_query is None else ctx.guild.id != guild_to_query.id
if is_owner:
has_perms = True
elif playlist.scope == PlaylistScope.USER.value:
if not is_different_user:
has_perms = True
elif playlist.scope == PlaylistScope.GUILD.value and not is_different_guild:
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
)
if (
guild.owner_id == ctx.author.id
or (dj_enabled and await self._has_dj_role(ctx, ctx.author))
or (await self.bot.is_mod(ctx.author))
or (not dj_enabled and not is_different_user)
):
has_perms = True
if has_perms is False:
if hasattr(playlist, "name"):
msg = _(
"You do not have the permissions to manage {name} (`{id}`) [**{scope}**]."
).format(
user=playlist_author,
name=playlist.name,
id=playlist.id,
scope=self.humanize_scope(
playlist.scope,
ctx=guild_to_query
if playlist.scope == PlaylistScope.GUILD.value
else playlist_author
if playlist.scope == PlaylistScope.USER.value
else None,
),
)
elif playlist.scope == PlaylistScope.GUILD.value and (
is_different_guild or dj_enabled
):
msg = _(
"You do not have the permissions to manage that playlist in {guild}."
).format(guild=guild_to_query)
elif (
playlist.scope in [PlaylistScope.GUILD.value, PlaylistScope.USER.value]
and is_different_user
):
msg = _(
"You do not have the permissions to manage playlist owned by {user}."
).format(user=playlist_author)
else:
msg = _(
"You do not have the permissions to manage playlists in {scope} scope."
).format(scope=self.humanize_scope(scope, the=True))
await self.send_embed_msg(ctx, title=_("No access to playlist."), description=msg)
return False
return True
async def get_playlist_match(
self,
context: commands.Context,
matches: MutableMapping,
scope: str,
author: discord.User,
guild: discord.Guild,
specified_user: bool = False,
) -> Tuple[Optional[Playlist], str, str]:
"""
Parameters
----------
context: commands.Context
The context in which this is being called.
matches: dict
A dict of the matches found where key is scope and value is matches.
scope:str
The custom config scope. A value from :code:`PlaylistScope`.
author: discord.User
The user.
guild: discord.Guild
The guild.
specified_user: bool
Whether or not a user ID was specified via argparse.
Returns
-------
Tuple[Optional[Playlist], str, str]
Tuple of Playlist or None if none found, original user input and scope.
Raises
------
`TooManyMatches`
When more than 10 matches are found or
When multiple matches are found but none is selected.
"""
correct_scope_matches: List[Playlist]
original_input = matches.get("arg")
lazy_match = False
if scope is None:
correct_scope_matches_temp: MutableMapping = matches.get("all")
lazy_match = True
else:
correct_scope_matches_temp: MutableMapping = matches.get(scope)
guild_to_query = guild.id
user_to_query = author.id
correct_scope_matches_user = []
correct_scope_matches_guild = []
correct_scope_matches_global = []
if not correct_scope_matches_temp:
return None, original_input, scope or PlaylistScope.GUILD.value
if lazy_match or (scope == PlaylistScope.USER.value):
correct_scope_matches_user = [
p for p in matches.get(PlaylistScope.USER.value) if user_to_query == p.scope_id
]
if lazy_match or (scope == PlaylistScope.GUILD.value and not correct_scope_matches_user):
if specified_user:
correct_scope_matches_guild = [
p
for p in matches.get(PlaylistScope.GUILD.value)
if guild_to_query == p.scope_id and p.author == user_to_query
]
else:
correct_scope_matches_guild = [
p
for p in matches.get(PlaylistScope.GUILD.value)
if guild_to_query == p.scope_id
]
if lazy_match or (
scope == PlaylistScope.GLOBAL.value
and not correct_scope_matches_user
and not correct_scope_matches_guild
):
if specified_user:
correct_scope_matches_global = [
p for p in matches.get(PlaylistScope.GLOBAL.value) if p.author == user_to_query
]
else:
correct_scope_matches_global = [p for p in matches.get(PlaylistScope.GLOBAL.value)]
correct_scope_matches = [
*correct_scope_matches_global,
*correct_scope_matches_guild,
*correct_scope_matches_user,
]
match_count = len(correct_scope_matches)
if match_count > 1:
correct_scope_matches2 = [
p for p in correct_scope_matches if p.name == str(original_input).strip()
]
if correct_scope_matches2:
correct_scope_matches = correct_scope_matches2
elif original_input.isnumeric():
arg = int(original_input)
correct_scope_matches3 = [p for p in correct_scope_matches if p.id == arg]
if correct_scope_matches3:
correct_scope_matches = correct_scope_matches3
match_count = len(correct_scope_matches)
# We done all the trimming we can with the info available time to ask the user
if match_count > 10:
if original_input.isnumeric():
arg = int(original_input)
correct_scope_matches = [p for p in correct_scope_matches if p.id == arg]
if match_count > 10:
raise TooManyMatches(
_(
"{match_count} playlists match {original_input}: "
"Please try to be more specific, or use the playlist ID."
).format(match_count=match_count, original_input=original_input)
)
elif match_count == 1:
return correct_scope_matches[0], original_input, correct_scope_matches[0].scope
elif match_count == 0:
return None, original_input, scope or PlaylistScope.GUILD.value
# TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged
pos_len = 3
playlists = f"{'#':{pos_len}}\n"
number = 0
correct_scope_matches = sorted(correct_scope_matches, key=lambda x: x.name.lower())
async for number, playlist in AsyncIter(correct_scope_matches).enumerate(start=1):
author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown")
line = _(
"{number}."
" <{playlist.name}>\n"
" - Scope: < {scope} >\n"
" - ID: < {playlist.id} >\n"
" - Tracks: < {tracks} >\n"
" - Author: < {author} >\n\n"
).format(
number=number,
playlist=playlist,
scope=self.humanize_scope(playlist.scope),
tracks=len(playlist.tracks),
author=author,
)
playlists += line
embed = discord.Embed(
title=_("{playlists} playlists found, which one would you like?").format(
playlists=number
),
description=box(playlists, lang="md"),
colour=await context.embed_colour(),
)
msg = await context.send(embed=embed)
avaliable_emojis = ReactionPredicate.NUMBER_EMOJIS[1:]
avaliable_emojis.append("🔟")
emojis = avaliable_emojis[: len(correct_scope_matches)]
emojis.append("\N{CROSS MARK}")
start_adding_reactions(msg, emojis)
pred = ReactionPredicate.with_emojis(emojis, msg, user=context.author)
try:
await context.bot.wait_for("reaction_add", check=pred, timeout=60)
except asyncio.TimeoutError:
with contextlib.suppress(discord.HTTPException):
await msg.delete()
raise TooManyMatches(
_("Too many matches found and you did not select which one you wanted.")
)
if emojis[pred.result] == "\N{CROSS MARK}":
with contextlib.suppress(discord.HTTPException):
await msg.delete()
raise TooManyMatches(
_("Too many matches found and you did not select which one you wanted.")
)
with contextlib.suppress(discord.HTTPException):
await msg.delete()
return (
correct_scope_matches[pred.result],
original_input,
correct_scope_matches[pred.result].scope,
)
async def _build_playlist_list_page(
self, ctx: commands.Context, page_num: int, abc_names: List, scope: Optional[str]
) -> discord.Embed:
plist_num_pages = math.ceil(len(abc_names) / 5)
plist_idx_start = (page_num - 1) * 5
plist_idx_end = plist_idx_start + 5
plist = ""
async for i, playlist_info in AsyncIter(
abc_names[plist_idx_start:plist_idx_end]
).enumerate(start=plist_idx_start):
item_idx = i + 1
plist += "`{}.` {}".format(item_idx, playlist_info)
if scope is None:
embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Playlists you can access in this server:"),
description=plist,
)
else:
embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Playlists for {scope}:").format(scope=scope),
description=plist,
)
embed.set_footer(
text=_("Page {page_num}/{total_pages} | {num} playlists.").format(
page_num=page_num, total_pages=plist_num_pages, num=len(abc_names)
)
)
return embed
async def _load_v3_playlist(
self,
ctx: commands.Context,
scope: str,
uploaded_playlist_name: str,
uploaded_playlist_url: str,
track_list: List,
author: Union[discord.User, discord.Member],
guild: Union[discord.Guild],
) -> None:
embed1 = discord.Embed(title=_("Please wait, adding tracks..."))
playlist_msg = await self.send_embed_msg(ctx, embed=embed1)
track_count = len(track_list)
uploaded_track_count = len(track_list)
await asyncio.sleep(1)
embed2 = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Loading track {num}/{total}...").format(
num=track_count, total=uploaded_track_count
),
)
await playlist_msg.edit(embed=embed2)
playlist = await create_playlist(
ctx,
self.playlist_api,
scope,
uploaded_playlist_name,
uploaded_playlist_url,
track_list,
author,
guild,
)
scope_name = self.humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if not track_count:
msg = _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format(
name=playlist.name, id=playlist.id, scope=scope_name
)
elif uploaded_track_count != track_count:
bad_tracks = uploaded_track_count - track_count
msg = _(
"Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) "
"could not be loaded."
).format(num=track_count, playlist_name=playlist.name, num_bad=bad_tracks)
else:
msg = _("Added {num} tracks from the {playlist_name} playlist.").format(
num=track_count, playlist_name=playlist.name
)
embed3 = discord.Embed(
colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg
)
await playlist_msg.edit(embed=embed3)
database_entries = []
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
async for t in AsyncIter(track_list):
uri = t.get("info", {}).get("uri")
if uri:
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
data = json.dumps(t)
if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]):
database_entries.append(
{
"query": uri,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
)
if database_entries:
await self.api_interface.local_cache_api.lavalink.insert(database_entries)
async def _load_v2_playlist(
self,
ctx: commands.Context,
uploaded_track_list,
player: lavalink.player_manager.Player,
playlist_url: str,
uploaded_playlist_name: str,
scope: str,
author: Union[discord.User, discord.Member],
guild: Union[discord.Guild],
):
track_list = []
successful_count = 0
uploaded_track_count = len(uploaded_track_list)
embed1 = discord.Embed(title=_("Please wait, adding tracks..."))
playlist_msg = await self.send_embed_msg(ctx, embed=embed1)
notifier = Notifier(ctx, playlist_msg, {"playlist": _("Loading track {num}/{total}...")})
async for track_count, song_url in AsyncIter(uploaded_track_list).enumerate(start=1):
try:
try:
result, called_api = await self.api_interface.fetch_track(
ctx, player, Query.process_input(song_url, self.local_folder_current_path)
)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, "
"try again in a few minutes."
),
)
track = result.tracks[0]
except Exception as err:
debug_exc_log(log, err, f"Failed to get track for {song_url}")
continue
try:
track_obj = self.get_track_json(player, other_track=track)
track_list.append(track_obj)
successful_count += 1
except Exception as err:
debug_exc_log(log, err, f"Failed to create track for {track}")
continue
if (track_count % 2 == 0) or (track_count == len(uploaded_track_list)):
await notifier.notify_user(
current=track_count, total=len(uploaded_track_list), key="playlist"
)
playlist = await create_playlist(
ctx,
self.playlist_api,
scope,
uploaded_playlist_name,
playlist_url,
track_list,
author,
guild,
)
scope_name = self.humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if not successful_count:
msg = _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format(
name=playlist.name, id=playlist.id, scope=scope_name
)
elif uploaded_track_count != successful_count:
bad_tracks = uploaded_track_count - successful_count
msg = _(
"Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) "
"could not be loaded."
).format(num=successful_count, playlist_name=playlist.name, num_bad=bad_tracks)
else:
msg = _("Added {num} tracks from the {playlist_name} playlist.").format(
num=successful_count, playlist_name=playlist.name
)
embed3 = discord.Embed(
colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg
)
await playlist_msg.edit(embed=embed3)
async def _maybe_update_playlist(
self, ctx: commands.Context, player: lavalink.player_manager.Player, playlist: Playlist
) -> Tuple[List[lavalink.Track], List[lavalink.Track], Playlist]:
if playlist.url is None:
return [], [], playlist
results = {}
updated_tracks = await self.fetch_playlist_tracks(
ctx,
player,
Query.process_input(playlist.url, self.local_folder_current_path),
skip_cache=True,
)
if isinstance(updated_tracks, discord.Message):
return [], [], playlist
if not updated_tracks:
# No Tracks available on url Lets set it to none to avoid repeated calls here
results["url"] = None
if updated_tracks: # Tracks have been updated
results["tracks"] = updated_tracks
old_tracks = playlist.tracks_obj
new_tracks = [lavalink.Track(data=track) for track in updated_tracks]
removed = list(set(old_tracks) - set(new_tracks))
added = list(set(new_tracks) - set(old_tracks))
if removed or added:
await playlist.edit(results)
return added, removed, playlist
async def _playlist_check(self, ctx: commands.Context) -> bool:
if not self._player_check(ctx):
if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed
if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.")
await self.send_embed_msg(ctx, title=msg, description=desc)
return False
try:
if (
not ctx.author.voice.channel.permissions_for(ctx.me).connect
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
and self.is_vc_full(ctx.author.voice.channel)
):
await self.send_embed_msg(
ctx,
title=_("Unable To Get Playlists"),
description=_("I don't have permission to connect to your channel."),
)
return False
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
except IndexError:
await self.send_embed_msg(
ctx,
title=_("Unable To Get Playlists"),
description=_("Connection to Lavalink has not yet been established."),
)
return False
except AttributeError:
await self.send_embed_msg(
ctx,
title=_("Unable To Get Playlists"),
description=_("Connect to a voice channel first."),
)
return False
player = lavalink.get_player(ctx.guild.id)
player.store("channel", ctx.channel.id)
player.store("guild", ctx.guild.id)
if (
not ctx.author.voice or ctx.author.voice.channel != player.channel
) and not await self._can_instaskip(ctx, ctx.author):
await self.send_embed_msg(
ctx,
title=_("Unable To Get Playlists"),
description=_("You must be in the voice channel to use the playlist command."),
)
return False
await self._eq_check(ctx, player)
await self.set_player_settings(ctx)
return True
async def fetch_playlist_tracks(
self,
ctx: commands.Context,
player: lavalink.player_manager.Player,
query: Query,
skip_cache: bool = False,
) -> Union[discord.Message, None, List[MutableMapping]]:
search = query.is_search
tracklist = []
if query.is_spotify:
try:
if self.play_lock[ctx.message.guild.id]:
return await self.send_embed_msg(
ctx,
title=_("Unable To Get Tracks"),
description=_("Wait until the playlist has finished loading."),
)
except KeyError:
pass
tracks = await self._get_spotify_tracks(ctx, query, forced=skip_cache)
if isinstance(tracks, discord.Message):
return None
if not tracks:
embed = discord.Embed(title=_("Nothing found."))
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
embed = discord.Embed(title=_("Track is not playable."))
embed.description = _(
"**{suffix}** is not a fully supported format and some "
"tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
async for track in AsyncIter(tracks):
track_obj = self.get_track_json(player, other_track=track)
tracklist.append(track_obj)
self.update_player_lock(ctx, False)
elif query.is_search:
try:
result, called_api = await self.api_interface.fetch_track(
ctx, player, query, forced=skip_cache
)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, try again in a few "
"minutes."
),
)
tracks = result.tracks
if not tracks:
embed = discord.Embed(title=_("Nothing found."))
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
embed = discord.Embed(title=_("Track is not playable."))
embed.description = _(
"**{suffix}** is not a fully supported format and some "
"tracks may not play."
).format(suffix=query.suffix)
return await self.send_embed_msg(ctx, embed=embed)
else:
try:
result, called_api = await self.api_interface.fetch_track(
ctx, player, query, forced=skip_cache
)
except TrackEnqueueError:
self.update_player_lock(ctx, False)
return await self.send_embed_msg(
ctx,
title=_("Unable to Get Track"),
description=_(
"I'm unable get a track from Lavalink at the moment, try again in a few "
"minutes."
),
)
tracks = result.tracks
if not search and len(tracklist) == 0:
async for track in AsyncIter(tracks):
track_obj = self.get_track_json(player, other_track=track)
tracklist.append(track_obj)
elif len(tracklist) == 0:
track_obj = self.get_track_json(player, other_track=tracks[0])
tracklist.append(track_obj)
return tracklist
def humanize_scope(
self, scope: str, ctx: Union[discord.Guild, discord.abc.User, str] = None, the: bool = None
) -> Optional[str]:
if scope == PlaylistScope.GLOBAL.value:
return _("the Global") if the else _("Global")
elif scope == PlaylistScope.GUILD.value:
return ctx.name if ctx else _("the Server") if the else _("Server")
elif scope == PlaylistScope.USER.value:
return str(ctx) if ctx else _("the User") if the else _("User")

View File

@ -0,0 +1,165 @@
import logging
import math
from typing import List, Tuple
import discord
import lavalink
from fuzzywuzzy import process
from redbot.core.utils import AsyncIter
from redbot.core import commands
from redbot.core.utils.chat_formatting import humanize_number
from ...audio_dataclasses import LocalPath, Query
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass, _
log = logging.getLogger("red.cogs.Audio.cog.Utilities.queue")
class QueueUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def _build_queue_page(
self,
ctx: commands.Context,
queue: list,
player: lavalink.player_manager.Player,
page_num: int,
) -> discord.Embed:
shuffle = await self.config.guild(ctx.guild).shuffle()
repeat = await self.config.guild(ctx.guild).repeat()
autoplay = await self.config.guild(ctx.guild).auto_play()
queue_num_pages = math.ceil(len(queue) / 10)
queue_idx_start = (page_num - 1) * 10
queue_idx_end = queue_idx_start + 10
if len(player.queue) > 500:
queue_list = _("__Too many songs in the queue, only showing the first 500__.\n\n")
else:
queue_list = ""
arrow = await self.draw_time(ctx)
pos = self.format_time(player.position)
if player.current.is_stream:
dur = "LIVE"
else:
dur = self.format_time(player.current.length)
query = Query.process_input(player.current, self.local_folder_current_path)
current_track_description = self.get_track_description(
player.current, self.local_folder_current_path
)
if query.is_stream:
queue_list += _("**Currently livestreaming:**\n")
queue_list += f"{current_track_description}\n"
queue_list += _("Requested by: **{user}**").format(user=player.current.requester)
queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n"
else:
queue_list += _("Playing: ")
queue_list += f"{current_track_description}\n"
queue_list += _("Requested by: **{user}**").format(user=player.current.requester)
queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n"
async for i, track in AsyncIter(queue[queue_idx_start:queue_idx_end]).enumerate(
start=queue_idx_start
):
req_user = track.requester
track_idx = i + 1
track_description = self.get_track_description(
track, self.local_folder_current_path, shorten=True
)
queue_list += f"`{track_idx}.` {track_description}, "
queue_list += _("requested by **{user}**\n").format(user=req_user)
embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Queue for __{guild_name}__").format(guild_name=ctx.guild.name),
description=queue_list,
)
if await self.config.guild(ctx.guild).thumbnail() and player.current.thumbnail:
embed.set_thumbnail(url=player.current.thumbnail)
queue_dur = await self.queue_duration(ctx)
queue_total_duration = self.format_time(queue_dur)
text = _(
"Page {page_num}/{total_pages} | {num_tracks} tracks, {num_remaining} remaining\n"
).format(
page_num=humanize_number(page_num),
total_pages=humanize_number(queue_num_pages),
num_tracks=len(player.queue),
num_remaining=queue_total_duration,
)
text += (
_("Auto-Play")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}")
)
text += (
(" | " if text else "")
+ _("Shuffle")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}")
)
text += (
(" | " if text else "")
+ _("Repeat")
+ ": "
+ ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}")
)
embed.set_footer(text=text)
return embed
async def _build_queue_search_list(
self, queue_list: List[lavalink.Track], search_words: str
) -> List[Tuple[int, str]]:
track_list = []
async for queue_idx, track in AsyncIter(queue_list).enumerate(start=1):
if not self.match_url(track.uri):
query = Query.process_input(track, self.local_folder_current_path)
if (
query.is_local
and query.local_track_path is not None
and track.title == "Unknown title"
):
track_title = query.local_track_path.to_string_user()
else:
track_title = "{} - {}".format(track.author, track.title)
else:
track_title = track.title
song_info = {str(queue_idx): track_title}
track_list.append(song_info)
search_results = process.extract(search_words, track_list, limit=50)
search_list = []
async for search, percent_match in AsyncIter(search_results):
async for queue_position, title in AsyncIter(search.items()):
if percent_match > 89:
search_list.append((queue_position, title))
return search_list
async def _build_queue_search_page(
self, ctx: commands.Context, page_num: int, search_list: List[Tuple[int, str]]
) -> discord.Embed:
search_num_pages = math.ceil(len(search_list) / 10)
search_idx_start = (page_num - 1) * 10
search_idx_end = search_idx_start + 10
track_match = ""
async for i, track in AsyncIter(search_list[search_idx_start:search_idx_end]).enumerate(
start=search_idx_start
):
track_idx = i + 1
if type(track) is str:
track_location = LocalPath(track, self.local_folder_current_path).to_string_user()
track_match += "`{}.` **{}**\n".format(track_idx, track_location)
else:
track_match += "`{}.` **{}**\n".format(track[0], track[1])
embed = discord.Embed(
colour=await ctx.embed_colour(), title=_("Matching Tracks:"), description=track_match
)
embed.set_footer(
text=_("Page {page_num}/{total_pages} | {num_tracks} tracks").format(
page_num=humanize_number(page_num),
total_pages=humanize_number(search_num_pages),
num_tracks=len(search_list),
)
)
return embed

View File

@ -0,0 +1,82 @@
import logging
import re
from typing import Final, List, Set, Pattern
from urllib.parse import urlparse
import discord
from redbot.core import Config
from ...audio_dataclasses import Query
from ..abc import MixinMeta
from ..cog_utils import CompositeMetaClass
log = logging.getLogger("red.cogs.Audio.cog.Utilities.validation")
_RE_YT_LIST_PLAYLIST: Final[Pattern] = re.compile(
r"^(https?://)?(www\.)?(youtube\.com|youtu\.?be)(/playlist\?).*(list=)(.*)(&|$)"
)
class ValidationUtilities(MixinMeta, metaclass=CompositeMetaClass):
def match_url(self, url: str) -> bool:
try:
query_url = urlparse(url)
return all([query_url.scheme, query_url.netloc, query_url.path])
except Exception:
return False
def match_yt_playlist(self, url: str) -> bool:
if _RE_YT_LIST_PLAYLIST.match(url):
return True
return False
def is_url_allowed(self, url: str) -> bool:
valid_tld = [
"youtube.com",
"youtu.be",
"soundcloud.com",
"bandcamp.com",
"vimeo.com",
"beam.pro",
"mixer.com",
"twitch.tv",
"spotify.com",
"localtracks",
]
query_url = urlparse(url)
url_domain = ".".join(query_url.netloc.split(".")[-2:])
if not query_url.netloc:
url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:])
return True if url_domain in valid_tld else False
def is_vc_full(self, channel: discord.VoiceChannel) -> bool:
return not (channel.user_limit == 0 or channel.user_limit > len(channel.members))
async def is_query_allowed(
self, config: Config, guild: discord.Guild, query: str, query_obj: Query = None
) -> bool:
"""Checks if the query is allowed in this server or globally"""
query = query.lower().strip()
if query_obj is not None:
query = query_obj.lavalink_query.replace("ytsearch:", "youtubesearch").replace(
"scsearch:", "soundcloudsearch"
)
global_whitelist = set(await config.url_keyword_whitelist())
global_whitelist = [i.lower() for i in global_whitelist]
if global_whitelist:
return any(i in query for i in global_whitelist)
global_blacklist = set(await config.url_keyword_blacklist())
global_blacklist = [i.lower() for i in global_blacklist]
if any(i in query for i in global_blacklist):
return False
if guild is not None:
whitelist_unique: Set[str] = set(await config.guild(guild).url_keyword_whitelist())
whitelist: List[str] = [i.lower() for i in whitelist_unique]
if whitelist:
return any(i in query for i in whitelist)
blacklist_unique: Set[str] = set(await config.guild(guild).url_keyword_blacklist())
blacklist: List[str] = [i.lower() for i in blacklist_unique]
return not any(i in query for i in blacklist)
return True

View File

@ -1,372 +0,0 @@
import asyncio
import concurrent.futures
import contextlib
import datetime
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Union, MutableMapping, Mapping
import apsw
from redbot.core import Config
from redbot.core.bot import Red
from redbot.core.data_manager import cog_data_path
from .errors import InvalidTableError
from .sql_statements import *
from .utils import PlaylistScope
log = logging.getLogger("red.audio.database")
if TYPE_CHECKING:
database_connection: apsw.Connection
_bot: Red
_config: Config
else:
_config = None
_bot = None
database_connection = None
SCHEMA_VERSION = 3
SQLError = apsw.ExecutionCompleteError
_PARSER: Mapping = {
"youtube": {
"insert": YOUTUBE_UPSERT,
"youtube_url": {"query": YOUTUBE_QUERY},
"update": YOUTUBE_UPDATE,
},
"spotify": {
"insert": SPOTIFY_UPSERT,
"track_info": {"query": SPOTIFY_QUERY},
"update": SPOTIFY_UPDATE,
},
"lavalink": {
"insert": LAVALINK_UPSERT,
"data": {"query": LAVALINK_QUERY, "played": LAVALINK_QUERY_LAST_FETCHED_RANDOM},
"update": LAVALINK_UPDATE,
},
}
def _pass_config_to_databases(config: Config, bot: Red):
global _config, _bot, database_connection
if _config is None:
_config = config
if _bot is None:
_bot = bot
if database_connection is None:
database_connection = apsw.Connection(
str(cog_data_path(_bot.get_cog("Audio")) / "Audio.db")
)
@dataclass
class PlaylistFetchResult:
playlist_id: int
playlist_name: str
scope_id: int
author_id: int
playlist_url: Optional[str] = None
tracks: List[MutableMapping] = field(default_factory=lambda: [])
def __post_init__(self):
if isinstance(self.tracks, str):
self.tracks = json.loads(self.tracks)
@dataclass
class CacheFetchResult:
query: Optional[Union[str, MutableMapping]]
last_updated: int
def __post_init__(self):
if isinstance(self.last_updated, int):
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
if isinstance(self.query, str) and all(
k in self.query for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
):
self.query = json.loads(self.query)
@dataclass
class CacheLastFetchResult:
tracks: List[MutableMapping] = field(default_factory=lambda: [])
def __post_init__(self):
if isinstance(self.tracks, str):
self.tracks = json.loads(self.tracks)
@dataclass
class CacheGetAllLavalink:
query: str
data: List[MutableMapping] = field(default_factory=lambda: [])
def __post_init__(self):
if isinstance(self.data, str):
self.data = json.loads(self.data)
class CacheInterface:
def __init__(self):
self.database = database_connection.cursor()
@staticmethod
def close():
with contextlib.suppress(Exception):
database_connection.close()
async def init(self):
self.database.execute(PRAGMA_SET_temp_store)
self.database.execute(PRAGMA_SET_journal_mode)
self.database.execute(PRAGMA_SET_read_uncommitted)
self.maybe_migrate()
self.database.execute(LAVALINK_CREATE_TABLE)
self.database.execute(LAVALINK_CREATE_INDEX)
self.database.execute(YOUTUBE_CREATE_TABLE)
self.database.execute(YOUTUBE_CREATE_INDEX)
self.database.execute(SPOTIFY_CREATE_TABLE)
self.database.execute(SPOTIFY_CREATE_INDEX)
await self.clean_up_old_entries()
async def clean_up_old_entries(self):
max_age = await _config.cache_age()
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
maxage_int = int(time.mktime(maxage.timetuple()))
values = {"maxage": maxage_int}
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.execute, LAVALINK_DELETE_OLD_ENTRIES, values)
executor.submit(self.database.execute, YOUTUBE_DELETE_OLD_ENTRIES, values)
executor.submit(self.database.execute, SPOTIFY_DELETE_OLD_ENTRIES, values)
def maybe_migrate(self):
current_version = self.database.execute(PRAGMA_FETCH_user_version).fetchone()
if isinstance(current_version, tuple):
current_version = current_version[0]
if current_version == SCHEMA_VERSION:
return
self.database.execute(PRAGMA_SET_user_version, {"version": SCHEMA_VERSION})
async def insert(self, table: str, values: List[MutableMapping]):
try:
query = _PARSER.get(table, {}).get("insert")
if query is None:
raise InvalidTableError(f"{table} is not a valid table in the database.")
self.database.execute("BEGIN;")
self.database.executemany(query, values)
self.database.execute("COMMIT;")
except Exception as err:
log.debug("Error during audio db insert", exc_info=err)
async def update(self, table: str, values: Dict[str, Union[str, int]]):
try:
table = _PARSER.get(table, {})
sql_query = table.get("update")
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
values["last_fetched"] = time_now
if not table:
raise InvalidTableError(f"{table} is not a valid table in the database.")
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.database.execute, sql_query, values)
except Exception as err:
log.debug("Error during audio db update", exc_info=err)
async def fetch_one(
self, table: str, query: str, values: Dict[str, Union[str, int]]
) -> Tuple[Optional[str], bool]:
table = _PARSER.get(table, {})
sql_query = table.get(query, {}).get("query")
if not table:
raise InvalidTableError(f"{table} is not a valid table in the database.")
max_age = await _config.cache_age()
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
maxage_int = int(time.mktime(maxage.timetuple()))
values.update({"maxage": maxage_int})
output = self.database.execute(sql_query, values).fetchone() or (None, 0)
result = CacheFetchResult(*output)
return result.query, False
async def fetch_all(
self, table: str, query: str, values: Dict[str, Union[str, int]]
) -> List[CacheLastFetchResult]:
table = _PARSER.get(table, {})
sql_query = table.get(query, {}).get("played")
if not table:
raise InvalidTableError(f"{table} is not a valid table in the database.")
output = []
for index, row in enumerate(self.database.execute(sql_query, values), start=1):
if index % 50 == 0:
await asyncio.sleep(0.01)
output.append(CacheLastFetchResult(*row))
return output
async def fetch_random(
self, table: str, query: str, values: Dict[str, Union[str, int]]
) -> CacheLastFetchResult:
table = _PARSER.get(table, {})
sql_query = table.get(query, {}).get("played")
if not table:
raise InvalidTableError(f"{table} is not a valid table in the database.")
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
for future in concurrent.futures.as_completed(
[executor.submit(self.database.execute, sql_query, values)]
):
try:
row = future.result()
row = row.fetchone()
except Exception as exc:
log.debug(f"Failed to completed random fetch from database", exc_info=exc)
return CacheLastFetchResult(*row)
class PlaylistInterface:
def __init__(self):
self.cursor = database_connection.cursor()
self.cursor.execute(PRAGMA_SET_temp_store)
self.cursor.execute(PRAGMA_SET_journal_mode)
self.cursor.execute(PRAGMA_SET_read_uncommitted)
self.cursor.execute(PLAYLIST_CREATE_TABLE)
self.cursor.execute(PLAYLIST_CREATE_INDEX)
@staticmethod
def close():
with contextlib.suppress(Exception):
database_connection.close()
@staticmethod
def get_scope_type(scope: str) -> int:
if scope == PlaylistScope.GLOBAL.value:
table = 1
elif scope == PlaylistScope.USER.value:
table = 3
else:
table = 2
return table
def fetch(self, scope: str, playlist_id: int, scope_id: int) -> PlaylistFetchResult:
scope_type = self.get_scope_type(scope)
row = (
self.cursor.execute(
PLAYLIST_FETCH,
({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}),
).fetchone()
or []
)
return PlaylistFetchResult(*row) if row else None
async def fetch_all(
self, scope: str, scope_id: int, author_id=None
) -> List[PlaylistFetchResult]:
scope_type = self.get_scope_type(scope)
if author_id is not None:
output = []
for index, row in enumerate(
self.cursor.execute(
PLAYLIST_FETCH_ALL_WITH_FILTER,
({"scope_type": scope_type, "scope_id": scope_id, "author_id": author_id}),
),
start=1,
):
if index % 50 == 0:
await asyncio.sleep(0.01)
output.append(row)
else:
output = []
for index, row in enumerate(
self.cursor.execute(
PLAYLIST_FETCH_ALL, ({"scope_type": scope_type, "scope_id": scope_id})
),
start=1,
):
if index % 50 == 0:
await asyncio.sleep(0.01)
output.append(row)
return [PlaylistFetchResult(*row) for row in output] if output else []
async def fetch_all_converter(
self, scope: str, playlist_name, playlist_id
) -> List[PlaylistFetchResult]:
scope_type = self.get_scope_type(scope)
try:
playlist_id = int(playlist_id)
except Exception:
playlist_id = -1
output = []
for index, row in enumerate(
self.cursor.execute(
PLAYLIST_FETCH_ALL_CONVERTER,
(
{
"scope_type": scope_type,
"playlist_name": playlist_name,
"playlist_id": playlist_id,
}
),
),
start=1,
):
if index % 50 == 0:
await asyncio.sleep(0.01)
output.append(row)
return [PlaylistFetchResult(*row) for row in output] if output else []
def delete(self, scope: str, playlist_id: int, scope_id: int):
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(
self.cursor.execute,
PLAYLIST_DELETE,
({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}),
)
def delete_scheduled(self):
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self.cursor.execute, PLAYLIST_DELETE_SCHEDULED)
def drop(self, scope: str):
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(
self.cursor.execute, PLAYLIST_DELETE_SCOPE, ({"scope_type": scope_type})
)
def create_table(self, scope: str):
scope_type = self.get_scope_type(scope)
return self.cursor.execute(PLAYLIST_CREATE_TABLE, ({"scope_type": scope_type}))
def upsert(
self,
scope: str,
playlist_id: int,
playlist_name: str,
scope_id: int,
author_id: int,
playlist_url: Optional[str],
tracks: List[MutableMapping],
):
scope_type = self.get_scope_type(scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(
self.cursor.execute,
PLAYLIST_UPSERT,
{
"scope_type": str(scope_type),
"playlist_id": int(playlist_id),
"playlist_name": str(playlist_name),
"scope_id": int(scope_id),
"author_id": int(author_id),
"playlist_url": playlist_url,
"tracks": json.dumps(tracks),
},
)

View File

@ -1,14 +1,15 @@
# The equalizer class and some audio eq functions are derived from
# 180093157554388993's work, with his permission
from typing import Final
class Equalizer:
def __init__(self):
self._band_count = 15
self.bands = [0.0 for _loop_counter in range(self._band_count)]
self.band_count: Final[int] = 15
self.bands = [0.0 for _loop_counter in range(self.band_count)]
def set_gain(self, band: int, gain: float):
if band < 0 or band >= self._band_count:
if band < 0 or band >= self.band_count:
raise IndexError(f"Band {band} does not exist!")
gain = min(max(gain, -0.25), 1.0)
@ -16,13 +17,13 @@ class Equalizer:
self.bands[band] = gain
def get_gain(self, band: int):
if band < 0 or band >= self._band_count:
if band < 0 or band >= self.band_count:
raise IndexError(f"Band {band} does not exist!")
return self.bands[band]
def visualise(self):
block = ""
bands = [str(band + 1).zfill(2) for band in range(self._band_count)]
bands = [str(band + 1).zfill(2) for band in range(self.band_count)]
bottom = (" " * 8) + " ".join(bands)
gains = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0, -0.1, -0.2, -0.25]

View File

@ -9,7 +9,7 @@ import shutil
import sys
import tempfile
import time
from typing import ClassVar, List, Optional, Tuple
from typing import ClassVar, Final, List, Optional, Tuple, Pattern
import aiohttp
from tqdm import tqdm
@ -19,24 +19,25 @@ from redbot.core import data_manager
from .errors import LavalinkDownloadFailed
log = logging.getLogger("red.audio.manager")
JAR_VERSION = "3.3.1"
JAR_BUILD = 987
LAVALINK_DOWNLOAD_URL = (
f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/"
JAR_VERSION: Final[str] = "3.3.1"
JAR_BUILD: Final[int] = 987
LAVALINK_DOWNLOAD_URL: Final[str] = (
"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/"
f"{JAR_VERSION}_{JAR_BUILD}/"
"Lavalink.jar"
)
LAVALINK_DOWNLOAD_DIR = data_manager.cog_data_path(raw_name="Audio")
LAVALINK_JAR_FILE = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar"
BUNDLED_APP_YML = pathlib.Path(__file__).parent / "data" / "application.yml"
LAVALINK_APP_YML = LAVALINK_DOWNLOAD_DIR / "application.yml"
LAVALINK_DOWNLOAD_DIR: Final[pathlib.Path] = data_manager.cog_data_path(raw_name="Audio")
LAVALINK_JAR_FILE: Final[pathlib.Path] = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar"
BUNDLED_APP_YML: Final[pathlib.Path] = pathlib.Path(__file__).parent / "data" / "application.yml"
LAVALINK_APP_YML: Final[pathlib.Path] = LAVALINK_DOWNLOAD_DIR / "application.yml"
_RE_READY_LINE = re.compile(rb"Started Launcher in \S+ seconds")
_FAILED_TO_START = re.compile(rb"Web server failed to start. (.*)")
_RE_BUILD_LINE = re.compile(rb"Build:\s+(?P<build>\d+)")
_RE_JAVA_VERSION_LINE = re.compile(
_RE_READY_LINE: Final[Pattern] = re.compile(rb"Started Launcher in \S+ seconds")
_FAILED_TO_START: Final[Pattern] = re.compile(rb"Web server failed to start. (.*)")
_RE_BUILD_LINE: Final[Pattern] = re.compile(rb"Build:\s+(?P<build>\d+)")
_RE_JAVA_VERSION_LINE: Final[Pattern] = re.compile(
r'version "(?P<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"'
)
_RE_JAVA_SHORT_VERSION = re.compile(r'version "(?P<major>\d+)"')
_RE_JAVA_SHORT_VERSION: Final[Pattern] = re.compile(r'version "(?P<major>\d+)"')
class ServerManager:

View File

@ -1,3 +1,5 @@
from typing import Final
# TODO: https://github.com/Cog-Creators/Red-DiscordBot/pull/3195#issuecomment-567821701
# Thanks a lot Sinbad!
@ -26,15 +28,19 @@ __all__ = [
"YOUTUBE_UPSERT",
"YOUTUBE_UPDATE",
"YOUTUBE_QUERY",
"YOUTUBE_QUERY_ALL",
"YOUTUBE_DELETE_OLD_ENTRIES",
"YOUTUBE_QUERY_LAST_FETCHED_RANDOM",
# Spotify table statements
"SPOTIFY_DROP_TABLE",
"SPOTIFY_CREATE_INDEX",
"SPOTIFY_CREATE_TABLE",
"SPOTIFY_UPSERT",
"SPOTIFY_QUERY",
"SPOTIFY_QUERY_ALL",
"SPOTIFY_UPDATE",
"SPOTIFY_DELETE_OLD_ENTRIES",
"SPOTIFY_QUERY_LAST_FETCHED_RANDOM",
# Lavalink table statements
"LAVALINK_DROP_TABLE",
"LAVALINK_CREATE_TABLE",
@ -42,30 +48,44 @@ __all__ = [
"LAVALINK_UPSERT",
"LAVALINK_UPDATE",
"LAVALINK_QUERY",
"LAVALINK_QUERY_ALL",
"LAVALINK_QUERY_LAST_FETCHED_RANDOM",
"LAVALINK_DELETE_OLD_ENTRIES",
"LAVALINK_FETCH_ALL_ENTRIES_GLOBAL",
]
# PRAGMA Statements
PRAGMA_SET_temp_store = """
PRAGMA_SET_temp_store: Final[
str
] = """
PRAGMA temp_store = 2;
"""
PRAGMA_SET_journal_mode = """
PRAGMA_SET_journal_mode: Final[
str
] = """
PRAGMA journal_mode = wal;
"""
PRAGMA_SET_read_uncommitted = """
PRAGMA_SET_read_uncommitted: Final[
str
] = """
PRAGMA read_uncommitted = 1;
"""
PRAGMA_FETCH_user_version = """
PRAGMA_FETCH_user_version: Final[
str
] = """
pragma user_version;
"""
PRAGMA_SET_user_version = """
PRAGMA_SET_user_version: Final[
str
] = """
pragma user_version=3;
"""
# Playlist table statements
PLAYLIST_CREATE_TABLE = """
PLAYLIST_CREATE_TABLE: Final[
str
] = """
CREATE TABLE IF NOT EXISTS playlists (
scope_type INTEGER NOT NULL,
playlist_id INTEGER NOT NULL,
@ -78,7 +98,9 @@ CREATE TABLE IF NOT EXISTS playlists (
PRIMARY KEY (playlist_id, scope_id, scope_type)
);
"""
PLAYLIST_DELETE = """
PLAYLIST_DELETE: Final[
str
] = """
UPDATE playlists
SET
deleted = true
@ -90,21 +112,27 @@ WHERE
)
;
"""
PLAYLIST_DELETE_SCOPE = """
PLAYLIST_DELETE_SCOPE: Final[
str
] = """
DELETE
FROM
playlists
WHERE
scope_type = :scope_type ;
"""
PLAYLIST_DELETE_SCHEDULED = """
PLAYLIST_DELETE_SCHEDULED: Final[
str
] = """
DELETE
FROM
playlists
WHERE
deleted = true;
"""
PLAYLIST_FETCH_ALL = """
PLAYLIST_FETCH_ALL: Final[
str
] = """
SELECT
playlist_id,
playlist_name,
@ -120,7 +148,9 @@ WHERE
AND deleted = false
;
"""
PLAYLIST_FETCH_ALL_WITH_FILTER = """
PLAYLIST_FETCH_ALL_WITH_FILTER: Final[
str
] = """
SELECT
playlist_id,
playlist_name,
@ -139,7 +169,9 @@ WHERE
)
;
"""
PLAYLIST_FETCH_ALL_CONVERTER = """
PLAYLIST_FETCH_ALL_CONVERTER: Final[
str
] = """
SELECT
playlist_id,
playlist_name,
@ -162,7 +194,9 @@ WHERE
)
;
"""
PLAYLIST_FETCH = """
PLAYLIST_FETCH: Final[
str
] = """
SELECT
playlist_id,
playlist_name,
@ -181,7 +215,9 @@ WHERE
)
LIMIT 1;
"""
PLAYLIST_UPSERT = """
PLAYLIST_UPSERT: Final[
str
] = """
INSERT INTO
playlists ( scope_type, playlist_id, playlist_name, scope_id, author_id, playlist_url, tracks )
VALUES
@ -195,15 +231,23 @@ VALUES
playlist_url = excluded.playlist_url,
tracks = excluded.tracks;
"""
PLAYLIST_CREATE_INDEX = """
CREATE INDEX IF NOT EXISTS name_index ON playlists (scope_type, playlist_id, playlist_name, scope_id);
PLAYLIST_CREATE_INDEX: Final[
str
] = """
CREATE INDEX IF NOT EXISTS name_index ON playlists (
scope_type, playlist_id, playlist_name, scope_id
);
"""
# YouTube table statements
YOUTUBE_DROP_TABLE = """
YOUTUBE_DROP_TABLE: Final[
str
] = """
DROP TABLE IF EXISTS youtube;
"""
YOUTUBE_CREATE_TABLE = """
YOUTUBE_CREATE_TABLE: Final[
str
] = """
CREATE TABLE IF NOT EXISTS youtube(
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_info TEXT,
@ -212,11 +256,15 @@ CREATE TABLE IF NOT EXISTS youtube(
last_fetched INTEGER
);
"""
YOUTUBE_CREATE_INDEX = """
YOUTUBE_CREATE_INDEX: Final[
str
] = """
CREATE UNIQUE INDEX IF NOT EXISTS idx_youtube_url
ON youtube (track_info, youtube_url);
"""
YOUTUBE_UPSERT = """INSERT INTO
YOUTUBE_UPSERT: Final[
str
] = """INSERT INTO
youtube
(
track_info,
@ -241,12 +289,16 @@ DO UPDATE
track_info = excluded.track_info,
last_updated = excluded.last_updated
"""
YOUTUBE_UPDATE = """
YOUTUBE_UPDATE: Final[
str
] = """
UPDATE youtube
SET last_fetched=:last_fetched
WHERE track_info=:track;
"""
YOUTUBE_QUERY = """
YOUTUBE_QUERY: Final[
str
] = """
SELECT youtube_url, last_updated
FROM youtube
WHERE
@ -254,17 +306,41 @@ WHERE
AND last_updated > :maxage
LIMIT 1;
"""
YOUTUBE_DELETE_OLD_ENTRIES = """
YOUTUBE_QUERY_ALL: Final[
str
] = """
SELECT youtube_url, last_updated
FROM youtube
"""
YOUTUBE_DELETE_OLD_ENTRIES: Final[
str
] = """
DELETE FROM youtube
WHERE
last_updated < :maxage;
last_updated < :maxage
;
"""
YOUTUBE_QUERY_LAST_FETCHED_RANDOM: Final[
str
] = """
SELECT youtube_url, last_updated
FROM youtube
WHERE
last_fetched > :day
AND last_updated > :maxage
LIMIT 100
;
"""
# Spotify table statements
SPOTIFY_DROP_TABLE = """
SPOTIFY_DROP_TABLE: Final[
str
] = """
DROP TABLE IF EXISTS spotify;
"""
SPOTIFY_CREATE_TABLE = """
SPOTIFY_CREATE_TABLE: Final[
str
] = """
CREATE TABLE IF NOT EXISTS spotify(
id TEXT,
type TEXT,
@ -277,11 +353,15 @@ CREATE TABLE IF NOT EXISTS spotify(
last_fetched INTEGER
);
"""
SPOTIFY_CREATE_INDEX = """
SPOTIFY_CREATE_INDEX: Final[
str
] = """
CREATE UNIQUE INDEX IF NOT EXISTS idx_spotify_uri
ON spotify (id, type, uri);
"""
SPOTIFY_UPSERT = """INSERT INTO
SPOTIFY_UPSERT: Final[
str
] = """INSERT INTO
spotify
(
id, type, uri, track_name, artist_name,
@ -306,12 +386,16 @@ DO UPDATE
track_info = excluded.track_info,
last_updated = excluded.last_updated;
"""
SPOTIFY_UPDATE = """
SPOTIFY_UPDATE: Final[
str
] = """
UPDATE spotify
SET last_fetched=:last_fetched
WHERE uri=:uri;
"""
SPOTIFY_QUERY = """
SPOTIFY_QUERY: Final[
str
] = """
SELECT track_info, last_updated
FROM spotify
WHERE
@ -319,17 +403,41 @@ WHERE
AND last_updated > :maxage
LIMIT 1;
"""
SPOTIFY_DELETE_OLD_ENTRIES = """
SPOTIFY_QUERY_ALL: Final[
str
] = """
SELECT track_info, last_updated
FROM spotify
"""
SPOTIFY_DELETE_OLD_ENTRIES: Final[
str
] = """
DELETE FROM spotify
WHERE
last_updated < :maxage;
last_updated < :maxage
;
"""
SPOTIFY_QUERY_LAST_FETCHED_RANDOM: Final[
str
] = """
SELECT track_info, last_updated
FROM spotify
WHERE
last_fetched > :day
AND last_updated > :maxage
LIMIT 100
;
"""
# Lavalink table statements
LAVALINK_DROP_TABLE = """
LAVALINK_DROP_TABLE: Final[
str
] = """
DROP TABLE IF EXISTS lavalink ;
"""
LAVALINK_CREATE_TABLE = """
LAVALINK_CREATE_TABLE: Final[
str
] = """
CREATE TABLE IF NOT EXISTS lavalink(
query TEXT,
data JSON,
@ -338,11 +446,15 @@ CREATE TABLE IF NOT EXISTS lavalink(
);
"""
LAVALINK_CREATE_INDEX = """
LAVALINK_CREATE_INDEX: Final[
str
] = """
CREATE UNIQUE INDEX IF NOT EXISTS idx_lavalink_query
ON lavalink (query);
"""
LAVALINK_UPSERT = """INSERT INTO
LAVALINK_UPSERT: Final[
str
] = """INSERT INTO
lavalink
(
query,
@ -366,12 +478,16 @@ DO UPDATE
data = excluded.data,
last_updated = excluded.last_updated;
"""
LAVALINK_UPDATE = """
LAVALINK_UPDATE: Final[
str
] = """
UPDATE lavalink
SET last_fetched=:last_fetched
WHERE query=:query;
"""
LAVALINK_QUERY = """
LAVALINK_QUERY: Final[
str
] = """
SELECT data, last_updated
FROM lavalink
WHERE
@ -379,22 +495,34 @@ WHERE
AND last_updated > :maxage
LIMIT 1;
"""
LAVALINK_QUERY_LAST_FETCHED_RANDOM = """
SELECT data
LAVALINK_QUERY_ALL: Final[
str
] = """
SELECT data, last_updated
FROM lavalink
"""
LAVALINK_QUERY_LAST_FETCHED_RANDOM: Final[
str
] = """
SELECT data, last_updated
FROM lavalink
WHERE
last_fetched > :day
AND last_updated > :maxage
ORDER BY RANDOM()
LIMIT 10
LIMIT 100
;
"""
LAVALINK_DELETE_OLD_ENTRIES = """
LAVALINK_DELETE_OLD_ENTRIES: Final[
str
] = """
DELETE FROM lavalink
WHERE
last_updated < :maxage;
last_updated < :maxage
;
"""
LAVALINK_FETCH_ALL_ENTRIES_GLOBAL = """
LAVALINK_FETCH_ALL_ENTRIES_GLOBAL: Final[
str
] = """
SELECT query, data
FROM lavalink
"""

View File

@ -1,336 +1,10 @@
import asyncio
import contextlib
import functools
import re
import tarfile
import time
import zipfile
from enum import Enum, unique
from io import BytesIO
from typing import MutableMapping, Optional, TYPE_CHECKING
from urllib.parse import urlparse
from typing import MutableMapping
import discord
import lavalink
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator
from redbot.core.utils.chat_formatting import bold, box
from discord.utils import escape_markdown as escape
from .audio_dataclasses import Query
__all__ = [
"_pass_config_to_utils",
"track_limit",
"queue_duration",
"draw_time",
"dynamic_time",
"match_url",
"clear_react",
"match_yt_playlist",
"remove_react",
"get_track_description",
"track_creator",
"time_convert",
"url_check",
"userlimit",
"is_allowed",
"track_to_json",
"rgetattr",
"humanize_scope",
"CacheLevel",
"format_playlist_picker_data",
"get_track_description_unformatted",
"track_remaining_duration",
"Notifier",
"PlaylistScope",
]
_RE_TIME_CONVERTER = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])")
_RE_YT_LIST_PLAYLIST = re.compile(
r"^(https?://)?(www\.)?(youtube\.com|youtu\.?be)(/playlist\?).*(list=)(.*)(&|$)"
)
if TYPE_CHECKING:
_config: Config
_bot: Red
else:
_config = None
_bot = None
_ = Translator("Audio", __file__)
def _pass_config_to_utils(config: Config, bot: Red) -> None:
global _config, _bot
if _config is None:
_config = config
if _bot is None:
_bot = bot
def track_limit(track, maxlength) -> bool:
try:
length = round(track.length / 1000)
except AttributeError:
length = round(track / 1000)
if maxlength < length <= 900000000000000: # livestreams return 9223372036854775807ms
return False
return True
async def is_allowed(guild: discord.Guild, query: str, query_obj: Query = None) -> bool:
query = query.lower().strip()
if query_obj is not None:
query = query_obj.lavalink_query.replace("ytsearch:", "youtubesearch").replace(
"scsearch:", "soundcloudsearch"
)
global_whitelist = set(await _config.url_keyword_whitelist())
global_whitelist = [i.lower() for i in global_whitelist]
if global_whitelist:
return any(i in query for i in global_whitelist)
global_blacklist = set(await _config.url_keyword_blacklist())
global_blacklist = [i.lower() for i in global_blacklist]
if any(i in query for i in global_blacklist):
return False
if guild is not None:
whitelist = set(await _config.guild(guild).url_keyword_whitelist())
whitelist = [i.lower() for i in whitelist]
if whitelist:
return any(i in query for i in whitelist)
blacklist = set(await _config.guild(guild).url_keyword_blacklist())
blacklist = [i.lower() for i in blacklist]
return not any(i in query for i in blacklist)
return True
async def queue_duration(ctx) -> int:
player = lavalink.get_player(ctx.guild.id)
duration = []
for i in range(len(player.queue)):
if not player.queue[i].is_stream:
duration.append(player.queue[i].length)
queue_dur = sum(duration)
if not player.queue:
queue_dur = 0
try:
if not player.current.is_stream:
remain = player.current.length - player.position
else:
remain = 0
except AttributeError:
remain = 0
queue_total_duration = remain + queue_dur
return queue_total_duration
async def track_remaining_duration(ctx) -> int:
player = lavalink.get_player(ctx.guild.id)
if not player.current:
return 0
try:
if not player.current.is_stream:
remain = player.current.length - player.position
else:
remain = 0
except AttributeError:
remain = 0
return remain
async def draw_time(ctx) -> str:
player = lavalink.get_player(ctx.guild.id)
paused = player.paused
pos = player.position
dur = player.current.length
sections = 12
loc_time = round((pos / dur) * sections)
bar = "\N{BOX DRAWINGS HEAVY HORIZONTAL}"
seek = "\N{RADIO BUTTON}"
if paused:
msg = "\N{DOUBLE VERTICAL BAR}"
else:
msg = "\N{BLACK RIGHT-POINTING TRIANGLE}"
for i in range(sections):
if i == loc_time:
msg += seek
else:
msg += bar
return msg
def dynamic_time(seconds) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
d, h = divmod(h, 24)
if d > 0:
msg = "{0}d {1}h"
elif d == 0 and h > 0:
msg = "{1}h {2}m"
elif d == 0 and h == 0 and m > 0:
msg = "{2}m {3}s"
elif d == 0 and h == 0 and m == 0 and s > 0:
msg = "{3}s"
else:
msg = ""
return msg.format(d, h, m, s)
def format_playlist_picker_data(pid, pname, ptracks, pauthor, scope) -> str:
author = _bot.get_user(pauthor) or pauthor or _("Unknown")
line = _(
" - Name: <{pname}>\n"
" - Scope: < {scope} >\n"
" - ID: < {pid} >\n"
" - Tracks: < {ptracks} >\n"
" - Author: < {author} >\n\n"
).format(pname=pname, scope=humanize_scope(scope), pid=pid, ptracks=ptracks, author=author)
return box(line, lang="md")
def match_url(url) -> bool:
try:
query_url = urlparse(url)
return all([query_url.scheme, query_url.netloc, query_url.path])
except Exception:
return False
def match_yt_playlist(url) -> bool:
if _RE_YT_LIST_PLAYLIST.match(url):
return True
return False
async def remove_react(message, react_emoji, react_user) -> None:
with contextlib.suppress(discord.HTTPException):
await message.remove_reaction(react_emoji, react_user)
async def clear_react(bot: Red, message: discord.Message, emoji: MutableMapping = None) -> None:
try:
await message.clear_reactions()
except discord.Forbidden:
if not emoji:
return
with contextlib.suppress(discord.HTTPException):
for key in emoji.values():
await asyncio.sleep(0.2)
await message.remove_reaction(key, bot.user)
except discord.HTTPException:
return
def get_track_description(track) -> Optional[str]:
if track and getattr(track, "uri", None):
query = Query.process_input(track.uri)
if query.is_local or "localtracks/" in track.uri:
if track.title != "Unknown title":
return f'**{escape(f"{track.author} - {track.title}")}**' + escape(
f"\n{query.to_string_user()} "
)
else:
return escape(query.to_string_user())
else:
return f'**{escape(f"[{track.title}]({track.uri}) ")}**'
elif hasattr(track, "to_string_user") and track.is_local:
return escape(track.to_string_user() + " ")
def get_track_description_unformatted(track) -> Optional[str]:
if track and hasattr(track, "uri"):
query = Query.process_input(track.uri)
if query.is_local or "localtracks/" in track.uri:
if track.title != "Unknown title":
return escape(f"{track.author} - {track.title}")
else:
return escape(query.to_string_user())
else:
return escape(f"{track.title}")
elif hasattr(track, "to_string_user") and track.is_local:
return escape(track.to_string_user() + " ")
def track_creator(player, position=None, other_track=None) -> MutableMapping:
if position == "np":
queued_track = player.current
elif position is None:
queued_track = other_track
else:
queued_track = player.queue[position]
return track_to_json(queued_track)
def track_to_json(track: lavalink.Track) -> MutableMapping:
track_keys = track._info.keys()
track_values = track._info.values()
track_id = track.track_identifier
track_info = {}
for k, v in zip(track_keys, track_values):
track_info[k] = v
keys = ["track", "info"]
values = [track_id, track_info]
track_obj = {}
for key, value in zip(keys, values):
track_obj[key] = value
return track_obj
def time_convert(length) -> int:
match = _RE_TIME_CONVERTER.match(length)
if match is not None:
hr = int(match.group(1)) if match.group(1) else 0
mn = int(match.group(2)) if match.group(2) else 0
sec = int(match.group(3)) if match.group(3) else 0
pos = sec + (mn * 60) + (hr * 3600)
return pos
else:
try:
return int(length)
except ValueError:
return 0
def url_check(url) -> bool:
valid_tld = [
"youtube.com",
"youtu.be",
"soundcloud.com",
"bandcamp.com",
"vimeo.com",
"beam.pro",
"mixer.com",
"twitch.tv",
"spotify.com",
"localtracks",
]
query_url = urlparse(url)
url_domain = ".".join(query_url.netloc.split(".")[-2:])
if not query_url.netloc:
url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:])
return True if url_domain in valid_tld else False
def userlimit(channel) -> bool:
if channel.user_limit == 0 or channel.user_limit > len(channel.members) + 1:
return False
return True
def rsetattr(obj, attr, val):
pre, _, post = attr.rpartition(".")
return setattr(rgetattr(obj, pre) if pre else obj, post, val)
def rgetattr(obj, attr, *args):
def _getattr(obj2, attr2):
return getattr(obj2, attr2, *args)
return functools.reduce(_getattr, [obj] + attr.split("."))
from redbot.core import commands
class CacheLevel:
@ -494,13 +168,13 @@ class Notifier:
self.color = await self.context.embed_colour()
embed2 = discord.Embed(
colour=self.color,
title=self.updates.get(key).format(num=current, total=total, seconds=seconds),
title=self.updates.get(key, "").format(num=current, total=total, seconds=seconds),
)
if seconds and seconds_key:
embed2.set_footer(text=self.updates.get(seconds_key).format(seconds=seconds))
embed2.set_footer(text=self.updates.get(seconds_key, "").format(seconds=seconds))
try:
await self.message.edit(embed=embed2)
self.last_msg_time = time.time()
self.last_msg_time = int(time.time())
except discord.errors.NotFound:
pass
@ -514,7 +188,7 @@ class Notifier:
async def update_embed(self, embed: discord.Embed):
try:
await self.message.edit(embed=embed)
self.last_msg_time = time.time()
self.last_msg_time = int(time.time())
except discord.errors.NotFound:
pass
@ -531,13 +205,3 @@ class PlaylistScope(Enum):
@staticmethod
def list():
return list(map(lambda c: c.value, PlaylistScope))
def humanize_scope(scope, ctx=None, the=None):
if scope == PlaylistScope.GLOBAL.value:
return (_("the ") if the else "") + _("Global")
elif scope == PlaylistScope.GUILD.value:
return ctx.name if ctx else (_("the ") if the else "") + _("Server")
elif scope == PlaylistScope.USER.value:
return str(ctx) if ctx else (_("the ") if the else "") + _("User")