From 8fa47cb789461cdfeec74a9ff7dd26431dcb9d5a Mon Sep 17 00:00:00 2001 From: Draper <27962761+Drapersniper@users.noreply.github.com> Date: Wed, 20 May 2020 21:30:06 +0100 Subject: [PATCH] Merge V3/feature/audio into V3/develop (a.k.a. audio refactor) (#3459) --- redbot/cogs/audio/__init__.py | 7 +- redbot/cogs/audio/apis/__init__.py | 10 + redbot/cogs/audio/apis/api_utils.py | 140 + redbot/cogs/audio/apis/global_db.py | 42 + .../cogs/audio/{apis.py => apis/interface.py} | 906 +- redbot/cogs/audio/apis/local_db.py | 372 + .../playlist_interface.py} | 538 +- redbot/cogs/audio/apis/playlist_wrapper.py | 249 + redbot/cogs/audio/apis/spotify.py | 189 + redbot/cogs/audio/apis/youtube.py | 65 + redbot/cogs/audio/audio.py | 8411 ----------------- redbot/cogs/audio/audio_dataclasses.py | 172 +- redbot/cogs/audio/audio_logging.py | 17 + redbot/cogs/audio/checks.py | 31 - redbot/cogs/audio/config.py | 18 - redbot/cogs/audio/converters.py | 166 +- redbot/cogs/audio/core/__init__.py | 121 + redbot/cogs/audio/core/abc.py | 504 + redbot/cogs/audio/core/cog_utils.py | 28 + redbot/cogs/audio/core/commands/__init__.py | 25 + redbot/cogs/audio/core/commands/audioset.py | 1307 +++ redbot/cogs/audio/core/commands/controller.py | 841 ++ redbot/cogs/audio/core/commands/equalizer.py | 385 + redbot/cogs/audio/core/commands/llset.py | 168 + .../cogs/audio/core/commands/localtracks.py | 118 + .../cogs/audio/core/commands/miscellaneous.py | 138 + redbot/cogs/audio/core/commands/player.py | 861 ++ redbot/cogs/audio/core/commands/playlists.py | 1951 ++++ redbot/cogs/audio/core/commands/queue.py | 359 + redbot/cogs/audio/core/events/__init__.py | 13 + redbot/cogs/audio/core/events/cog.py | 147 + redbot/cogs/audio/core/events/dpy.py | 184 + redbot/cogs/audio/core/events/lavalink.py | 192 + redbot/cogs/audio/core/events/red.py | 21 + redbot/cogs/audio/core/tasks/__init__.py | 12 + redbot/cogs/audio/core/tasks/lavalink.py | 113 + redbot/cogs/audio/core/tasks/player.py | 70 + redbot/cogs/audio/core/tasks/startup.py | 49 + redbot/cogs/audio/core/utilities/__init__.py | 23 + redbot/cogs/audio/core/utilities/equalizer.py | 174 + .../cogs/audio/core/utilities/formatting.py | 376 + .../cogs/audio/core/utilities/local_tracks.py | 127 + .../audio/core/utilities/menus/__init__.py | 0 .../audio/core/utilities/miscellaneous.py | 335 + redbot/cogs/audio/core/utilities/player.py | 669 ++ redbot/cogs/audio/core/utilities/playlists.py | 647 ++ redbot/cogs/audio/core/utilities/queue.py | 165 + .../cogs/audio/core/utilities/validation.py | 82 + redbot/cogs/audio/databases.py | 372 - redbot/cogs/audio/equalizer.py | 11 +- redbot/cogs/audio/manager.py | 29 +- redbot/cogs/audio/sql_statements.py | 218 +- redbot/cogs/audio/utils.py | 348 +- 53 files changed, 12372 insertions(+), 10144 deletions(-) create mode 100644 redbot/cogs/audio/apis/__init__.py create mode 100644 redbot/cogs/audio/apis/api_utils.py create mode 100644 redbot/cogs/audio/apis/global_db.py rename redbot/cogs/audio/{apis.py => apis/interface.py} (51%) create mode 100644 redbot/cogs/audio/apis/local_db.py rename redbot/cogs/audio/{playlists.py => apis/playlist_interface.py} (71%) create mode 100644 redbot/cogs/audio/apis/playlist_wrapper.py create mode 100644 redbot/cogs/audio/apis/spotify.py create mode 100644 redbot/cogs/audio/apis/youtube.py delete mode 100644 redbot/cogs/audio/audio.py create mode 100644 redbot/cogs/audio/audio_logging.py delete mode 100644 redbot/cogs/audio/checks.py delete mode 100644 redbot/cogs/audio/config.py create mode 100644 redbot/cogs/audio/core/__init__.py create mode 100644 redbot/cogs/audio/core/abc.py create mode 100644 redbot/cogs/audio/core/cog_utils.py create mode 100644 redbot/cogs/audio/core/commands/__init__.py create mode 100644 redbot/cogs/audio/core/commands/audioset.py create mode 100644 redbot/cogs/audio/core/commands/controller.py create mode 100644 redbot/cogs/audio/core/commands/equalizer.py create mode 100644 redbot/cogs/audio/core/commands/llset.py create mode 100644 redbot/cogs/audio/core/commands/localtracks.py create mode 100644 redbot/cogs/audio/core/commands/miscellaneous.py create mode 100644 redbot/cogs/audio/core/commands/player.py create mode 100644 redbot/cogs/audio/core/commands/playlists.py create mode 100644 redbot/cogs/audio/core/commands/queue.py create mode 100644 redbot/cogs/audio/core/events/__init__.py create mode 100644 redbot/cogs/audio/core/events/cog.py create mode 100644 redbot/cogs/audio/core/events/dpy.py create mode 100644 redbot/cogs/audio/core/events/lavalink.py create mode 100644 redbot/cogs/audio/core/events/red.py create mode 100644 redbot/cogs/audio/core/tasks/__init__.py create mode 100644 redbot/cogs/audio/core/tasks/lavalink.py create mode 100644 redbot/cogs/audio/core/tasks/player.py create mode 100644 redbot/cogs/audio/core/tasks/startup.py create mode 100644 redbot/cogs/audio/core/utilities/__init__.py create mode 100644 redbot/cogs/audio/core/utilities/equalizer.py create mode 100644 redbot/cogs/audio/core/utilities/formatting.py create mode 100644 redbot/cogs/audio/core/utilities/local_tracks.py create mode 100644 redbot/cogs/audio/core/utilities/menus/__init__.py create mode 100644 redbot/cogs/audio/core/utilities/miscellaneous.py create mode 100644 redbot/cogs/audio/core/utilities/player.py create mode 100644 redbot/cogs/audio/core/utilities/playlists.py create mode 100644 redbot/cogs/audio/core/utilities/queue.py create mode 100644 redbot/cogs/audio/core/utilities/validation.py delete mode 100644 redbot/cogs/audio/databases.py diff --git a/redbot/cogs/audio/__init__.py b/redbot/cogs/audio/__init__.py index e69258734..8ad61af23 100644 --- a/redbot/cogs/audio/__init__.py +++ b/redbot/cogs/audio/__init__.py @@ -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() diff --git a/redbot/cogs/audio/apis/__init__.py b/redbot/cogs/audio/apis/__init__.py new file mode 100644 index 000000000..2203547a2 --- /dev/null +++ b/redbot/cogs/audio/apis/__init__.py @@ -0,0 +1,10 @@ +from . import ( + api_utils, + global_db, + interface, + local_db, + playlist_interface, + playlist_wrapper, + spotify, + youtube, +) diff --git a/redbot/cogs/audio/apis/api_utils.py b/redbot/cogs/audio/apis/api_utils.py new file mode 100644 index 000000000..444f3b789 --- /dev/null +++ b/redbot/cogs/audio/apis/api_utils.py @@ -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") diff --git a/redbot/cogs/audio/apis/global_db.py b/redbot/cogs/audio/apis/global_db.py new file mode 100644 index 000000000..271ce1d90 --- /dev/null +++ b/redbot/cogs/audio/apis/global_db.py @@ -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 diff --git a/redbot/cogs/audio/apis.py b/redbot/cogs/audio/apis/interface.py similarity index 51% rename from redbot/cogs/audio/apis.py rename to redbot/cogs/audio/apis/interface.py index d949be375..85cfdcbaa 100644 --- a/redbot/cogs/audio/apis.py +++ b/redbot/cogs/audio/apis/interface.py @@ -1,277 +1,207 @@ import asyncio -import base64 -import contextlib import datetime import json import logging import random import time from collections import namedtuple -from typing import Callable, List, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union, NoReturn +from typing import Callable, List, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union, cast import aiohttp import discord import lavalink from lavalink.rest_api import LoadResult +from redbot.core.utils import AsyncIter from redbot.core import Config, commands from redbot.core.bot import Red -from redbot.core.i18n import Translator, cog_i18n +from redbot.core.commands import Cog, Context +from redbot.core.i18n import Translator +from redbot.core.utils.dbtools import APSWConnectionWrapper -from . import audio_dataclasses -from .databases import CacheInterface, SQLError -from .errors import DatabaseError, SpotifyFetchError, YouTubeApiError, TrackEnqueueError -from .playlists import get_playlist -from .utils import CacheLevel, Notifier, is_allowed, queue_duration, track_limit - -log = logging.getLogger("red.audio.cache") -_ = Translator("Audio", __file__) - -_TOP_100_GLOBALS = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i" -_TOP_100_US = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn5rWitrRWFKdm-ulaFiIyoK" +from ..audio_dataclasses import Query +from ..audio_logging import IS_DEBUG, debug_exc_log +from ..errors import DatabaseError, SpotifyFetchError, TrackEnqueueError +from ..utils import CacheLevel, Notifier +from .global_db import GlobalCacheWrapper +from .local_db import LocalCacheWrapper +from .playlist_interface import get_playlist +from .playlist_wrapper import PlaylistWrapper +from .spotify import SpotifyWrapper +from .youtube import YouTubeWrapper if TYPE_CHECKING: - _database: CacheInterface - _bot: Red - _config: Config -else: - _database = None - _bot = None - _config = None + from .. import Audio + +_ = Translator("Audio", __file__) +log = logging.getLogger("red.cogs.Audio.api.AudioAPIInterface") +_TOP_100_US = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn5rWitrRWFKdm-ulaFiIyoK" -def _pass_config_to_apis(config: Config, bot: Red): - global _database, _config, _bot - if _config is None: - _config = config - if _bot is None: - _bot = bot - if _database is None: - _database = CacheInterface() +class AudioAPIInterface: + """Handles music queries. - -class SpotifyAPI: - """Wrapper for the Spotify API.""" - - def __init__(self, bot: Red, session: aiohttp.ClientSession): - self.bot = bot - self.session = session - self.spotify_token: Optional[MutableMapping[str, Union[str, int]]] = None - self.client_id = None - self.client_secret = None - - @staticmethod - async def _check_token(token: MutableMapping): - now = int(time.time()) - return token["expires_at"] - now < 60 - - @staticmethod - def _make_token_auth( - client_id: Optional[str], client_secret: Optional[str] - ) -> MutableMapping[str, Union[str, int]]: - if client_id is None: - client_id = "" - if client_secret is None: - client_secret = "" - - auth_header = base64.b64encode((client_id + ":" + client_secret).encode("ascii")) - return {"Authorization": "Basic %s" % auth_header.decode("ascii")} - - async def _make_get( - self, url: str, headers: MutableMapping = None, params: MutableMapping = None - ) -> MutableMapping[str, str]: - if params is None: - params = {} - async with self.session.request("GET", url, params=params, headers=headers) as r: - if r.status != 200: - log.debug( - "Issue making GET request to {0}: [{1.status}] {2}".format( - url, r, await r.json() - ) - ) - return await r.json() - - async def _get_auth(self) -> NoReturn: - tokens = await self.bot.get_shared_api_tokens("spotify") - self.client_id = tokens.get("client_id", "") - self.client_secret = tokens.get("client_secret", "") - - async def _request_token(self) -> MutableMapping[str, Union[str, int]]: - await self._get_auth() - - payload = {"grant_type": "client_credentials"} - headers = self._make_token_auth(self.client_id, self.client_secret) - r = await self.post_call( - "https://accounts.spotify.com/api/token", payload=payload, headers=headers - ) - return r - - async def _get_spotify_token(self) -> Optional[str]: - if self.spotify_token and not await self._check_token(self.spotify_token): - return self.spotify_token["access_token"] - token = await self._request_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()) + token["expires_in"] - except KeyError: - return - self.spotify_token = token - log.debug("Created a new access token for Spotify: {0}".format(token)) - return self.spotify_token["access_token"] - - async def post_call( - self, url: str, payload: MutableMapping, headers: MutableMapping = None - ) -> MutableMapping[str, Union[str, int]]: - async with self.session.post(url, data=payload, headers=headers) as r: - if r.status != 200: - log.debug( - "Issue making POST request to {0}: [{1.status}] {2}".format( - url, r, await r.json() - ) - ) - return await r.json() - - async def get_call( - self, url: str, params: MutableMapping - ) -> MutableMapping[str, Union[str, int]]: - token = await self._get_spotify_token() - return await self._make_get( - url, params=params, headers={"Authorization": "Bearer {0}".format(token)} - ) - - async def get_categories(self) -> List[MutableMapping]: - url = "https://api.spotify.com/v1/browse/categories" - params = {} - result = await self.get_call(url, 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] - - async def get_playlist_from_category(self, category: str): - url = f"https://api.spotify.com/v1/browse/categories/{category}/playlists" - params = {} - result = await self.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"), - } - for c in playlists - ] - - -class YouTubeAPI: - """Wrapper for the YouTube Data API.""" - - def __init__(self, bot: Red, session: aiohttp.ClientSession): - self.bot = bot - self.session = session - self.api_key = None - - async def _get_api_key(self,) -> str: - tokens = await self.bot.get_shared_api_tokens("youtube") - self.api_key = tokens.get("api_key", "") - return self.api_key - - async def get_call(self, query: str) -> Optional[str]: - params = { - "q": query, - "part": "id", - "key": await self._get_api_key(), - "maxResults": 1, - "type": "video", - } - yt_url = "https://www.googleapis.com/youtube/v3/search" - async with self.session.request("GET", yt_url, 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']}" - - -@cog_i18n(_) -class MusicCache: - """Handles music queries to the Spotify and Youtube Data API. - - Always tries the Cache first. + Always tries the Local cache first, then Global cache before making API calls. """ - def __init__(self, bot: Red, session: aiohttp.ClientSession): + def __init__( + self, + bot: Red, + config: Config, + session: aiohttp.ClientSession, + conn: APSWConnectionWrapper, + cog: Union["Audio", Cog], + ): self.bot = bot - self.spotify_api: SpotifyAPI = SpotifyAPI(bot, session) - self.youtube_api: YouTubeAPI = YouTubeAPI(bot, session) + self.config = config + self.conn = conn + self.cog = cog + self.spotify_api: SpotifyWrapper = SpotifyWrapper(self.bot, self.config, session, self.cog) + self.youtube_api: YouTubeWrapper = YouTubeWrapper(self.bot, self.config, session, self.cog) + self.local_cache_api = LocalCacheWrapper(self.bot, self.config, self.conn, self.cog) + self.global_cache_api = GlobalCacheWrapper(self.bot, self.config, session, self.cog) self._session: aiohttp.ClientSession = session - self.database = _database - self._tasks: MutableMapping = {} self._lock: asyncio.Lock = asyncio.Lock() - self.config: Optional[Config] = None - async def initialize(self, config: Config): - self.config = config - await _database.init() + async def initialize(self) -> None: + """Initialises the Local Cache connection""" + await self.local_cache_api.lavalink.init() - @staticmethod - def _spotify_format_call(qtype: str, key: str) -> Tuple[str, MutableMapping]: - params = {} - if qtype == "album": - query = f"https://api.spotify.com/v1/albums/{key}/tracks" - elif qtype == "track": - query = f"https://api.spotify.com/v1/tracks/{key}" + def close(self) -> None: + """Closes the Local Cache connection""" + self.local_cache_api.lavalink.close() + + async def get_random_track_from_db(self) -> Optional[MutableMapping]: + """Get a random track from the local database and return it""" + track: Optional[MutableMapping] = {} + try: + query_data = {} + date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7) + date_timestamp = int(date.timestamp()) + query_data["day"] = date_timestamp + 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())) + query_data["maxage"] = maxage_int + track = await self.local_cache_api.lavalink.fetch_random(query_data) + if track is not None: + if track.get("loadType") == "V2_COMPACT": + track["loadType"] = "V2_COMPAT" + results = LoadResult(track) + track = random.choice(list(results.tracks)) + except Exception as exc: + debug_exc_log(log, exc, "Failed to fetch a random track from database") + track = {} + + if not track: + return None + + return track + + async def route_tasks( + self, action_type: str = None, data: Union[List[MutableMapping], MutableMapping] = None, + ) -> None: + """Separate the tasks and run them in the appropriate functions""" + + if not data: + return + if action_type == "insert" and isinstance(data, list): + for table, d in data: + if table == "lavalink": + await self.local_cache_api.lavalink.insert(d) + elif table == "youtube": + await self.local_cache_api.youtube.insert(d) + elif table == "spotify": + await self.local_cache_api.spotify.insert(d) + elif action_type == "update" and isinstance(data, dict): + for table, d in data: + if table == "lavalink": + await self.local_cache_api.lavalink.update(data) + elif table == "youtube": + await self.local_cache_api.youtube.update(data) + elif table == "spotify": + await self.local_cache_api.spotify.update(data) + + async def run_tasks(self, ctx: Optional[commands.Context] = None, message_id=None) -> None: + """Run tasks for a specific context""" + if message_id is not None: + lock_id = message_id + elif ctx is not None: + lock_id = ctx.message.id else: - query = f"https://api.spotify.com/v1/playlists/{key}/tracks" - return query, params + return + lock_author = ctx.author if ctx else None + async with self._lock: + if lock_id in self._tasks: + if IS_DEBUG: + log.debug(f"Running database writes for {lock_id} ({lock_author})") + try: + tasks = self._tasks[lock_id] + tasks = [self.route_tasks(a, tasks[a]) for a in tasks] + await asyncio.gather(*tasks, return_exceptions=True) + del self._tasks[lock_id] + except Exception as exc: + debug_exc_log( + log, exc, f"Failed database writes for {lock_id} ({lock_author})" + ) + else: + if IS_DEBUG: + log.debug(f"Completed database writes for {lock_id} ({lock_author})") - @staticmethod - def _get_spotify_track_info(track_data: MutableMapping) -> Tuple[str, ...]: - artist_name = track_data["artists"][0]["name"] - track_name = track_data["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"] + async def run_all_pending_tasks(self) -> None: + """Run all pending tasks left in the cache, called on cog_unload""" + async with self._lock: + if IS_DEBUG: + log.debug("Running pending writes to database") + try: + tasks: MutableMapping = {"update": [], "insert": [], "global": []} + async for k, task in AsyncIter(self._tasks.items()): + async for t, args in AsyncIter(task.items()): + tasks[t].append(args) + self._tasks = {} + coro_tasks = [self.route_tasks(a, tasks[a]) for a in tasks] - return song_url, track_info, uri, artist_name, track_name, _id, _type + await asyncio.gather(*coro_tasks, return_exceptions=True) - async def _spotify_first_time_query( + except Exception as exc: + debug_exc_log(log, exc, "Failed database writes") + else: + if IS_DEBUG: + log.debug("Completed pending writes to database have finished") + + def append_task(self, ctx: commands.Context, event: str, task: Tuple, _id: int = None) -> None: + """Add a task to the cache to be run later""" + lock_id = _id or ctx.message.id + if lock_id not in self._tasks: + self._tasks[lock_id] = {"update": [], "insert": [], "global": []} + self._tasks[lock_id][event].append(task) + + async def fetch_spotify_query( self, ctx: commands.Context, query_type: str, uri: str, - notifier: Notifier, + notifier: Optional[Notifier], skip_youtube: bool = False, current_cache_level: CacheLevel = CacheLevel.none(), ) -> List[str]: + """Return youtube URLS for the spotify URL provided""" youtube_urls = [] - - tracks = await self._spotify_fetch_tracks(query_type, uri, params=None, notifier=notifier) + tracks = await self.fetch_from_spotify_api( + query_type, uri, params=None, notifier=notifier, ctx=ctx + ) total_tracks = len(tracks) database_entries = [] track_count = 0 time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level) - for track in tracks: - if track.get("error", {}).get("message") == "invalid id": + async for track in AsyncIter(tracks): + if isinstance(track, str): + break + elif isinstance(track, dict) and track.get("error", {}).get("message") == "invalid id": continue ( song_url, @@ -281,7 +211,7 @@ class MusicCache: track_name, _id, _type, - ) = self._get_spotify_track_info(track) + ) = await self.spotify_api.get_spotify_track_info(track, ctx) database_entries.append( { @@ -299,78 +229,55 @@ class MusicCache: if skip_youtube is False: val = None if youtube_cache: - update = True - with contextlib.suppress(SQLError): - (val, update) = await self.database.fetch_one( - "youtube", "youtube_url", {"track": track_info} + try: + (val, last_update) = await self.local_cache_api.youtube.fetch_one( + {"track": track_info} ) - if update: - val = None + except Exception as exc: + debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table") + if val is None: - val = await self._youtube_first_time_query( + val = await self.fetch_youtube_query( ctx, track_info, current_cache_level=current_cache_level ) if youtube_cache and val: task = ("update", ("youtube", {"track": track_info})) self.append_task(ctx, *task) - if val: youtube_urls.append(val) else: youtube_urls.append(track_info) - await asyncio.sleep(0) track_count += 1 - if notifier and ((track_count % 2 == 0) or (track_count == total_tracks)): + if notifier is not None and ((track_count % 2 == 0) or (track_count == total_tracks)): await notifier.notify_user(current=track_count, total=total_tracks, key="youtube") if CacheLevel.set_spotify().is_subset(current_cache_level): task = ("insert", ("spotify", database_entries)) self.append_task(ctx, *task) return youtube_urls - async def _youtube_first_time_query( - self, - ctx: commands.Context, - track_info: str, - current_cache_level: CacheLevel = CacheLevel.none(), - ) -> str: - track_url = await self.youtube_api.get_call(track_info) - if CacheLevel.set_youtube().is_subset(current_cache_level) and track_url: - time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) - task = ( - "insert", - ( - "youtube", - [ - { - "track_info": track_info, - "track_url": track_url, - "last_updated": time_now, - "last_fetched": time_now, - } - ], - ), - ) - self.append_task(ctx, *task) - return track_url - - async def _spotify_fetch_tracks( + async def fetch_from_spotify_api( self, query_type: str, uri: str, recursive: Union[str, bool] = False, params: MutableMapping = None, notifier: Optional[Notifier] = None, - ) -> Union[MutableMapping, List[str]]: + ctx: Context = None, + ) -> Union[List[MutableMapping], List[str]]: + """Gets track info from spotify API""" if recursive is False: - (call, params) = self._spotify_format_call(query_type, uri) - results = await self.spotify_api.get_call(call, params) + (call, params) = self.spotify_api.spotify_format_call(query_type, uri) + results = await self.spotify_api.make_get_call(call, params) else: - results = await self.spotify_api.get_call(recursive, params) + if isinstance(recursive, str): + results = await self.spotify_api.make_get_call(recursive, params) + else: + results = {} try: if results["error"]["status"] == 401 and not recursive: raise SpotifyFetchError( - ( + _( "The Spotify API key or client secret has not been set properly. " "\nUse `{prefix}audioset spotifyapi` for instructions." ) @@ -385,7 +292,7 @@ class MusicCache: track_count = 0 total_tracks = results.get("tracks", results).get("total", 1) while True: - new_tracks = [] + new_tracks: List = [] if query_type == "track": new_tracks = results tracks.append(new_tracks) @@ -402,10 +309,9 @@ class MusicCache: track_count += len(new_tracks) if notifier: await notifier.notify_user(current=track_count, total=total_tracks, key="spotify") - try: if results.get("next") is not None: - results = await self._spotify_fetch_tracks( + results = await self.fetch_from_spotify_api( query_type, uri, results["next"], params, notifier=notifier ) continue @@ -413,9 +319,8 @@ class MusicCache: break except KeyError: raise SpotifyFetchError( - "This doesn't seem to be a valid Spotify playlist/album URL or code." + _("This doesn't seem to be a valid Spotify playlist/album URL or code.") ) - return tracks async def spotify_query( @@ -435,7 +340,7 @@ class MusicCache: query_type : str Type of query to perform (Pl uri: str - Spotify URL ID . + Spotify URL ID. skip_youtube:bool Whether or not to skip YouTube API Calls. notifier: Notifier @@ -448,18 +353,20 @@ class MusicCache: current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_spotify().is_subset(current_cache_level) if query_type == "track" and cache_enabled: - update = True - with contextlib.suppress(SQLError): - (val, update) = await self.database.fetch_one( - "spotify", "track_info", {"uri": f"spotify:track:{uri}"} + try: + (val, last_update) = await self.local_cache_api.spotify.fetch_one( + {"uri": f"spotify:track:{uri}"} + ) + except Exception as exc: + debug_exc_log( + log, exc, f"Failed to fetch 'spotify:track:{uri}' from Spotify table" ) - if update: val = None else: val = None youtube_urls = [] if val is None: - urls = await self._spotify_first_time_query( + urls = await self.fetch_spotify_query( ctx, query_type, uri, @@ -484,24 +391,52 @@ class MusicCache: player: lavalink.Player, lock: Callable, notifier: Optional[Notifier] = None, + forced: bool = False, + query_global: bool = False, ) -> List[lavalink.Track]: - track_list = [] + """Queries the Database then falls back to Spotify and YouTube APIs then Enqueued matched tracks. + + Parameters + ---------- + ctx: commands.Context + The context this method is being called under. + query_type : str + Type of query to perform (Pl + uri: str + Spotify URL ID. + enqueue:bool + Whether or not to enqueue the tracks + player: lavalink.Player + The current Player. + notifier: Notifier + A Notifier object to handle the user UI notifications while tracks are loaded. + lock: Callable + A callable handling the Track enqueue lock while spotify tracks are being added. + query_global: bool + Whether or not to query the global API. + forced: bool + Ignore Cache and make a fetch from API. + Returns + ------- + List[str] + List of Youtube URLs. + """ + # globaldb_toggle = await self.config.global_db_enabled() + track_list: List = [] has_not_allowed = False try: current_cache_level = CacheLevel(await self.config.cache_level()) guild_data = await self.config.guild(ctx.guild).all() - - # now = int(time.time()) enqueued_tracks = 0 consecutive_fails = 0 - queue_dur = await queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_dur) + queue_dur = await self.cog.queue_duration(ctx) + queue_total_duration = self.cog.format_time(queue_dur) before_queue_length = len(player.queue) - tracks_from_spotify = await self._spotify_fetch_tracks( + tracks_from_spotify = await self.fetch_from_spotify_api( query_type, uri, params=None, notifier=notifier ) total_tracks = len(tracks_from_spotify) - if total_tracks < 1: + if total_tracks < 1 and notifier is not None: lock(ctx, False) embed3 = discord.Embed( colour=await ctx.embed_colour(), @@ -515,7 +450,7 @@ class MusicCache: youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level) spotify_cache = CacheLevel.set_spotify().is_subset(current_cache_level) - for track_count, track in enumerate(tracks_from_spotify): + async for track_count, track in AsyncIter(tracks_from_spotify).enumerate(start=1): ( song_url, track_info, @@ -524,7 +459,7 @@ class MusicCache: track_name, _id, _type, - ) = self._get_spotify_track_info(track) + ) = await self.spotify_api.get_spotify_track_info(track, ctx) database_entries.append( { @@ -540,26 +475,32 @@ class MusicCache: } ) val = None + llresponse = None if youtube_cache: - update = True - with contextlib.suppress(SQLError): - (val, update) = await self.database.fetch_one( - "youtube", "youtube_url", {"track": track_info} + try: + (val, last_updated) = await self.local_cache_api.youtube.fetch_one( + {"track": track_info} ) - if update: - val = None + except Exception as exc: + debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table") + if val is None: - val = await self._youtube_first_time_query( + val = await self.fetch_youtube_query( ctx, track_info, current_cache_level=current_cache_level ) - if youtube_cache and val: + if youtube_cache and val and llresponse is None: task = ("update", ("youtube", {"track": track_info})) self.append_task(ctx, *task) - if val: + if llresponse is not None: + track_object = llresponse.tracks + elif val: try: - (result, called_api) = await self.lavalink_query( - ctx, player, audio_dataclasses.Query.process_input(val) + (result, called_api) = await self.fetch_track( + ctx, + player, + Query.process_input(val, self.cog.local_folder_current_path), + forced=forced, ) except (RuntimeError, aiohttp.ServerDisconnectedError): lock(ctx, False) @@ -567,7 +508,8 @@ class MusicCache: colour=await ctx.embed_colour(), title=_("The connection was reset while loading the playlist."), ) - await notifier.update_embed(error_embed) + if notifier is not None: + await notifier.update_embed(error_embed) break except asyncio.TimeoutError: lock(ctx, False) @@ -575,7 +517,8 @@ class MusicCache: colour=await ctx.embed_colour(), title=_("Player timeout, skipping remaining tracks."), ) - await notifier.update_embed(error_embed) + if notifier is not None: + await notifier.update_embed(error_embed) break track_object = result.tracks else: @@ -584,42 +527,46 @@ class MusicCache: key = "lavalink" seconds = "???" second_key = None - await notifier.notify_user( - current=track_count, - total=total_tracks, - key=key, - seconds_key=second_key, - seconds=seconds, - ) + if notifier is not None: + await notifier.notify_user( + current=track_count, + total=total_tracks, + key=key, + seconds_key=second_key, + seconds=seconds, + ) if consecutive_fails >= 10: error_embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Failing to get tracks, skipping remaining."), ) - await notifier.update_embed(error_embed) + if notifier is not None: + await notifier.update_embed(error_embed) break if not track_object: consecutive_fails += 1 continue consecutive_fails = 0 single_track = track_object[0] - if not await is_allowed( + if not await self.cog.is_query_allowed( + self.config, ctx.guild, ( f"{single_track.title} {single_track.author} {single_track.uri} " - f"{str(audio_dataclasses.Query.process_input(single_track))}" + f"{Query.process_input(single_track, self.cog.local_folder_current_path)}" ), ): has_not_allowed = True - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") + if IS_DEBUG: + log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") continue track_list.append(single_track) if enqueue: if len(player.queue) >= 10000: continue if guild_data["maxlength"] > 0: - if track_limit(single_track, guild_data["maxlength"]): + if self.cog.is_track_too_long(single_track, guild_data["maxlength"]): enqueued_tracks += 1 player.add(ctx.author, single_track) self.bot.dispatch( @@ -640,20 +587,19 @@ class MusicCache: if not player.current: await player.play() - if len(track_list) == 0: - if not has_not_allowed: - raise SpotifyFetchError( - message=_( - "Nothing found.\nThe YouTube API key may be invalid " - "or you may be rate limited on YouTube's search service.\n" - "Check the YouTube API key again and follow the instructions " - "at `{prefix}audioset youtubeapi`." - ).format(prefix=ctx.prefix) + if not track_list and not has_not_allowed: + raise SpotifyFetchError( + message=_( + "Nothing found.\nThe YouTube API key may be invalid " + "or you may be rate limited on YouTube's search service.\n" + "Check the YouTube API key again and follow the instructions " + "at `{prefix}audioset youtubeapi`." ) + ) player.maybe_shuffle() if enqueue and tracks_from_spotify: if total_tracks > enqueued_tracks: - maxlength_msg = " {bad_tracks} tracks cannot be queued.".format( + maxlength_msg = _(" {bad_tracks} tracks cannot be queued.").format( bad_tracks=(total_tracks - enqueued_tracks) ) else: @@ -674,33 +620,65 @@ class MusicCache: ).format(time=queue_total_duration, position=before_queue_length + 1) ) - await notifier.update_embed(embed) + if notifier is not None: + await notifier.update_embed(embed) lock(ctx, False) if spotify_cache: task = ("insert", ("spotify", database_entries)) self.append_task(ctx, *task) - except Exception as e: + except Exception as exc: lock(ctx, False) - raise e + raise exc finally: lock(ctx, False) return track_list - async def youtube_query(self, ctx: commands.Context, track_info: str) -> str: + async def fetch_youtube_query( + self, + ctx: commands.Context, + track_info: str, + current_cache_level: CacheLevel = CacheLevel.none(), + ) -> Optional[str]: + """ + Call the Youtube API and returns the youtube URL that the query matched + """ + track_url = await self.youtube_api.get_call(track_info) + if CacheLevel.set_youtube().is_subset(current_cache_level) and track_url: + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + task = ( + "insert", + ( + "youtube", + [ + { + "track_info": track_info, + "track_url": track_url, + "last_updated": time_now, + "last_fetched": time_now, + } + ], + ), + ) + self.append_task(ctx, *task) + return track_url + + async def fetch_from_youtube_api( + self, ctx: commands.Context, track_info: str + ) -> Optional[str]: + """ + Gets an YouTube URL from for the query + """ current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_youtube().is_subset(current_cache_level) val = None if cache_enabled: - update = True - with contextlib.suppress(SQLError): - (val, update) = await self.database.fetch_one( - "youtube", "youtube_url", {"track": track_info} - ) - if update: - val = None + try: + (val, update) = await self.local_cache_api.youtube.fetch_one({"track": track_info}) + except Exception as exc: + debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table") if val is None: - youtube_url = await self._youtube_first_time_query( + youtube_url = await self.fetch_youtube_query( ctx, track_info, current_cache_level=current_cache_level ) else: @@ -710,12 +688,14 @@ class MusicCache: youtube_url = val return youtube_url - async def lavalink_query( + async def fetch_track( self, ctx: commands.Context, player: lavalink.Player, - query: audio_dataclasses.Query, + query: Query, forced: bool = False, + lazy: bool = False, + should_query_global: bool = True, ) -> Tuple[LoadResult, bool]: """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. @@ -729,7 +709,12 @@ class MusicCache: query: audio_dataclasses.Query The Query object for the query in question. forced:bool - Whether or not to skip cache and call API first.. + Whether or not to skip cache and call API first. + lazy:bool + If set to True, it will not call the api if a track is not found. + should_query_global:bool + If the method should query the global database. + Returns ------- Tuple[lavalink.LoadResult, bool] @@ -738,198 +723,165 @@ class MusicCache: current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) val = None - _raw_query = audio_dataclasses.Query.process_input(query) - query = str(_raw_query) - if cache_enabled and not forced and not _raw_query.is_local: - update = True - with contextlib.suppress(SQLError): - (val, update) = await self.database.fetch_one("lavalink", "data", {"query": query}) - if update: - val = None + query = Query.process_input(query, self.cog.local_folder_current_path) + query_string = str(query) + valid_global_entry = False + results = None + called_api = False + prefer_lyrics = await self.cog.get_lyrics_status(ctx) + if prefer_lyrics and query.is_youtube and query.is_search: + query_string = f"{query} - lyrics" + if cache_enabled and not forced and not query.is_local: + try: + (val, last_updated) = await self.local_cache_api.lavalink.fetch_one( + {"query": query_string} + ) + except Exception as exc: + debug_exc_log(log, exc, f"Failed to fetch '{query_string}' from Lavalink table") + if val and isinstance(val, dict): - log.debug(f"Querying Local Database for {query}") - task = ("update", ("lavalink", {"query": query})) + if IS_DEBUG: + log.debug(f"Updating Local Database with {query_string}") + task = ("update", ("lavalink", {"query": query_string})) self.append_task(ctx, *task) else: val = None - if val and not forced and isinstance(val, dict): + + if val and not forced and isinstance(val, dict): + valid_global_entry = False + called_api = False + else: + val = None + + if valid_global_entry: + pass + elif lazy is True: + called_api = False + elif val and not forced and isinstance(val, dict): data = val - data["query"] = query + data["query"] = query_string if data.get("loadType") == "V2_COMPACT": data["loadType"] = "V2_COMPAT" results = LoadResult(data) called_api = False if results.has_error: # If cached value has an invalid entry make a new call so that it gets updated - return await self.lavalink_query(ctx, player, _raw_query, forced=True) + results, called_api = await self.fetch_track(ctx, player, query, forced=True) else: + if IS_DEBUG: + log.debug(f"Querying Lavalink api for {query_string}") called_api = True - results = None try: - results = await player.load_tracks(query) + results = await player.load_tracks(query_string) except KeyError: results = None except RuntimeError: raise TrackEnqueueError - if results is None: - results = LoadResult({"loadType": "LOAD_FAILED", "playlistInfo": {}, "tracks": []}) - if ( - cache_enabled - and results.load_type - and not results.has_error - and not _raw_query.is_local - and results.tracks - ): - with contextlib.suppress(SQLError): - time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) - data = json.dumps(results._raw) - if all( - k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"] - ): - task = ( - "insert", - ( - "lavalink", - [ - { - "query": query, - "data": data, - "last_updated": time_now, - "last_fetched": time_now, - } - ], - ), - ) - self.append_task(ctx, *task) + if results is None: + results = LoadResult({"loadType": "LOAD_FAILED", "playlistInfo": {}, "tracks": []}) + + if ( + cache_enabled + and results.load_type + and not results.has_error + and not query.is_local + and results.tracks + ): + try: + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + data = json.dumps(results._raw) + if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]): + task = ( + "insert", + ( + "lavalink", + [ + { + "query": query_string, + "data": data, + "last_updated": time_now, + "last_fetched": time_now, + } + ], + ), + ) + self.append_task(ctx, *task) + except Exception as exc: + debug_exc_log( + log, + exc, + f"Failed to enqueue write task for '{query_string}' to Lavalink table", + ) return results, called_api - async def run_tasks(self, ctx: Optional[commands.Context] = None, _id=None): - lock_id = _id or ctx.message.id - lock_author = ctx.author if ctx else None - async with self._lock: - if lock_id in self._tasks: - log.debug(f"Running database writes for {lock_id} ({lock_author})") - with contextlib.suppress(Exception): - tasks = self._tasks[ctx.message.id] - del self._tasks[ctx.message.id] - await asyncio.gather( - *[self.database.insert(*a) for a in tasks["insert"]], - return_exceptions=True, - ) - await asyncio.gather( - *[self.database.update(*a) for a in tasks["update"]], - return_exceptions=True, - ) - log.debug(f"Completed database writes for {lock_id} " f"({lock_author})") - - async def run_all_pending_tasks(self): - async with self._lock: - log.debug("Running pending writes to database") - with contextlib.suppress(Exception): - tasks = {"update": [], "insert": []} - for (k, task) in self._tasks.items(): - for t, args in task.items(): - tasks[t].append(args) - self._tasks = {} - - await asyncio.gather( - *[self.database.insert(*a) for a in tasks["insert"]], return_exceptions=True - ) - await asyncio.gather( - *[self.database.update(*a) for a in tasks["update"]], return_exceptions=True - ) - log.debug("Completed pending writes to database have finished") - - def append_task(self, ctx: commands.Context, event: str, task: tuple, _id=None): - lock_id = _id or ctx.message.id - if lock_id not in self._tasks: - self._tasks[lock_id] = {"update": [], "insert": []} - self._tasks[lock_id][event].append(task) - - async def get_random_from_db(self): - tracks = [] - try: - query_data = {} - date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7) - date = int(date.timestamp()) - query_data["day"] = date - 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())) - query_data["maxage"] = maxage_int - - vals = await self.database.fetch_all("lavalink", "data", query_data) - recently_played = [r.tracks for r in vals if r if isinstance(tracks, dict)] - - if recently_played: - track = random.choice(recently_played) - if track.get("loadType") == "V2_COMPACT": - track["loadType"] = "V2_COMPAT" - results = LoadResult(track) - tracks = list(results.tracks) - except Exception: - tracks = [] - - return tracks - - async def autoplay(self, player: lavalink.Player): + async def autoplay(self, player: lavalink.Player, playlist_api: PlaylistWrapper): + """ + Enqueue a random track + """ autoplaylist = await self.config.guild(player.channel.guild).autoplaylist() current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) playlist = None tracks = None if autoplaylist["enabled"]: - with contextlib.suppress(Exception): + try: playlist = await get_playlist( autoplaylist["id"], autoplaylist["scope"], self.bot, + playlist_api, player.channel.guild, player.channel.guild.me, ) tracks = playlist.tracks_obj + except Exception as exc: + debug_exc_log(log, exc, "Failed to fetch playlist for autoplay") if not tracks or not getattr(playlist, "tracks", None): if cache_enabled: - tracks = await self.get_random_from_db() + track = await self.get_random_track_from_db() + tracks = [] if not track else [track] if not tracks: - ctx = namedtuple("Context", "message") - (results, called_api) = await self.lavalink_query( - ctx(player.channel.guild), + ctx = namedtuple("Context", "message guild cog") + (results, called_api) = await self.fetch_track( + cast( + commands.Context, ctx(player.channel.guild, player.channel.guild, self.cog) + ), player, - audio_dataclasses.Query.process_input(_TOP_100_US), + Query.process_input(_TOP_100_US, self.cog.local_folder_current_path), ) tracks = list(results.tracks) if tracks: multiple = len(tracks) > 1 - track = tracks[0] - valid = not multiple tries = len(tracks) + track = tracks[0] while valid is False and multiple: tries -= 1 if tries <= 0: raise DatabaseError("No valid entry found") track = random.choice(tracks) - query = audio_dataclasses.Query.process_input(track) + query = Query.process_input(track, self.cog.local_folder_current_path) await asyncio.sleep(0.001) - if not query.valid: + if not query.valid or ( + query.is_local + and query.local_track_path is not None + and not query.local_track_path.exists() + ): continue - if query.is_local and not query.track.exists(): - continue - if not await is_allowed( + if not await self.cog.is_query_allowed( + self.config, player.channel.guild, ( f"{track.title} {track.author} {track.uri} " - f"{str(audio_dataclasses.Query.process_input(track))}" + f"{str(Query.process_input(track, self.cog.local_folder_current_path))}" ), ): - log.debug( - "Query is not allowed in " - f"{player.channel.guild} ({player.channel.guild.id})" - ) + if IS_DEBUG: + log.debug( + "Query is not allowed in " + f"{player.channel.guild} ({player.channel.guild.id})" + ) continue valid = True diff --git a/redbot/cogs/audio/apis/local_db.py b/redbot/cogs/audio/apis/local_db.py new file mode 100644 index 000000000..c9ef378f6 --- /dev/null +++ b/redbot/cogs/audio/apis/local_db.py @@ -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) diff --git a/redbot/cogs/audio/playlists.py b/redbot/cogs/audio/apis/playlist_interface.py similarity index 71% rename from redbot/cogs/audio/playlists.py rename to redbot/cogs/audio/apis/playlist_interface.py index 98af9be3a..263981762 100644 --- a/redbot/cogs/audio/playlists.py +++ b/redbot/cogs/audio/apis/playlist_interface.py @@ -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) diff --git a/redbot/cogs/audio/apis/playlist_wrapper.py b/redbot/cogs/audio/apis/playlist_wrapper.py new file mode 100644 index 000000000..106ca6c8f --- /dev/null +++ b/redbot/cogs/audio/apis/playlist_wrapper.py @@ -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), + }, + ) diff --git a/redbot/cogs/audio/apis/spotify.py b/redbot/cogs/audio/apis/spotify.py new file mode 100644 index 000000000..63bfaacef --- /dev/null +++ b/redbot/cogs/audio/apis/spotify.py @@ -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 + ] diff --git a/redbot/cogs/audio/apis/youtube.py b/redbot/cogs/audio/apis/youtube.py new file mode 100644 index 000000000..307fed116 --- /dev/null +++ b/redbot/cogs/audio/apis/youtube.py @@ -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 diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py deleted file mode 100644 index c2549c4dd..000000000 --- a/redbot/cogs/audio/audio.py +++ /dev/null @@ -1,8411 +0,0 @@ -import asyncio -import contextlib -import datetime -import heapq -import json -import logging -import tarfile -import math -import random -import re -import os.path -import time -import traceback -from collections import Counter, namedtuple -from io import BytesIO -from pathlib import Path -from typing import List, Optional, Tuple, Union, cast, MutableMapping, Mapping - -import aiohttp -import discord -import lavalink -from discord.embeds import EmptyEmbed -from discord.utils import escape_markdown as escape -from fuzzywuzzy import process - -from redbot.core import Config, bank, checks, commands -from redbot.core.bot import Red -from redbot.core.data_manager import cog_data_path -from redbot.core.i18n import Translator, cog_i18n -from redbot.core.utils.chat_formatting import bold, box, humanize_number, inline, pagify -from redbot.core.utils.menus import ( - DEFAULT_CONTROLS, - close_menu, - menu, - next_page, - prev_page, - start_adding_reactions, -) -from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate - -from . import audio_dataclasses -from .apis import MusicCache -from .config import pass_config_to_dependencies -from .converters import ComplexScopeParser, ScopeParser, get_lazy_converter, get_playlist_converter -from .equalizer import Equalizer -from .errors import ( - DatabaseError, - LavalinkDownloadFailed, - MissingGuild, - QueryUnauthorized, - SpotifyFetchError, - TooManyMatches, - TrackEnqueueError, -) -from .manager import ServerManager -from .playlists import ( - FakePlaylist, - Playlist, - create_playlist, - delete_playlist, - get_all_playlist, - get_all_playlist_for_migration23, - get_playlist, - get_playlist_database, -) -from .utils import * - -_ = Translator("Audio", __file__) - -__version__ = "1.1.1" -__author__ = ["aikaterna", "Draper"] - -log = logging.getLogger("red.audio") - -_SCHEMA_VERSION = 3 -LazyGreedyConverter = get_lazy_converter("--") -PlaylistConverter = get_playlist_converter() - - -@cog_i18n(_) -class Audio(commands.Cog): - """Play audio through voice channels.""" - - _default_lavalink_settings = { - "host": "localhost", - "rest_port": 2333, - "ws_port": 2333, - "password": "youshallnotpass", - } - - def __init__(self, bot): - super().__init__() - self.bot: Red = bot - self.config: Config = Config.get_conf(self, 2711759130, force_registration=True) - self.skip_votes: MutableMapping[discord.Guild, List[discord.Member]] = {} - self.play_lock: MutableMapping[int, bool] = {} - self._daily_playlist_cache: MutableMapping[int, bool] = {} - self._dj_status_cache: MutableMapping[int, Optional[bool]] = {} - self._dj_role_cache: MutableMapping[int, Optional[int]] = {} - self.session: aiohttp.ClientSession = aiohttp.ClientSession() - self._connect_task: Optional[asyncio.Task] = None - self._disconnect_task: Optional[asyncio.Task] = None - self._cleaned_up: bool = False - self._connection_aborted: bool = False - self._manager: Optional[ServerManager] = None - default_global: Mapping = dict( - schema_version=1, - cache_level=0, - cache_age=365, - 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: Mapping = dict( - auto_play=False, - autoplaylist=dict(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, - 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=[], - ) - _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) - self.music_cache: Optional[MusicCache] = None - self._error_counter: Counter = Counter() - self._error_timer: MutableMapping[int, int] = {} - self._disconnected_players: MutableMapping[int, bool] = {} - - # These 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._init_task: asyncio.Task = self.bot.loop.create_task(self.initialize()) - self._ready_event: asyncio.Event = asyncio.Event() - - async def cog_before_invoke(self, ctx: commands.Context): - await self._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.llsetup in [ctx.command, ctx.command.root_parent]: - pass - - elif self._connect_task and self._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." - ) - dj_enabled = self._dj_status_cache.setdefault( - ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() - ) - daily_cache = self._daily_playlist_cache.setdefault( - ctx.guild.id, await self.config.guild(ctx.guild).daily_playlists() - ) - 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._embed_msg(ctx, title=_("No DJ role found. Disabling DJ mode.")) - - async def initialize(self) -> None: - await self.bot.wait_until_ready() - # Unlike most cases, we want the cache to exit before migration. - try: - pass_config_to_dependencies(self.config, self.bot, await self.config.localpath()) - self.music_cache = MusicCache(self.bot, self.session) - await self.music_cache.initialize(self.config) - await self._migrate_config( - from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION - ) - dat = get_playlist_database() - if dat: - dat.delete_scheduled() - self._restart_connect() - self._disconnect_task = self.bot.loop.create_task(self.disconnect_timer()) - lavalink.register_event_listener(self.event_handler) - except Exception as err: - log.exception("Audio failed to start up, please report this issue.", exc_info=err) - raise err - - self._ready_event.set() - - async def _migrate_config(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 = {} - for (guild_id, guild_data) in all_guild_data.items(): - temp_guild_playlist = guild_data.pop("playlists", None) - if temp_guild_playlist: - guild_playlist = {} - for (count, (name, data)) in enumerate(temp_guild_playlist.items(), 1): - 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 [] - for t in 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, - } - ) - await asyncio.sleep(0) - if guild_playlist: - all_playlist[str(guild_id)] = guild_playlist - await asyncio.sleep(0) - await self.config.custom(PlaylistScope.GUILD.value).set(all_playlist) - # new schema is now in place - await self.config.schema_version.set(_SCHEMA_VERSION) - - # migration done, now let's delete all the old stuff - for guild_id in 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(scope) - for p in scope_playlist: - await p.save() - await self.config.custom(scope).clear() - await self.config.schema_version.set(_SCHEMA_VERSION) - - if database_entries: - await self.music_cache.database.insert("lavalink", database_entries) - - def _restart_connect(self): - if self._connect_task: - self._connect_task.cancel() - - self._connect_task = self.bot.loop.create_task(self.attempt_connect()) - - async def attempt_connect(self, timeout: int = 50): - self._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._manager is not None: - await self._manager.shutdown() - self._manager = ServerManager() - try: - await self._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._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._connection_aborted = True - raise - else: - break - else: - host = await self.config.host() - password = await self.config.password() - rest_port = await self.config.rest_port() - ws_port = await self.config.ws_port() - break - else: - log.critical( - "Setting up the Lavalink server failed after multiple attempts. See above " - "tracebacks for details." - ) - self._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._manager is not None: - await self._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._connection_aborted = True - raise - else: - break - else: - self._connection_aborted = True - log.critical( - "Connecting to the Lavalink server failed after multiple attempts. See above " - "tracebacks for details." - ) - - async def error_reset(self, player: lavalink.Player): - guild = 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 = 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 - - @staticmethod - async def _players_check(): - try: - current = next( - ( - player.current - for player in lavalink.active_players() - if player.current is not None - ), - None, - ) - get_single_title = get_track_description_unformatted(current) - playing_servers = len(lavalink.active_players()) - except IndexError: - get_single_title = None - playing_servers = 0 - return get_single_title, playing_servers - - async def _status_check(self, track, playing_servers): - 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 event_handler( - self, player: lavalink.Player, event_type: lavalink.LavalinkEvents, extra - ): - current_track = player.current - current_channel = player.channel - guild = rgetattr(current_channel, "guild", None) - guild_id = rgetattr(guild, "id", None) - current_requester = rgetattr(current_track, "requester", None) - current_stream = rgetattr(current_track, "is_stream", None) - current_length = rgetattr(current_track, "length", None) - current_thumbnail = rgetattr(current_track, "thumbnail", None) - current_extras = 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 = get_track_description(current_track) - status = await self.config.status() - - await self.error_reset(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_song = player.fetch("prev_song") - 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_song = player.fetch("prev_song") - 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: - try: - await self.music_cache.autoplay(player) - except DatabaseError: - notify_channel = player.fetch("channel") - if notify_channel: - notify_channel = self.bot.get_channel(notify_channel) - await self._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") - prev_song = player.fetch("prev_song") - 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._embed_msg(notify_channel, title=_("Auto Play Started.")) - - if not description: - return - if current_stream: - dur = "LIVE" - else: - dur = lavalink.utils.format_time(current_length) - - thumb = None - if await self.config.guild(guild).thumbnail() and current_thumbnail: - thumb = current_thumbnail - - notify_message = await self._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 = await self._players_check() - await self._status_check(*player_check) - - if event_type == lavalink.LavalinkEvents.TRACK_END and status: - await asyncio.sleep(1) - if not player.is_playing: - player_check = await self._players_check() - await self._status_check(*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._embed_msg(notify_channel, title=_("Queue Ended.")) - if disconnect: - self.bot.dispatch("red_audio_audio_disconnect", guild) - await player.disconnect() - if status: - player_check = await self._players_check() - await self._status_check(*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." - ), - ) - return await message_channel.send(embed=embed) - else: - description = description or "" - if event_type == lavalink.LavalinkEvents.TRACK_STUCK: - embed = discord.Embed( - title=_("Track Stuck"), description="{}".format(description) - ) - else: - embed = discord.Embed( - title=_("Track Error"), - description="{}\n{}".format(extra.replace("\n", ""), description), - ) - await message_channel.send(embed=embed) - await player.skip() - - async def play_query( - self, - query: str, - guild: discord.Guild, - channel: discord.VoiceChannel, - is_autoplay: bool = True, - ): - if not self._player_check(guild.me): - try: - if ( - not channel.permissions_for(guild.me).connect - or not channel.permissions_for(guild.me).move_members - and userlimit(channel) - ): - log.error(f"I don't have permission to connect to {channel} in {guild}.") - - await lavalink.connect(channel) - player = lavalink.get_player(guild.id) - player.store("connect", datetime.datetime.utcnow()) - except IndexError: - log.debug( - f"Connection to Lavalink has not yet been established" - f" while trying to connect to to {channel} in {guild}." - ) - return - query = audio_dataclasses.Query.process_input(query) - restrict = await self.config.restrict() - if restrict and match_url(query): - valid_url = url_check(query) - if not valid_url: - raise QueryUnauthorized(f"{query} is not an allowed query.") - elif not await is_allowed(guild, f"{query}", query_obj=query): - raise QueryUnauthorized(f"{query} is not an allowed query.") - - player = lavalink.get_player(guild.id) - player.store("channel", channel.id) - player.store("guild", guild.id) - await self._data_check(guild.me) - - ctx = namedtuple("Context", "message") - (results, called_api) = await self.music_cache.lavalink_query(ctx(guild), player, query) - - if not results.tracks: - log.debug(f"Query returned no tracks.") - return - track = results.tracks[0] - - if not await is_allowed( - guild, f"{track.title} {track.author} {track.uri} {str(query._raw)}" - ): - log.debug(f"Query is not allowed in {guild} ({guild.id})") - return - track.extras["autoplay"] = is_autoplay - player.add(player.channel.guild.me, track) - self.bot.dispatch( - "red_audio_track_auto_play", player.channel.guild, track, player.channel.guild.me - ) - if not player.current: - await player.play() - - @commands.group() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def audioset(self, ctx: commands.Context): - """Music configuration options.""" - - @audioset.command(name="dailyqueue") - @checks.admin() - async def _audioset_historical_queue(self, ctx: commands.Context): - """Toggle daily queues. - - Daily queues creates a playlist for all tracks played today. - """ - daily_playlists = self._daily_playlist_cache.setdefault( - ctx.guild.id, await self.config.guild(ctx.guild).daily_playlists() - ) - await self.config.guild(ctx.guild).daily_playlists.set(not daily_playlists) - self._daily_playlist_cache[ctx.guild.id] = not daily_playlists - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Daily queues: {true_or_false}.").format( - true_or_false=_("Enabled") if not daily_playlists else _("Disabled") - ), - ) - - @audioset.command() - @checks.mod_or_permissions(manage_messages=True) - async def dc(self, ctx: commands.Context): - """Toggle the bot auto-disconnecting when done playing. - - This setting takes precedence over `[p]audioset emptydisconnect`. - """ - - disconnect = await self.config.guild(ctx.guild).disconnect() - autoplay = await self.config.guild(ctx.guild).auto_play() - msg = "" - msg += _("Auto-disconnection at queue end: {true_or_false}.").format( - true_or_false=_("Enabled") if not disconnect else _("Disabled") - ) - if disconnect is not True and autoplay is True: - msg += _("\nAuto-play has been disabled.") - await self.config.guild(ctx.guild).auto_play.set(False) - - await self.config.guild(ctx.guild).disconnect.set(not disconnect) - - await self._embed_msg(ctx, title=_("Setting Changed"), description=msg) - - @audioset.group(name="restrictions") - @checks.mod_or_permissions(manage_messages=True) - async def _perms(self, ctx: commands.Context): - """Manages the keyword whitelist and blacklist.""" - - @checks.is_owner() - @_perms.group(name="global") - async def _perms_global(self, ctx: commands.Context): - """Manages the global keyword whitelist/blacklist.""" - - @_perms_global.group(name="whitelist") - async def _perms_global_whitelist(self, ctx: commands.Context): - """Manages the global keyword whitelist.""" - - @_perms_global.group(name="blacklist") - async def _perms_global_blacklist(self, ctx: commands.Context): - """Manages the global keyword blacklist.""" - - @_perms_global_blacklist.command(name="add") - async def _perms_global_blacklist_add(self, ctx: commands.Context, *, keyword: str): - """Adds a keyword to the blacklist.""" - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = False - async with self.config.url_keyword_blacklist() as blacklist: - if keyword in blacklist: - exists = True - else: - blacklist.append(keyword) - if exists: - return await self._embed_msg(ctx, title=_("Keyword already in the blacklist.")) - else: - return await self._embed_msg( - ctx, - title=_("Blacklist Modified"), - description=_("Added: `{blacklisted}` to the blacklist.").format( - blacklisted=keyword - ), - ) - - @_perms_global_whitelist.command(name="add") - async def _perms_global_whitelist_add(self, ctx: commands.Context, *, keyword: str): - """Adds a keyword to the whitelist. - - If anything is added to whitelist, it will blacklist everything else. - """ - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = False - async with self.config.url_keyword_whitelist() as whitelist: - if keyword in whitelist: - exists = True - else: - whitelist.append(keyword) - if exists: - return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) - else: - return await self._embed_msg( - ctx, - title=_("Whitelist Modified"), - description=_("Added: `{whitelisted}` to the whitelist.").format( - whitelisted=keyword - ), - ) - - @_perms_global_blacklist.command(name="delete", aliases=["del", "remove"]) - async def _perms_global_blacklist_delete(self, ctx: commands.Context, *, keyword: str): - """Removes a keyword from the blacklist.""" - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = True - async with self.config.url_keyword_blacklist() as blacklist: - if keyword not in blacklist: - exists = False - else: - blacklist.remove(keyword) - if not exists: - return await self._embed_msg(ctx, title=_("Keyword is not in the blacklist.")) - else: - return await self._embed_msg( - ctx, - title=_("Blacklist Modified"), - description=_("Removed: `{blacklisted}` from the blacklist.").format( - blacklisted=keyword - ), - ) - - @_perms_global_whitelist.command(name="delete", aliases=["del", "remove"]) - async def _perms_global_whitelist_delete(self, ctx: commands.Context, *, keyword: str): - """Removes a keyword from the whitelist.""" - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = True - async with self.config.url_keyword_whitelist() as whitelist: - if keyword not in whitelist: - exists = False - else: - whitelist.remove(keyword) - if not exists: - return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) - else: - return await self._embed_msg( - ctx, - title=_("Whitelist Modified"), - description=_("Removed: `{whitelisted}` from the whitelist.").format( - whitelisted=keyword - ), - ) - - @_perms_global_whitelist.command(name="list") - async def _perms_global_whitelist_list(self, ctx: commands.Context): - """List all keywords added to the whitelist.""" - whitelist = await self.config.url_keyword_whitelist() - if not whitelist: - return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) - whitelist.sort() - text = "" - total = len(whitelist) - pages = [] - for i, entry in enumerate(whitelist, 1): - text += f"{i}. [{entry}]" - if i != total: - text += "\n" - if i % 10 == 0: - pages.append(box(text, lang="ini")) - text = "" - else: - pages.append(box(text, lang="ini")) - embed_colour = await ctx.embed_colour() - pages = list( - discord.Embed(title="Global Whitelist", description=page, colour=embed_colour) - for page in pages - ) - await menu(ctx, pages, DEFAULT_CONTROLS) - - @_perms_global_blacklist.command(name="list") - async def _perms_global_blacklist_list(self, ctx: commands.Context): - """List all keywords added to the blacklist.""" - blacklist = await self.config.url_keyword_blacklist() - if not blacklist: - return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) - blacklist.sort() - text = "" - total = len(blacklist) - pages = [] - for i, entry in enumerate(blacklist, 1): - text += f"{i}. [{entry}]" - if i != total: - text += "\n" - if i % 10 == 0: - pages.append(box(text, lang="ini")) - text = "" - else: - pages.append(box(text, lang="ini")) - embed_colour = await ctx.embed_colour() - pages = list( - discord.Embed(title="Global Blacklist", description=page, colour=embed_colour) - for page in pages - ) - await menu(ctx, pages, DEFAULT_CONTROLS) - - @_perms_global_whitelist.command(name="clear") - async def _perms_global_whitelist_clear(self, ctx: commands.Context): - """Clear all keywords from the whitelist.""" - whitelist = await self.config.url_keyword_whitelist() - if not whitelist: - return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) - await self.config.url_keyword_whitelist.clear() - return await self._embed_msg( - ctx, - title=_("Whitelist Modified"), - description=_("All entries have been removed from the whitelist."), - ) - - @_perms_global_blacklist.command(name="clear") - async def _perms_global_blacklist_clear(self, ctx: commands.Context): - """Clear all keywords added to the blacklist.""" - blacklist = await self.config.url_keyword_blacklist() - if not blacklist: - return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) - await self.config.url_keyword_blacklist.clear() - return await self._embed_msg( - ctx, - title=_("Blacklist Modified"), - description=_("All entries have been removed from the blacklist."), - ) - - @_perms.group(name="whitelist") - async def _perms_whitelist(self, ctx: commands.Context): - """Manages the keyword whitelist.""" - - @_perms.group(name="blacklist") - async def _perms_blacklist(self, ctx: commands.Context): - """Manages the keyword blacklist.""" - - @_perms_blacklist.command(name="add") - async def _perms_blacklist_add(self, ctx: commands.Context, *, keyword: str): - """Adds a keyword to the blacklist.""" - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = False - async with self.config.guild(ctx.guild).url_keyword_blacklist() as blacklist: - if keyword in blacklist: - exists = True - else: - blacklist.append(keyword) - if exists: - return await self._embed_msg(ctx, title=_("Keyword already in the blacklist.")) - else: - return await self._embed_msg( - ctx, - title=_("Blacklist Modified"), - description=_("Added: `{blacklisted}` to the blacklist.").format( - blacklisted=keyword - ), - ) - - @_perms_whitelist.command(name="add") - async def _perms_whitelist_add(self, ctx: commands.Context, *, keyword: str): - """Adds a keyword to the whitelist. - - If anything is added to whitelist, it will blacklist everything else. - """ - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = False - async with self.config.guild(ctx.guild).url_keyword_whitelist() as whitelist: - if keyword in whitelist: - exists = True - else: - whitelist.append(keyword) - if exists: - return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) - else: - return await self._embed_msg( - ctx, - title=_("Whitelist Modified"), - description=_("Added: `{whitelisted}` to the whitelist.").format( - whitelisted=keyword - ), - ) - - @_perms_blacklist.command(name="delete", aliases=["del", "remove"]) - async def _perms_blacklist_delete(self, ctx: commands.Context, *, keyword: str): - """Removes a keyword from the blacklist.""" - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = True - async with self.config.guild(ctx.guild).url_keyword_blacklist() as blacklist: - if keyword not in blacklist: - exists = False - else: - blacklist.remove(keyword) - if not exists: - return await self._embed_msg(ctx, title=_("Keyword is not in the blacklist.")) - else: - return await self._embed_msg( - ctx, - title=_("Blacklist Modified"), - description=_("Removed: `{blacklisted}` from the blacklist.").format( - blacklisted=keyword - ), - ) - - @_perms_whitelist.command(name="delete", aliases=["del", "remove"]) - async def _perms_whitelist_delete(self, ctx: commands.Context, *, keyword: str): - """Removes a keyword from the whitelist.""" - keyword = keyword.lower().strip() - if not keyword: - return await ctx.send_help() - exists = True - async with self.config.guild(ctx.guild).url_keyword_whitelist() as whitelist: - if keyword not in whitelist: - exists = False - else: - whitelist.remove(keyword) - if not exists: - return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) - else: - return await self._embed_msg( - ctx, - title=_("Whitelist Modified"), - description=_("Removed: `{whitelisted}` from the whitelist.").format( - whitelisted=keyword - ), - ) - - @_perms_whitelist.command(name="list") - async def _perms_whitelist_list(self, ctx: commands.Context): - """List all keywords added to the whitelist.""" - whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() - if not whitelist: - return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) - whitelist.sort() - text = "" - total = len(whitelist) - pages = [] - for i, entry in enumerate(whitelist, 1): - text += f"{i}. [{entry}]" - if i != total: - text += "\n" - if i % 10 == 0: - pages.append(box(text, lang="ini")) - text = "" - else: - pages.append(box(text, lang="ini")) - embed_colour = await ctx.embed_colour() - pages = list( - discord.Embed(title="Whitelist", description=page, colour=embed_colour) - for page in pages - ) - await menu(ctx, pages, DEFAULT_CONTROLS) - - @_perms_blacklist.command(name="list") - async def _perms_blacklist_list(self, ctx: commands.Context): - """List all keywords added to the blacklist.""" - blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() - if not blacklist: - return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) - blacklist.sort() - text = "" - total = len(blacklist) - pages = [] - for i, entry in enumerate(blacklist, 1): - text += f"{i}. [{entry}]" - if i != total: - text += "\n" - if i % 10 == 0: - pages.append(box(text, lang="ini")) - text = "" - else: - pages.append(box(text, lang="ini")) - embed_colour = await ctx.embed_colour() - pages = list( - discord.Embed(title="Blacklist", description=page, colour=embed_colour) - for page in pages - ) - await menu(ctx, pages, DEFAULT_CONTROLS) - - @_perms_whitelist.command(name="clear") - async def _perms_whitelist_clear(self, ctx: commands.Context): - """Clear all keywords from the whitelist.""" - whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() - if not whitelist: - return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) - await self.config.guild(ctx.guild).url_keyword_whitelist.clear() - return await self._embed_msg( - ctx, - title=_("Whitelist Modified"), - description=_("All entries have been removed from the whitelist."), - ) - - @_perms_blacklist.command(name="clear") - async def _perms_blacklist_clear(self, ctx: commands.Context): - """Clear all keywords added to the blacklist.""" - blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() - if not blacklist: - return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) - await self.config.guild(ctx.guild).url_keyword_blacklist.clear() - return await self._embed_msg( - ctx, - title=_("Blacklist Modified"), - description=_("All entries have been removed from the blacklist."), - ) - - @audioset.group(name="autoplay") - @checks.mod_or_permissions(manage_messages=True) - async def _autoplay(self, ctx: commands.Context): - """Change auto-play setting.""" - - @_autoplay.command(name="toggle") - async def _autoplay_toggle(self, ctx: commands.Context): - """Toggle auto-play when there no songs in queue.""" - autoplay = await self.config.guild(ctx.guild).auto_play() - repeat = await self.config.guild(ctx.guild).repeat() - disconnect = await self.config.guild(ctx.guild).disconnect() - msg = _("Auto-play when queue ends: {true_or_false}.").format( - true_or_false=_("Enabled") if not autoplay else _("Disabled") - ) - await self.config.guild(ctx.guild).auto_play.set(not autoplay) - if autoplay is not True and repeat is True: - msg += _("\nRepeat has been disabled.") - await self.config.guild(ctx.guild).repeat.set(False) - if autoplay is not True and disconnect is True: - msg += _("\nAuto-disconnecting at queue end has been disabled.") - await self.config.guild(ctx.guild).disconnect.set(False) - - await self._embed_msg(ctx, title=_("Setting Changed"), description=msg) - if self._player_check(ctx): - await self._data_check(ctx) - - @_autoplay.command(name="playlist", usage=" [args]") - async def _autoplay_playlist( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ScopeParser = None, - ): - """Set a playlist to auto-play songs from. - - **Usage**: - ​ ​ ​ ​ `[p]audioset autoplay playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]audioset autoplay MyGuildPlaylist` - ​ ​ ​ ​ `[p]audioset autoplay MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]audioset autoplay PersonalPlaylist --scope User --author Draper` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - - scope, author, guild, specified_user = scope_data - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - return await self._embed_msg(ctx, title=str(e)) - if playlist_id is None: - return await self._embed_msg( - ctx, - title=_("No Playlist Found"), - description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), - ) - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - tracks = playlist.tracks - if not tracks: - return await self._embed_msg( - ctx, - title=_("No Tracks Found"), - description=_("Playlist {name} has no tracks.").format(name=playlist.name), - ) - playlist_data = dict(enabled=True, id=playlist.id, name=playlist.name, scope=scope) - await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) - except RuntimeError: - return await self._embed_msg( - ctx, - title=_("No Playlist Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - else: - return await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_( - "Playlist {name} (`{id}`) [**{scope}**] will be used for autoplay." - ).format( - name=playlist.name, - id=playlist.id, - scope=humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ), - ), - ) - - @_autoplay.command(name="reset") - async def _autoplay_reset(self, ctx: commands.Context): - """Resets auto-play to the default playlist.""" - playlist_data = dict(enabled=False, id=None, name=None, scope=None) - await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) - return await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Set auto-play playlist to default value."), - ) - - @audioset.command() - @checks.admin_or_permissions(manage_roles=True) - async def dj(self, ctx: commands.Context): - """Toggle DJ mode. - - DJ mode allows users with the DJ role to use audio commands. - """ - dj_role = self._dj_role_cache.setdefault( - ctx.guild.id, await self.config.guild(ctx.guild).dj_role() - ) - dj_role = ctx.guild.get_role(dj_role) - if dj_role is None: - await self._embed_msg( - ctx, - title=_("Missing DJ Role"), - description=_( - "Please set a role to use with DJ mode. Enter the role name or ID now." - ), - ) - - try: - pred = MessagePredicate.valid_role(ctx) - await ctx.bot.wait_for("message", timeout=15.0, check=pred) - await ctx.invoke(self.role, role_name=pred.result) - except asyncio.TimeoutError: - return await self._embed_msg(ctx, title=_("Response timed out, try again later.")) - dj_enabled = self._dj_status_cache.setdefault( - ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() - ) - await self.config.guild(ctx.guild).dj_enabled.set(not dj_enabled) - self._dj_status_cache[ctx.guild.id] = not dj_enabled - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("DJ role: {true_or_false}.").format( - true_or_false=_("Enabled") if not dj_enabled else _("Disabled") - ), - ) - - @audioset.command() - @checks.mod_or_permissions(administrator=True) - async def emptydisconnect(self, ctx: commands.Context, seconds: int): - """Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable. - - `[p]audioset dc` takes precedence over this setting. - """ - if seconds < 0: - return await self._embed_msg( - ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") - ) - if 10 > seconds > 0: - seconds = 10 - if seconds == 0: - enabled = False - await self._embed_msg( - ctx, title=_("Setting Changed"), description=_("Empty disconnect disabled.") - ) - else: - enabled = True - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Empty disconnect timer set to {num_seconds}.").format( - num_seconds=dynamic_time(seconds) - ), - ) - - await self.config.guild(ctx.guild).emptydc_timer.set(seconds) - await self.config.guild(ctx.guild).emptydc_enabled.set(enabled) - - @audioset.command() - @checks.mod_or_permissions(administrator=True) - async def emptypause(self, ctx: commands.Context, seconds: int): - """Auto-pause after x seconds when room is empty, 0 to disable.""" - if seconds < 0: - return await self._embed_msg( - ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") - ) - if 10 > seconds > 0: - seconds = 10 - if seconds == 0: - enabled = False - await self._embed_msg( - ctx, title=_("Setting Changed"), description=_("Empty pause disabled.") - ) - else: - enabled = True - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Empty pause timer set to {num_seconds}.").format( - num_seconds=dynamic_time(seconds) - ), - ) - await self.config.guild(ctx.guild).emptypause_timer.set(seconds) - await self.config.guild(ctx.guild).emptypause_enabled.set(enabled) - - @audioset.command() - @checks.mod_or_permissions(administrator=True) - async def jukebox(self, ctx: commands.Context, price: int): - """Set a price for queueing tracks for non-mods, 0 to disable.""" - if price < 0: - return await self._embed_msg( - ctx, title=_("Invalid Price"), description=_("Price can't be less than zero.") - ) - if price == 0: - jukebox = False - await self._embed_msg( - ctx, title=_("Setting Changed"), description=_("Jukebox mode disabled.") - ) - else: - jukebox = True - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Track queueing command price set to {price} {currency}.").format( - price=humanize_number(price), currency=await bank.get_currency_name(ctx.guild) - ), - ) - - await self.config.guild(ctx.guild).jukebox_price.set(price) - await self.config.guild(ctx.guild).jukebox.set(jukebox) - - @audioset.command() - @checks.is_owner() - async def localpath(self, ctx: commands.Context, *, local_path=None): - """Set the localtracks path if the Lavalink.jar is not run from the Audio data folder. - - Leave the path blank to reset the path to the default, the Audio data directory. - """ - - if not local_path: - await self.config.localpath.set(str(cog_data_path(raw_name="Audio"))) - pass_config_to_dependencies( - self.config, self.bot, str(cog_data_path(raw_name="Audio")) - ) - return await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_( - "The localtracks path location has been reset to {localpath}" - ).format(localpath=str(cog_data_path(raw_name="Audio").absolute())), - ) - - info_msg = _( - "This setting is only for bot owners to set a localtracks folder location " - "In the example below, the full path for 'ParentDirectory' " - "must be passed to this command.\n" - "The path must not contain spaces.\n" - "```\n" - "ParentDirectory\n" - " |__ localtracks (folder)\n" - " | |__ Awesome Album Name (folder)\n" - " | |__01 Cool Song.mp3\n" - " | |__02 Groovy Song.mp3\n" - "```\n" - "The folder path given to this command must contain the localtracks folder.\n" - "**This folder and files need to be visible to the user where `" - "Lavalink.jar` is being run from.**\n" - "Use this command with no path given to reset it to the default, " - "the Audio data directory for this bot.\n" - "Do you want to continue to set the provided path for local tracks?" - ) - info = await ctx.maybe_send_embed(info_msg) - - start_adding_reactions(info, ReactionPredicate.YES_OR_NO_EMOJIS) - pred = ReactionPredicate.yes_or_no(info, ctx.author) - await ctx.bot.wait_for("reaction_add", check=pred) - - if not pred.result: - with contextlib.suppress(discord.HTTPException): - await info.delete() - return - temp = audio_dataclasses.LocalPath(local_path, forced=True) - if not temp.exists() or not temp.is_dir(): - return await self._embed_msg( - ctx, - title=_("Invalid Path"), - description=_("{local_path} does not seem like a valid path.").format( - local_path=local_path - ), - ) - - if not temp.localtrack_folder.exists(): - warn_msg = _( - "`{localtracks}` does not exist. " - "The path will still be saved, but please check the path and " - "create a localtracks folder in `{localfolder}` before attempting " - "to play local tracks." - ).format(localfolder=temp.absolute(), localtracks=temp.localtrack_folder.absolute()) - await self._embed_msg(ctx, title=_("Invalid Environment"), description=warn_msg) - local_path = str(temp.localtrack_folder.absolute()) - await self.config.localpath.set(local_path) - pass_config_to_dependencies(self.config, self.bot, local_path) - return await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("The localtracks path location has been set to {localpath}").format( - localpath=local_path - ), - ) - - @audioset.command() - @checks.mod_or_permissions(administrator=True) - async def maxlength(self, ctx: commands.Context, seconds: Union[int, str]): - """Max length of a track to queue in seconds, 0 to disable. - - Accepts seconds or a value formatted like 00:00:00 (`hh:mm:ss`) or 00:00 (`mm:ss`). Invalid - input will turn the max length setting off. - """ - if not isinstance(seconds, int): - seconds = time_convert(seconds) - if seconds < 0: - return await self._embed_msg( - ctx, title=_("Invalid length"), description=_("Length can't be less than zero.") - ) - if seconds == 0: - await self._embed_msg( - ctx, title=_("Setting Changed"), description=_("Track max length disabled.") - ) - else: - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Track max length set to {seconds}.").format( - seconds=dynamic_time(seconds) - ), - ) - await self.config.guild(ctx.guild).maxlength.set(seconds) - - @audioset.command() - @checks.mod_or_permissions(manage_messages=True) - async def notify(self, ctx: commands.Context): - """Toggle track announcement and other bot messages.""" - notify = await self.config.guild(ctx.guild).notify() - await self.config.guild(ctx.guild).notify.set(not notify) - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Notify mode: {true_or_false}.").format( - true_or_false=_("Enabled") if not notify else _("Disabled") - ), - ) - - @audioset.command() - @checks.is_owner() - async def restrict(self, ctx: commands.Context): - """Toggle the domain restriction on Audio. - - When toggled off, users will be able to play songs from non-commercial websites and links. - When toggled on, users are restricted to YouTube, SoundCloud, Mixer, Vimeo, Twitch, and - Bandcamp links. - """ - restrict = await self.config.restrict() - await self.config.restrict.set(not restrict) - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Commercial links only: {true_or_false}.").format( - true_or_false=_("Enabled") if not restrict else _("Disabled") - ), - ) - - @audioset.command() - @checks.admin_or_permissions(manage_roles=True) - async def role(self, ctx: commands.Context, *, role_name: discord.Role): - """Set the role to use for DJ mode.""" - await self.config.guild(ctx.guild).dj_role.set(role_name.id) - self._dj_role_cache[ctx.guild.id] = role_name.id - 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) - await self._embed_msg( - ctx, - title=_("Settings Changed"), - description=_("DJ role set to: {role.name}.").format(role=dj_role_obj), - ) - - @audioset.command() - async def settings(self, ctx: commands.Context): - """Show the current settings.""" - is_owner = await ctx.bot.is_owner(ctx.author) - global_data = await self.config.all() - data = await self.config.guild(ctx.guild).all() - dj_role_obj = ctx.guild.get_role(data["dj_role"]) - dj_enabled = data["dj_enabled"] - emptydc_enabled = data["emptydc_enabled"] - emptydc_timer = data["emptydc_timer"] - emptypause_enabled = data["emptypause_enabled"] - emptypause_timer = data["emptypause_timer"] - jukebox = data["jukebox"] - jukebox_price = data["jukebox_price"] - thumbnail = data["thumbnail"] - dc = data["disconnect"] - autoplay = data["auto_play"] - maxlength = data["maxlength"] - vote_percent = data["vote_percent"] - current_level = CacheLevel(global_data["cache_level"]) - song_repeat = _("Enabled") if data["repeat"] else _("Disabled") - song_shuffle = _("Enabled") if data["shuffle"] else _("Disabled") - bumpped_shuffle = _("Enabled") if data["shuffle_bumped"] else _("Disabled") - song_notify = _("Enabled") if data["notify"] else _("Disabled") - song_status = _("Enabled") if global_data["status"] else _("Disabled") - - spotify_cache = CacheLevel.set_spotify() - youtube_cache = CacheLevel.set_youtube() - lavalink_cache = CacheLevel.set_lavalink() - has_spotify_cache = current_level.is_superset(spotify_cache) - has_youtube_cache = current_level.is_superset(youtube_cache) - has_lavalink_cache = current_level.is_superset(lavalink_cache) - autoplaylist = data["autoplaylist"] - vote_enabled = data["vote_enabled"] - msg = "----" + _("Server Settings") + "---- \n" - msg += _("Auto-disconnect: [{dc}]\n").format(dc=_("Enabled") if dc else _("Disabled")) - msg += _("Auto-play: [{autoplay}]\n").format( - autoplay=_("Enabled") if autoplay else _("Disabled") - ) - if emptydc_enabled: - msg += _("Disconnect timer: [{num_seconds}]\n").format( - num_seconds=dynamic_time(emptydc_timer) - ) - if emptypause_enabled: - msg += _("Auto Pause timer: [{num_seconds}]\n").format( - num_seconds=dynamic_time(emptypause_timer) - ) - if dj_enabled and dj_role_obj: - msg += _("DJ Role: [{role.name}]\n").format(role=dj_role_obj) - if jukebox: - msg += _("Jukebox: [{jukebox_name}]\n").format(jukebox_name=jukebox) - msg += _("Command price: [{jukebox_price}]\n").format( - jukebox_price=humanize_number(jukebox_price) - ) - if maxlength > 0: - msg += _("Max track length: [{tracklength}]\n").format( - tracklength=dynamic_time(maxlength) - ) - msg += _( - "Repeat: [{repeat}]\n" - "Shuffle: [{shuffle}]\n" - "Shuffle bumped: [{bumpped_shuffle}]\n" - "Song notify msgs: [{notify}]\n" - "Songs as status: [{status}]\n" - ).format( - repeat=song_repeat, - shuffle=song_shuffle, - notify=song_notify, - status=song_status, - bumpped_shuffle=bumpped_shuffle, - ) - if thumbnail: - msg += _("Thumbnails: [{0}]\n").format( - _("Enabled") if thumbnail else _("Disabled") - ) - if vote_percent > 0: - msg += _( - "Vote skip: [{vote_enabled}]\nSkip percentage: [{vote_percent}%]\n" - ).format( - vote_percent=vote_percent, - vote_enabled=_("Enabled") if vote_enabled else _("Disabled"), - ) - - if autoplay or autoplaylist["enabled"]: - if autoplaylist["enabled"]: - pname = autoplaylist["name"] - pid = autoplaylist["id"] - pscope = autoplaylist["scope"] - if pscope == PlaylistScope.GUILD.value: - pscope = f"Server" - elif pscope == PlaylistScope.USER.value: - pscope = f"User" - else: - pscope = "Global" - else: - pname = _("Cached") - pid = _("Cached") - pscope = _("Cached") - msg += ( - "\n---" - + _("Auto-play Settings") - + "--- \n" - + _("Playlist name: [{pname}]\n") - + _("Playlist ID: [{pid}]\n") - + _("Playlist scope: [{pscope}]\n") - ).format(pname=pname, pid=pid, pscope=pscope) - - if is_owner: - msg += ( - "\n---" - + _("Cache Settings") - + "--- \n" - + _("Max age: [{max_age}]\n") - + _("Spotify cache: [{spotify_status}]\n") - + _("Youtube cache: [{youtube_status}]\n") - + _("Lavalink cache: [{lavalink_status}]\n") - ).format( - max_age=str(await self.config.cache_age()) + " " + _("days"), - spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), - youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), - lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), - ) - - msg += _( - "\n---" + _("Lavalink Settings") + "--- \n" - "Cog version: [{version}]\n" - "Red-Lavalink: [{redlava}]\n" - "External server: [{use_external_lavalink}]\n" - ).format( - version=__version__, - redlava=lavalink.__version__, - use_external_lavalink=_("Enabled") - if global_data["use_external_lavalink"] - else _("Disabled"), - ) - if is_owner: - msg += _("Localtracks path: [{localpath}]\n").format(**global_data) - - await self._embed_msg(ctx, description=box(msg, lang="ini")) - - @audioset.command() - @checks.is_owner() - async def spotifyapi(self, ctx: commands.Context): - """Instructions to set the Spotify API tokens.""" - message = _( - "1. Go to Spotify developers and log in with your Spotify account.\n" - "(https://developer.spotify.com/dashboard/applications)\n" - '2. Click "Create An App".\n' - "3. Fill out the form provided with your app name, etc.\n" - '4. When asked if you\'re developing commercial integration select "No".\n' - "5. Accept the terms and conditions.\n" - "6. Copy your client ID and your client secret into:\n" - "`{prefix}set api spotify client_id " - "client_secret `" - ).format(prefix=ctx.prefix) - await ctx.maybe_send_embed(message) - - @checks.is_owner() - @audioset.command() - async def status(self, ctx: commands.Context): - """Enable/disable tracks' titles as status.""" - status = await self.config.status() - await self.config.status.set(not status) - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Song titles as status: {true_or_false}.").format( - true_or_false=_("Enabled") if not status else _("Disabled") - ), - ) - - @audioset.command() - @checks.mod_or_permissions(administrator=True) - async def thumbnail(self, ctx: commands.Context): - """Toggle displaying a thumbnail on audio messages.""" - thumbnail = await self.config.guild(ctx.guild).thumbnail() - await self.config.guild(ctx.guild).thumbnail.set(not thumbnail) - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Thumbnail display: {true_or_false}.").format( - true_or_false=_("Enabled") if not thumbnail else _("Disabled") - ), - ) - - @audioset.command() - @checks.mod_or_permissions(administrator=True) - async def vote(self, ctx: commands.Context, percent: int): - """Percentage needed for non-mods to skip tracks, 0 to disable.""" - if percent < 0: - return await self._embed_msg( - ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") - ) - elif percent > 100: - percent = 100 - if percent == 0: - enabled = False - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Voting disabled. All users can use queue management commands."), - ) - else: - enabled = True - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Vote percentage set to {percent}%.").format(percent=percent), - ) - - await self.config.guild(ctx.guild).vote_percent.set(percent) - await self.config.guild(ctx.guild).vote_enabled.set(enabled) - - @audioset.command() - @checks.is_owner() - async def youtubeapi(self, ctx: commands.Context): - """Instructions to set the YouTube API key.""" - message = _( - f"1. Go to Google Developers Console and log in with your Google account.\n" - "(https://console.developers.google.com/)\n" - "2. You should be prompted to create a new project (name does not matter).\n" - "3. Click on Enable APIs and Services at the top.\n" - "4. In the list of APIs choose or search for YouTube Data API v3 and " - "click on it. Choose Enable.\n" - "5. Click on Credentials on the left navigation bar.\n" - "6. Click on Create Credential at the top.\n" - '7. At the top click the link for "API key".\n' - "8. No application restrictions are needed. Click Create at the bottom.\n" - "9. You now have a key to add to `{prefix}set api youtube api_key `" - ).format(prefix=ctx.prefix) - await ctx.maybe_send_embed(message) - - @audioset.command(name="cache", usage="level=[5, 3, 2, 1, 0, -1, -2, -3]") - @checks.is_owner() - async def _storage(self, ctx: commands.Context, *, level: int = None): - """Sets the caching level. - - Level can be one of the following: - - 0: Disables all caching - 1: Enables Spotify Cache - 2: Enables YouTube Cache - 3: Enables Lavalink Cache - 5: Enables all Caches - - If you wish to disable a specific cache use a negative number. - """ - current_level = CacheLevel(await self.config.cache_level()) - spotify_cache = CacheLevel.set_spotify() - youtube_cache = CacheLevel.set_youtube() - lavalink_cache = CacheLevel.set_lavalink() - has_spotify_cache = current_level.is_superset(spotify_cache) - has_youtube_cache = current_level.is_superset(youtube_cache) - has_lavalink_cache = current_level.is_superset(lavalink_cache) - - if level is None: - msg = ( - _("Max age: [{max_age}]\n") - + _("Spotify cache: [{spotify_status}]\n") - + _("Youtube cache: [{youtube_status}]\n") - + _("Lavalink cache: [{lavalink_status}]\n") - ).format( - max_age=str(await self.config.cache_age()) + " " + _("days"), - spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), - youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), - lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), - ) - await self._embed_msg(ctx, title=_("Cache Settings"), description=box(msg, lang="ini")) - return await ctx.send_help() - if level not in [5, 3, 2, 1, 0, -1, -2, -3]: - return await ctx.send_help() - - removing = level < 0 - - if level == 5: - newcache = CacheLevel.all() - elif level == 0: - newcache = CacheLevel.none() - elif level in [-3, 3]: - if removing: - newcache = current_level - lavalink_cache - else: - newcache = current_level + lavalink_cache - elif level in [-2, 2]: - if removing: - newcache = current_level - youtube_cache - else: - newcache = current_level + youtube_cache - elif level in [-1, 1]: - if removing: - newcache = current_level - spotify_cache - else: - newcache = current_level + spotify_cache - else: - return await ctx.send_help() - - has_spotify_cache = newcache.is_superset(spotify_cache) - has_youtube_cache = newcache.is_superset(youtube_cache) - has_lavalink_cache = newcache.is_superset(lavalink_cache) - msg = ( - _("Max age: [{max_age}]\n") - + _("Spotify cache: [{spotify_status}]\n") - + _("Youtube cache: [{youtube_status}]\n") - + _("Lavalink cache: [{lavalink_status}]\n") - ).format( - max_age=str(await self.config.cache_age()) + " " + _("days"), - spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), - youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), - lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), - ) - - await self._embed_msg(ctx, title=_("Cache Settings"), description=box(msg, lang="ini")) - - await self.config.cache_level.set(newcache.value) - - @audioset.command(name="cacheage") - @checks.is_owner() - async def _cacheage(self, ctx: commands.Context, age: int): - """Sets the cache max age. - - This commands allows you to set the max number of days before an entry in the cache becomes - invalid. - """ - msg = "" - if age < 7: - msg = _( - "Cache age cannot be less than 7 days. If you wish to disable it run " - "{prefix}audioset cache.\n" - ).format(prefix=ctx.prefix) - age = 7 - msg += _("I've set the cache age to {age} days").format(age=age) - await self.config.cache_age.set(age) - await self._embed_msg(ctx, title=_("Setting Changed"), description=msg) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def audiostats(self, ctx: commands.Context): - """Audio stats.""" - server_num = len(lavalink.active_players()) - total_num = len(lavalink.all_players()) - localtracks = await self.config.localpath() - - msg = "" - for p in lavalink.all_players(): - connect_start = p.fetch("connect") - connect_dur = dynamic_time( - int((datetime.datetime.utcnow() - connect_start).total_seconds()) - ) - try: - query = audio_dataclasses.Query.process_input(p.current.uri) - if query.is_local: - if p.current.title == "Unknown title": - current_title = localtracks.LocalPath(p.current.uri).to_string_user() - msg += "{} [`{}`]: **{}**\n".format( - p.channel.guild.name, connect_dur, current_title - ) - else: - current_title = p.current.title - msg += "{} [`{}`]: **{} - {}**\n".format( - p.channel.guild.name, connect_dur, p.current.author, current_title - ) - else: - msg += "{} [`{}`]: **[{}]({})**\n".format( - p.channel.guild.name, connect_dur, p.current.title, p.current.uri - ) - except AttributeError: - msg += "{} [`{}`]: **{}**\n".format( - p.channel.guild.name, connect_dur, _("Nothing playing.") - ) - - if total_num == 0: - return await self._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() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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._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 await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable To Bump Track"), - description=_("You must be in the voice channel to bump a track."), - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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._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 = get_track_description(removed) - await self._embed_msg( - ctx, title=_("Moved track to the top of the queue."), description=description - ) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def disconnect(self, ctx: commands.Context): - """Disconnect from the voice channel.""" - if not self._player_check(ctx): - return await self._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() - ) - player = lavalink.get_player(ctx.guild.id) - - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable to disconnect"), - description=_("You need the DJ role to disconnect."), - ) - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg( - ctx, title=_("There are other people listening to music.") - ) - else: - await self._embed_msg(ctx, title=_("Disconnecting...")) - self.bot.dispatch("red_audio_audio_disconnect", ctx.guild) - self._play_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.group(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 eq(self, ctx: commands.Context): - """Equalizer management.""" - if not self._player_check(ctx): - ctx.command.reset_cooldown(ctx) - return await self._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) - - @eq.command(name="delete", aliases=["del", "remove"]) - async def _eq_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._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._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._embed_msg( - ctx, - title=_("Unable To Delete Preset"), - description=_("You are not the author of that preset setting."), - ) - - await self._embed_msg( - ctx, title=_("The {preset_name} preset was deleted.".format(preset_name=eq_preset)) - ) - - @eq.command(name="list") - async def _eq_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._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) - - @eq.command(name="load") - async def _eq_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._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._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: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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) - - @eq.command(name="reset") - async def _eq_reset(self, ctx: commands.Context): - """Reset the eq to 0 across all bands.""" - if not self._player_check(ctx): - return await self._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: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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) - - @eq.command(name="save") - @commands.cooldown(1, 15, commands.BucketType.guild) - async def _eq_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._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: - if not await self._can_instaskip(ctx, ctx.author): - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Unable To Save Preset"), - description=_("You need the DJ role to save equalizer presets."), - ) - if not eq_preset: - await self._embed_msg(ctx, title=_("Please enter a name for this equalizer preset.")) - try: - eq_name_msg = await ctx.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._embed_msg( - ctx, - title=_("Unable To Save Preset"), - description=_( - "No equalizer preset name entered, try the command again later." - ), - ) - - 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._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._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 ctx.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._embed_msg(ctx, embed=embed3) - - @eq.command(name="set") - async def _eq_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._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: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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 = None - - if band_number not in range(0, bands_num) and band_name_or_position not in band_names: - return await self._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) - - @commands.group() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def local(self, ctx: commands.Context): - """Local playback commands.""" - - @local.command(name="folder", aliases=["start"]) - async def local_folder( - self, ctx: commands.Context, play_subfolders: Optional[bool] = True, *, folder: str = None - ): - """Play all songs in a localtracks folder.""" - if not await self._localtracks_check(ctx): - return - - if not folder: - await ctx.invoke(self.local_play, play_subfolders=play_subfolders) - else: - folder = folder.strip() - _dir = audio_dataclasses.LocalPath.joinpath(folder) - if not _dir.exists(): - return await self._embed_msg( - ctx, - title=_("Folder Not Found"), - description=_("Localtracks folder named {name} does not exist.").format( - name=folder - ), - ) - query = audio_dataclasses.Query.process_input(_dir, search_subfolders=play_subfolders) - await self._local_play_all(ctx, query, from_search=False if not folder else True) - - @local.command(name="play") - async def local_play(self, ctx: commands.Context, play_subfolders: Optional[bool] = True): - """Play a local track.""" - if not await self._localtracks_check(ctx): - return - localtracks_folders = await self._localtracks_folders( - ctx, search_subfolders=play_subfolders - ) - if not localtracks_folders: - return await self._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) - - @local.command(name="search") - async def local_search( - self, ctx: commands.Context, search_subfolders: Optional[bool] = True, *, search_words - ): - """Search for songs across all localtracks folders.""" - if not await self._localtracks_check(ctx): - return - all_tracks = await self._folder_list( - ctx, - ( - audio_dataclasses.Query.process_input( - audio_dataclasses.LocalPath( - await self.config.localpath() - ).localtrack_folder.absolute(), - search_subfolders=search_subfolders, - ) - ), - ) - if not all_tracks: - return await self._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._embed_msg(ctx, title=_("No matches.")) - return await ctx.invoke(self.search, query=search_list) - - async def _localtracks_folders( - self, ctx: commands.Context, search_subfolders=False - ) -> Optional[List[Union[Path, audio_dataclasses.LocalPath]]]: - audio_data = audio_dataclasses.LocalPath( - audio_dataclasses.LocalPath(None).localtrack_folder.absolute() - ) - if not await self._localtracks_check(ctx): - return - - return ( - await audio_data.subfolders_in_tree() - if search_subfolders - else await audio_data.subfolders() - ) - - async def _folder_list( - self, ctx: commands.Context, query: audio_dataclasses.Query - ) -> Optional[List[audio_dataclasses.Query]]: - if not await self._localtracks_check(ctx): - return - query = audio_dataclasses.Query.process_input(query) - if not query.track.exists(): - return - return ( - await query.track.tracks_in_tree() - if query.search_subfolders - else await query.track.tracks_in_folder() - ) - - async def _folder_tracks( - self, ctx, player: lavalink.player_manager.Player, query: audio_dataclasses.Query - ) -> Optional[List[lavalink.rest_api.Track]]: - if not await self._localtracks_check(ctx): - return - - audio_data = audio_dataclasses.LocalPath(None) - try: - query.track.path.relative_to(audio_data.to_string()) - except ValueError: - return - local_tracks = [] - for local_file in await self._all_folder_tracks(ctx, query): - trackdata, called_api = await self.music_cache.lavalink_query(ctx, player, local_file) - with contextlib.suppress(IndexError): - local_tracks.append(trackdata.tracks[0]) - return local_tracks - - async def _local_play_all( - self, ctx: commands.Context, query: audio_dataclasses.Query, from_search=False - ) -> None: - if not await self._localtracks_check(ctx): - return - if from_search: - query = audio_dataclasses.Query.process_input( - query.track.to_string(), invoked_from="local folder" - ) - await ctx.invoke(self.search, query=query) - - async def _all_folder_tracks( - self, ctx: commands.Context, query: audio_dataclasses.Query - ) -> Optional[List[audio_dataclasses.Query]]: - if not await self._localtracks_check(ctx): - return - - return ( - await query.track.tracks_in_tree() - if query.search_subfolders - else await query.track.tracks_in_folder() - ) - - async def _localtracks_check(self, ctx: commands.Context) -> bool: - folder = audio_dataclasses.LocalPath(None) - if folder.localtrack_folder.exists(): - return True - if ctx.invoked_with != "start": - await self._embed_msg( - ctx, title=_("Invalid Environment"), description=_("No localtracks folder.") - ) - return False - - @staticmethod - async def _build_local_search_list(to_search, search_words): - to_search_string = {i.track.name for i in to_search} - search_results = process.extract(search_words, to_search_string, limit=50) - search_list = [] - for track_match, percent_match in search_results: - if percent_match > 60: - search_list.extend( - [i.track.to_string_user() for i in to_search if i.track.name == track_match] - ) - await asyncio.sleep(0) - return search_list - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def now(self, ctx: commands.Context): - """Now playing.""" - if not self._player_check(ctx): - return await self._embed_msg(ctx, title=_("Nothing playing.")) - expected = ("⏮", "⏹", "⏯", "⏭") - emoji = {"prev": "⏮", "stop": "⏹", "pause": "⏯", "next": "⏭"} - player = lavalink.get_player(ctx.guild.id) - if player.current: - arrow = await draw_time(ctx) - pos = lavalink.utils.format_time(player.position) - if player.current.is_stream: - dur = "LIVE" - else: - dur = lavalink.utils.format_time(player.current.length) - song = get_track_description(player.current) - 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) - if await self.config.guild(ctx.guild).thumbnail() and player.current: - if player.current.thumbnail: - embed.set_thumbnail(url=player.current.thumbnail) - - 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() - 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._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: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return - - if not player.queue: - expected = ("⏹", "⏯") - if player.current: - task = start_adding_reactions(message, expected[:4]) - 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.prev) - elif react == "stop": - await self._clear_react(message, emoji) - await ctx.invoke(self.stop) - elif react == "pause": - await self._clear_react(message, emoji) - await ctx.invoke(self.pause) - elif react == "next": - await self._clear_react(message, emoji) - await ctx.invoke(self.skip) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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._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 await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable To Manage Tracks"), - description=_("You must be in the voice channel to pause or resume."), - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._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._embed_msg(ctx, title=_("Nothing playing.")) - description = get_track_description(player.current) - - if player.current and not player.paused: - await player.pause() - return await self._embed_msg(ctx, title=_("Track Paused"), description=description) - if player.current and player.paused: - await player.pause(False) - return await self._embed_msg(ctx, title=_("Track Resumed"), description=description) - - await self._embed_msg(ctx, title=_("Nothing playing.")) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def percent(self, ctx: commands.Context): - """Queue percentage.""" - if not self._player_check(ctx): - return await self._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 - - for track in queue_tracks: - req_username = "{}#{}".format(track.requester.name, track.requester.discriminator) - await _usercount(req_username) - await asyncio.sleep(0) - - try: - req_username = "{}#{}".format( - player.current.requester.name, player.current.requester.discriminator - ) - await _usercount(req_username) - except AttributeError: - return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) - - for req_username in requesters["users"]: - percentage = float(requesters["users"][req_username]["songcount"]) / float( - requesters["total"] - ) - requesters["users"][req_username]["percent"] = round(percentage * 100, 1) - await asyncio.sleep(0) - - 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._embed_msg( - ctx, title=_("Queued and playing tracks:"), description=queue_user_list - ) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def play(self, ctx: commands.Context, *, query: str): - """Play a URL or search for a track.""" - query = audio_dataclasses.Query.process_input(query) - guild_data = await self.config.guild(ctx.guild).all() - restrict = await self.config.restrict() - if restrict and match_url(query): - valid_url = url_check(query) - if not valid_url: - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("That URL is not allowed."), - ) - elif not await is_allowed(ctx.guild, f"{query}", query_obj=query): - return await self._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.") - ) - if not self._player_check(ctx): - if self._connection_aborted: - msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - return await self._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 userlimit(ctx.author.voice.channel) - ): - return await self._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._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connect to a voice channel first."), - ) - except IndexError: - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connection to Lavalink has not yet been established."), - ) - if guild_data["dj_enabled"]: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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._data_check(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._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._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._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.") - ) - - if not await self._currency_check(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._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=err.message - ) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def bumpplay( - self, ctx: commands.Context, play_now: Optional[bool] = False, *, query: str - ): - """Force play a URL or search for a track.""" - query = audio_dataclasses.Query.process_input(query) - if not query.single_track: - return await self._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 match_url(query): - valid_url = url_check(query) - if not valid_url: - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("That URL is not allowed."), - ) - elif not await is_allowed(ctx.guild, f"{query}", query_obj=query): - return await self._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.") - ) - if not self._player_check(ctx): - if self._connection_aborted: - msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - return await self._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 userlimit(ctx.author.voice.channel) - ): - return await self._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._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connect to a voice channel first."), - ) - except IndexError: - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connection to Lavalink has not yet been established."), - ) - if guild_data["dj_enabled"]: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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._data_check(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._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._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._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.") - ) - - if not await self._currency_check(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._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=err.message - ) - if isinstance(tracks, discord.Message): - return - elif not tracks: - self._play_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 audio_dataclasses._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._embed_msg(ctx, embed=embed) - elif isinstance(tracks, discord.Message): - return - queue_dur = await 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 is_allowed( - ctx.guild, - ( - f"{single_track.title} {single_track.author} {single_track.uri} " - f"{str(audio_dataclasses.Query.process_input(single_track))}" - ), - ): - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") - self._play_lock(ctx, False) - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("This track is not allowed in this server."), - ) - elif guild_data["maxlength"] > 0: - if track_limit(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._play_lock(ctx, False) - return await self._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 = get_track_description(single_track) - 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=lavalink.utils.format_time(queue_dur) - ) - await self._embed_msg( - ctx, title=_("Track Enqueued"), description=description, footer=footer - ) - - if not player.current: - await player.play() - elif play_now: - await player.skip() - - self._play_lock(ctx, False) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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"], - not api_data["youtube_api"], - ] - ): - return await self._embed_msg( - ctx, - title=_("Invalid Environment"), - description=_( - "The owner needs to set the Spotify client ID, Spotify client secret, " - "and YouTube API key before Spotify URLs or codes can be used. " - "\nSee `{prefix}audioset youtubeapi` and `{prefix}audioset spotifyapi` " - "for instructions." - ).format(prefix=ctx.prefix), - ) - guild_data = await self.config.guild(ctx.guild).all() - if not self._player_check(ctx): - if self._connection_aborted: - msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - return await self._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 userlimit(ctx.author.voice.channel) - ): - return await self._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._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connect to a voice channel first."), - ) - except IndexError: - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connection to Lavalink has not yet been established."), - ) - if guild_data["dj_enabled"]: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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._data_check(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._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.music_cache.spotify_api.get_categories() - except SpotifyFetchError as error: - return await self._embed_msg( - ctx, - title=_("No categories found"), - description=_(error.message).format(prefix=ctx.prefix), - ) - if not category_list: - return await self._embed_msg(ctx, title=_("No categories found, try again later.")) - len_folder_pages = math.ceil(len(category_list) / 5) - category_search_page_list = [] - for page_num in 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) - await asyncio.sleep(0) - cat_menu_output = await menu(ctx, category_search_page_list, category_search_controls) - if not cat_menu_output: - return await self._embed_msg(ctx, title=_("No categories selected, try again later.")) - category_name, category_pick = cat_menu_output - playlists_list = await self.music_cache.spotify_api.get_playlist_from_category( - category_pick - ) - if not playlists_list: - return await self._embed_msg(ctx, title=_("No categories found, try again later.")) - len_folder_pages = math.ceil(len(playlists_list) / 5) - playlists_search_page_list = [] - for page_num in 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) - await asyncio.sleep(0) - playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls) - query = audio_dataclasses.Query.process_input(playlists_pick) - if not query.valid: - return await self._embed_msg(ctx, title=_("No tracks to play.")) - if len(player.queue) >= 10000: - return await self._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.") - ) - if not await self._currency_check(ctx, guild_data["jukebox_price"]): - return - if query.is_spotify: - return await self._get_spotify_tracks(ctx, query) - return await self._embed_msg( - ctx, title=_("Couldn't find tracks for the selected playlist.") - ) - - @staticmethod - async def _genre_search_button_action( - ctx: commands.Context, options, emoji, page, playlist=False - ): - 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") - - @staticmethod - async def _build_genre_search_page( - ctx: commands.Context, tracks, page_num, title, playlist=False - ): - search_num_pages = math.ceil(len(tracks) / 5) - search_idx_start = (page_num - 1) * 5 - search_idx_end = search_idx_start + 5 - search_list = "" - for i, entry in enumerate(tracks[search_idx_start:search_idx_end], 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 += "`{}.` {}\n".format(search_track_num, name) - await asyncio.sleep(0) - - 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 - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - @checks.mod_or_permissions(manage_messages=True) - async def autoplay(self, ctx: commands.Context): - """Starts auto play.""" - if not self._player_check(ctx): - if self._connection_aborted: - msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - return await self._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 userlimit(ctx.author.voice.channel) - ): - return await self._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._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("Connect to a voice channel first."), - ) - except IndexError: - return await self._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"]: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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._data_check(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._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._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.") - ) - if not await self._currency_check(ctx, guild_data["jukebox_price"]): - return - try: - await self.music_cache.autoplay(player) - except DatabaseError: - notify_channel = player.fetch("channel") - if notify_channel: - notify_channel = self.bot.get_channel(notify_channel) - await self._embed_msg(notify_channel, title=_("Couldn't get a valid track.")) - return - - if not guild_data["auto_play"]: - await ctx.invoke(self._autoplay_toggle) - if not guild_data["notify"] and ( - (player.current and not player.current.extras.get("autoplay")) or not player.current - ): - await self._embed_msg(ctx, title=_("Auto play started.")) - elif player.current: - await self._embed_msg(ctx, title=_("Adding a track to queue.")) - - async def _get_spotify_tracks(self, ctx: commands.Context, query: audio_dataclasses.Query): - 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 ( - not api_data["spotify_client_id"] - or not api_data["spotify_client_secret"] - or not api_data["youtube_api"] - ): - return await self._embed_msg( - ctx, - title=_("Invalid Environment"), - description=_( - "The owner needs to set the Spotify client ID, Spotify client secret, " - "and YouTube API key before Spotify URLs or codes can be used. " - "\nSee `{prefix}audioset youtubeapi` and `{prefix}audioset spotifyapi` " - "for instructions." - ).format(prefix=ctx.prefix), - ) - try: - if self.play_lock[ctx.message.guild.id]: - return await self._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.music_cache.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 audio_dataclasses._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._embed_msg(ctx, embed=embed) - except SpotifyFetchError as error: - self._play_lock(ctx, False) - return await self._embed_msg(ctx, title=_(error.message).format(prefix=ctx.prefix)) - self._play_lock(ctx, False) - try: - if enqueue_tracks: - new_query = audio_dataclasses.Query.process_input(res[0]) - new_query.start_time = query.start_time - return await self._enqueue_tracks(ctx, new_query) - else: - query = audio_dataclasses.Query.process_input(res[0]) - try: - result, called_api = await self.music_cache.lavalink_query( - ctx, player, query - ) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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 audio_dataclasses._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._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._play_lock(ctx, False) - return await self._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._play_lock(ctx, True) - track_list = await self._spotify_playlist( - ctx, "album" if query.is_album else "playlist", query, enqueue_tracks - ) - self._play_lock(ctx, False) - return track_list - else: - return await self._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[audio_dataclasses.Query, list], - enqueue: bool = True, - ): - player = lavalink.get_player(ctx.guild.id) - try: - if self.play_lock[ctx.message.guild.id]: - return await self._embed_msg( - ctx, - title=_("Unable To Get Tracks"), - description=_("Wait until the playlist has finished loading."), - ) - except KeyError: - self._play_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 is_allowed(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.music_cache.lavalink_query(ctx, player, query) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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._play_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 audio_dataclasses._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._embed_msg(ctx, embed=embed) - else: - tracks = query - queue_dur = await queue_duration(ctx) - queue_total_duration = lavalink.utils.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._embed_msg(ctx, title=_("Queue size limit reached.")) - track_len = 0 - empty_queue = not player.queue - for track in tracks: - if len(player.queue) >= 10000: - continue - if not await is_allowed( - ctx.guild, - ( - f"{track.title} {track.author} {track.uri} " - f"{str(audio_dataclasses.Query.process_input(track))}" - ), - ): - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") - continue - elif guild_data["maxlength"] > 0: - if track_limit(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 - ) - await asyncio.sleep(0) - 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")) - 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._play_lock(ctx, False) - message = await self._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 , a single Spotify url/code - # or this is a localtrack item - try: - if len(player.queue) >= 10000: - - return await self._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 is_allowed( - ctx.guild, - ( - f"{single_track.title} {single_track.author} {single_track.uri} " - f"{str(audio_dataclasses.Query.process_input(single_track))}" - ), - ): - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") - self._play_lock(ctx, False) - return await self._embed_msg( - ctx, title=_("This track is not allowed in this server.") - ) - elif guild_data["maxlength"] > 0: - if track_limit(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._play_lock(ctx, False) - return await self._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._play_lock(ctx, False) - title = _("Nothing found") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - return await self._embed_msg(ctx, title=title, description=desc) - description = get_track_description(single_track) - 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._play_lock(ctx, False) - message = await self._embed_msg(ctx, embed=embed) - return single_track or message - - async def _spotify_playlist( - self, - ctx: commands.Context, - stype: str, - query: audio_dataclasses.Query, - enqueue: bool = False, - ): - - player = lavalink.get_player(ctx.guild.id) - try: - embed1 = discord.Embed(title=_("Please wait, finding tracks...")) - playlist_msg = await self._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.music_cache.spotify_enqueue( - ctx, - stype, - query.id, - enqueue=enqueue, - player=player, - lock=self._play_lock, - notifier=notifier, - ) - except SpotifyFetchError as error: - self._play_lock(ctx, False) - return await self._embed_msg( - ctx, - title=_("Invalid Environment"), - description=_(error.message).format(prefix=ctx.prefix), - ) - except (RuntimeError, aiohttp.ServerDisconnectedError): - self._play_lock(ctx, False) - error_embed = discord.Embed( - title=_("The connection was reset while loading the playlist.") - ) - await self._embed_msg(ctx, embed=error_embed) - return None - except Exception as e: - self._play_lock(ctx, False) - raise e - self._play_lock(ctx, False) - return track_list - - async def can_manage_playlist( - self, scope: str, playlist: Playlist, ctx: commands.Context, user, guild - ): - - is_owner = await ctx.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: - if 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: - has_perms = True - elif dj_enabled and await self._has_dj_role(ctx, ctx.author): - has_perms = True - elif await ctx.bot.is_mod(ctx.author): - has_perms = True - elif 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=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=humanize_scope(scope, the=True)) - ) - - await self._embed_msg(ctx, title=_("No access to playlist."), description=msg) - return False - return True - - async def _get_correct_playlist_id( - self, - context: commands.Context, - matches: MutableMapping, - scope: str, - author: discord.User, - guild: discord.Guild, - specified_user: bool = False, - ) -> Tuple[Optional[int], 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[int], str] - Tuple of Playlist ID or None if none found and original user input. - 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.USGLOBALER.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].id, original_input, correct_scope_matches[0].scope - elif match_count == 0: - return None, original_input, scope - - # 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()) - for number, playlist in enumerate(correct_scope_matches, 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=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].id, - original_input, - correct_scope_matches[pred.result].scope, - ) - - @commands.group() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def playlist(self, ctx: commands.Context): - """Playlist configuration options. - - Scope info: - ​ ​ ​ ​ **Global**: - ​ ​ ​ ​ ​ ​ ​ ​ Visible to all users of this bot. - ​ ​ ​ ​ ​ ​ ​ ​ Only editable by bot owner. - ​ ​ ​ ​ **Guild**: - ​ ​ ​ ​ ​ ​ ​ ​ Visible to all users in this guild. - ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner, guild owner, guild admins, guild mods, DJ role and playlist creator. - ​ ​ ​ ​ **User**: - ​ ​ ​ ​ ​ ​ ​ ​ Visible to all bot users, if --author is passed. - ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner and creator. - - """ - - @playlist.command(name="append", usage=" [args]") - async def _playlist_append( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - query: LazyGreedyConverter, - *, - scope_data: ScopeParser = None, - ): - """Add a track URL, playlist link, or quick search to a playlist. - - The track(s) will be appended to the end of the playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist append playlist_name_OR_id track_name_OR_url [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist append MyGuildPlaylist Hello by Adele` - ​ ​ ​ ​ `[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global` - ​ ​ ​ ​ `[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - (scope, author, guild, specified_user) = scope_data - if not await self._playlist_check(ctx): - return - try: - (playlist_id, playlist_arg, scope) = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - return await self._embed_msg(ctx, title=str(e)) - if playlist_id is None: - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), - ) - - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - return await self._embed_msg( - ctx, - title=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): - return - player = lavalink.get_player(ctx.guild.id) - to_append = await self._playlist_tracks( - ctx, player, audio_dataclasses.Query.process_input(query) - ) - - if isinstance(to_append, discord.Message): - return None - - if not to_append: - return await self._embed_msg( - ctx, title=_("Could not find a track matching your query.") - ) - track_list = playlist.tracks - current_count = len(track_list) - to_append_count = len(to_append) - tracks_obj_list = playlist.tracks_obj - not_added = 0 - if current_count + to_append_count > 10000: - to_append = to_append[: 10000 - current_count] - not_added = to_append_count - len(to_append) - to_append_count = len(to_append) - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - appended = 0 - - if to_append and to_append_count == 1: - to = lavalink.Track(to_append[0]) - if to in tracks_obj_list: - return await self._embed_msg( - ctx, - title=_("Skipping track"), - description=_( - "{track} is already in {playlist} (`{id}`) [**{scope}**]." - ).format( - track=to.title, playlist=playlist.name, id=playlist.id, scope=scope_name - ), - footer=_("Playlist limit reached: Could not add track.").format(not_added) - if not_added > 0 - else None, - ) - else: - appended += 1 - if to_append and to_append_count > 1: - to_append_temp = [] - for t in to_append: - to = lavalink.Track(t) - if to not in tracks_obj_list: - appended += 1 - to_append_temp.append(t) - to_append = to_append_temp - if appended > 0: - track_list.extend(to_append) - update = {"tracks": track_list, "url": None} - await playlist.edit(update) - - if to_append_count == 1 and appended == 1: - track_title = to_append[0]["info"]["title"] - return await self._embed_msg( - ctx, - title=_("Track added"), - description=_("{track} appended to {playlist} (`{id}`) [**{scope}**].").format( - track=track_title, playlist=playlist.name, id=playlist.id, scope=scope_name - ), - ) - - desc = _("{num} tracks appended to {playlist} (`{id}`) [**{scope}**].").format( - num=appended, playlist=playlist.name, id=playlist.id, scope=scope_name - ) - if to_append_count > appended: - diff = to_append_count - appended - desc += _("\n{existing} {plural} already in the playlist and were skipped.").format( - existing=diff, plural=_("tracks are") if diff != 1 else _("track is") - ) - - embed = discord.Embed(title=_("Playlist Modified"), description=desc) - await self._embed_msg( - ctx, - embed=embed, - footer=_("Playlist limit reached: Could not add track.").format(not_added) - if not_added > 0 - else None, - ) - - @commands.cooldown(1, 150, commands.BucketType.member) - @playlist.command(name="copy", usage=" [args]", cooldown_after_parsing=True) - async def _playlist_copy( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ComplexScopeParser = None, - ): - - """Copy a playlist from one scope to another. - - **Usage**: - ​ ​ ​ ​ `[p]playlist copy playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --from-scope - ​ ​ ​ ​ ​ ​ ​ ​ --from-author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --from-guild [guild] **Only the bot owner can use this** - - ​ ​ ​ ​ ​ ​ ​ ​ --to-scope - ​ ​ ​ ​ ​ ​ ​ ​ --to-author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --to-guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global` - ​ ​ ​ ​ `[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 --to-scope User` - ​ ​ ​ ​ `[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666 --to-scope Guild --to-guild Red - Discord Bot` - """ - - if scope_data is None: - scope_data = [ - PlaylistScope.GUILD.value, - ctx.author, - ctx.guild, - False, - PlaylistScope.GUILD.value, - ctx.author, - ctx.guild, - False, - ] - ( - from_scope, - from_author, - from_guild, - specified_from_user, - to_scope, - to_author, - to_guild, - specified_to_user, - ) = scope_data - - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - - temp_playlist = FakePlaylist(to_author.id, to_scope) - if not await self.can_manage_playlist(to_scope, temp_playlist, ctx, to_author, to_guild): - ctx.command.reset_cooldown(ctx) - return - - try: - from_playlist = await get_playlist( - playlist_id, from_scope, self.bot, from_guild, from_author.id - ) - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(to_scope, the=True) - ), - ) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, title=_("You need to specify the Guild ID for the guild to lookup.") - ) - - to_playlist = await create_playlist( - ctx, - to_scope, - from_playlist.name, - from_playlist.url, - from_playlist.tracks, - to_author, - to_guild, - ) - if to_scope == PlaylistScope.GLOBAL.value: - to_scope_name = "the Global" - elif to_scope == PlaylistScope.USER.value: - to_scope_name = to_author - else: - to_scope_name = to_guild - - if from_scope == PlaylistScope.GLOBAL.value: - from_scope_name = "the Global" - elif from_scope == PlaylistScope.USER.value: - from_scope_name = from_author - else: - from_scope_name = from_guild - - return await self._embed_msg( - ctx, - title=_("Playlist Copied"), - description=_( - "Playlist {name} (`{from_id}`) copied from {from_scope} to {to_scope} (`{to_id}`)." - ).format( - name=from_playlist.name, - from_id=from_playlist.id, - from_scope=humanize_scope(from_scope, ctx=from_scope_name), - to_scope=humanize_scope(to_scope, ctx=to_scope_name), - to_id=to_playlist.id, - ), - ) - - @playlist.command(name="create", usage=" [args]") - async def _playlist_create( - self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None - ): - """Create an empty playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist create playlist_name [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist create MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist create MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist create MyPersonalPlaylist --scope User` - """ - if scope_data is None: - scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - - temp_playlist = FakePlaylist(author.id, scope) - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): - return - playlist_name = playlist_name.split(" ")[0].strip('"')[:32] - if playlist_name.isnumeric(): - return await self._embed_msg( - ctx, - title=_("Invalid Playlist Name"), - description=_( - "Playlist names must be a single word (up to 32 " - "characters) and not numbers only." - ), - ) - playlist = await create_playlist(ctx, scope, playlist_name, None, None, author, guild) - return await self._embed_msg( - ctx, - title=_("Playlist Created"), - description=_("Empty playlist {name} (`{id}`) [**{scope}**] created.").format( - name=playlist.name, id=playlist.id, scope=scope_name - ), - ) - - @playlist.command(name="delete", aliases=["del"], usage=" [args]") - async def _playlist_delete( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ScopeParser = None, - ): - """Delete a saved playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist delete playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist delete MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist delete MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist delete MyPersonalPlaylist --scope User` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - return await self._embed_msg(ctx, title=str(e)) - if playlist_id is None: - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - return await self._embed_msg( - ctx, - title=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - return await self._embed_msg( - ctx, title=_("You need to specify the Guild ID for the guild to lookup.") - ) - - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): - return - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - await delete_playlist(scope, playlist.id, guild or ctx.guild, author or ctx.author) - - await self._embed_msg( - ctx, - title=_("Playlist Deleted"), - description=_("{name} (`{id}`) [**{scope}**] playlist deleted.").format( - name=playlist.name, id=playlist.id, scope=scope_name - ), - ) - - @commands.cooldown(1, 30, commands.BucketType.member) - @playlist.command( - name="dedupe", usage=" [args]", cooldown_after_parsing=True - ) - async def _playlist_remdupe( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ScopeParser = None, - ): - """Remove duplicate tracks from a saved playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist dedupe playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist dedupe MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist dedupe MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist dedupe MyPersonalPlaylist --scope User` - """ - async with ctx.typing(): - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format( - arg=playlist_arg - ), - ) - - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): - ctx.command.reset_cooldown(ctx) - return - - track_objects = playlist.tracks_obj - original_count = len(track_objects) - unique_tracks = set() - unique_tracks_add = unique_tracks.add - track_objects = [ - x for x in track_objects if not (x in unique_tracks or unique_tracks_add(x)) - ] - - tracklist = [] - for track in track_objects: - 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 - tracklist.append(track_obj) - - final_count = len(tracklist) - if original_count - final_count != 0: - await self._embed_msg( - ctx, - title=_("Playlist Modified"), - description=_( - "Removed {track_diff} duplicated " - "tracks from {name} (`{id}`) [**{scope}**] playlist." - ).format( - name=playlist.name, - id=playlist.id, - track_diff=original_count - final_count, - scope=scope_name, - ), - ) - else: - await self._embed_msg( - ctx, - title=_("Playlist Has Not Been Modified"), - description=_( - "{name} (`{id}`) [**{scope}**] playlist has no duplicate tracks." - ).format(name=playlist.name, id=playlist.id, scope=scope_name), - ) - - @checks.is_owner() - @playlist.command( - name="download", - usage=" [v2=False] [args]", - cooldown_after_parsing=True, - ) - @commands.bot_has_permissions(attach_files=True) - @commands.cooldown(1, 30, commands.BucketType.guild) - async def _playlist_download( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - v2: Optional[bool] = False, - *, - scope_data: ScopeParser = None, - ): - """Download a copy of a playlist. - - These files can be used with the `[p]playlist upload` command. - Red v2-compatible playlists can be generated by passing True - for the v2 variable. - - **Usage**: - ​ ​ ​ ​ `[p]playlist download playlist_name_OR_id [v2=True_OR_False] [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist download MyGuildPlaylist True` - ​ ​ ​ ​ `[p]playlist download MyGlobalPlaylist False --scope Global` - ​ ​ ​ ​ `[p]playlist download MyPersonalPlaylist --scope User` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - - schema = 2 - version = "v3" if v2 is False else "v2" - - if not playlist.tracks: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=_("That playlist has no tracks.")) - if version == "v2": - v2_valid_urls = ["https://www.youtube.com/watch?v=", "https://soundcloud.com/"] - song_list = [] - for track in playlist.tracks: - if track["info"]["uri"].startswith(tuple(v2_valid_urls)): - song_list.append(track["info"]["uri"]) - await asyncio.sleep(0) - playlist_data = { - "author": playlist.author, - "link": playlist.url, - "playlist": song_list, - "name": playlist.name, - } - file_name = playlist.name - else: - # TODO: Keep new playlists backwards compatible, Remove me in a few releases - playlist_data = playlist.to_json() - playlist_songs_backwards_compatible = [ - track["info"]["uri"] for track in playlist.tracks - ] - playlist_data["playlist"] = playlist_songs_backwards_compatible - playlist_data["link"] = playlist.url - file_name = playlist.id - playlist_data.update({"schema": schema, "version": version}) - playlist_data = json.dumps(playlist_data).encode("utf-8") - to_write = BytesIO() - to_write.write(playlist_data) - to_write.seek(0) - if to_write.getbuffer().nbytes > ctx.guild.filesize_limit - 10000: - datapath = cog_data_path(raw_name="Audio") - temp_file = datapath / f"{file_name}.txt" - temp_tar = datapath / f"{file_name}.tar.gz" - with temp_file.open("wb") as playlist_file: - playlist_file.write(to_write.read()) - - with tarfile.open(str(temp_tar), "w:gz") as tar: - tar.add( - str(temp_file), arcname=str(temp_file.relative_to(datapath)), recursive=False - ) - try: - if os.path.getsize(str(temp_tar)) > ctx.guild.filesize_limit - 10000: - await ctx.send(_("This playlist is too large to be send in this server.")) - else: - await ctx.send( - content=_("Playlist is too large, here is the compressed version."), - file=discord.File(str(temp_tar)), - ) - except Exception: - pass - temp_file.unlink() - temp_tar.unlink() - else: - await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt")) - to_write.close() - - @commands.cooldown(1, 10, commands.BucketType.member) - @playlist.command( - name="info", usage=" [args]", cooldown_after_parsing=True - ) - async def _playlist_info( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ScopeParser = None, - ): - """Retrieve information from a saved playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist info playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist info MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist info MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist info MyPersonalPlaylist --scope User` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - track_len = len(playlist.tracks) - - msg = "​" - track_idx = 0 - if track_len > 0: - spaces = "\N{EN SPACE}" * (len(str(len(playlist.tracks))) + 2) - for i, track in enumerate(playlist.tracks, start=1): - if i % 500 == 0: # TODO: Improve when Toby menu's are merged - await asyncio.sleep(0.1) - track_idx = track_idx + 1 - query = audio_dataclasses.Query.process_input(track["info"]["uri"]) - if query.is_local: - if track["info"]["title"] != "Unknown title": - msg += "`{}.` **{} - {}**\n{}{}\n".format( - track_idx, - track["info"]["author"], - track["info"]["title"], - spaces, - query.to_string_user(), - ) - else: - msg += "`{}.` {}\n".format(track_idx, query.to_string_user()) - else: - msg += "`{}.` **[{}]({})**\n".format( - track_idx, track["info"]["title"], track["info"]["uri"] - ) - await asyncio.sleep(0) - - else: - msg = "No tracks." - - if not playlist.url: - embed_title = _("Playlist info for {playlist_name} (`{id}`) [**{scope}**]:\n").format( - playlist_name=playlist.name, id=playlist.id, scope=scope_name - ) - else: - embed_title = _( - "Playlist info for {playlist_name} (`{id}`) [**{scope}**]:\nURL: {url}" - ).format( - playlist_name=playlist.name, url=playlist.url, id=playlist.id, scope=scope_name - ) - - page_list = [] - pages = list(pagify(msg, delims=["\n"], page_length=2000)) - total_pages = len(pages) - for numb, page in enumerate(pages, start=1): - embed = discord.Embed( - colour=await ctx.embed_colour(), title=embed_title, description=page - ) - author_obj = self.bot.get_user(playlist.author) or playlist.author or _("Unknown") - embed.set_footer( - text=_("Page {page}/{pages} | Author: {author_name} | {num} track(s)").format( - author_name=author_obj, num=track_len, pages=total_pages, page=numb - ) - ) - page_list.append(embed) - await menu(ctx, page_list, DEFAULT_CONTROLS) - - @commands.cooldown(1, 15, commands.BucketType.guild) - @playlist.command(name="list", usage="[args]", cooldown_after_parsing=True) - @commands.bot_has_permissions(add_reactions=True) - async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None): - """List saved playlists. - - **Usage**: - ​ ​ ​ ​ `[p]playlist list [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist list` - ​ ​ ​ ​ `[p]playlist list --scope Global` - ​ ​ ​ ​ `[p]playlist list --scope User` - """ - if scope_data is None: - scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - - try: - playlists = await get_all_playlist(scope, self.bot, guild, author, specified_user) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - - if scope == PlaylistScope.GUILD.value: - name = f"{guild.name}" - elif scope == PlaylistScope.USER.value: - name = f"{author}" - else: - name = "Global" - - if not playlists and specified_user: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("No saved playlists for {scope} created by {author}.").format( - scope=name, author=author - ), - ) - elif not playlists: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("No saved playlists for {scope}.").format(scope=name), - ) - - playlist_list = [] - space = "\N{EN SPACE}" - for playlist in playlists: - playlist_list.append( - ("\n" + space * 4).join( - ( - bold(playlist.name), - _("ID: {id}").format(id=playlist.id), - _("Tracks: {num}").format(num=len(playlist.tracks)), - _("Author: {name}\n").format( - name=self.bot.get_user(playlist.author) - or playlist.author - or _("Unknown") - ), - ) - ) - ) - await asyncio.sleep(0) - abc_names = sorted(playlist_list, key=str.lower) - len_playlist_list_pages = math.ceil(len(abc_names) / 5) - playlist_embeds = [] - - for page_num in range(1, len_playlist_list_pages + 1): - embed = await self._build_playlist_list_page(ctx, page_num, abc_names, name) - playlist_embeds.append(embed) - await asyncio.sleep(0) - await menu(ctx, playlist_embeds, DEFAULT_CONTROLS) - - @staticmethod - async def _build_playlist_list_page(ctx: commands.Context, page_num, abc_names, scope): - plist_num_pages = math.ceil(len(abc_names) / 5) - plist_idx_start = (page_num - 1) * 5 - plist_idx_end = plist_idx_start + 5 - plist = "" - for i, playlist_info in enumerate( - abc_names[plist_idx_start:plist_idx_end], start=plist_idx_start - ): - item_idx = i + 1 - plist += "`{}.` {}".format(item_idx, playlist_info) - await asyncio.sleep(0) - 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 - - @playlist.command(name="queue", usage=" [args]", cooldown_after_parsing=True) - @commands.cooldown(1, 300, commands.BucketType.member) - async def _playlist_queue( - self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None - ): - """Save the queue to a playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist queue playlist_name [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist queue MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist queue MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist queue MyPersonalPlaylist --scope User` - """ - async with ctx.typing(): - if scope_data is None: - scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - temp_playlist = FakePlaylist(author.id, scope) - if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): - ctx.command.reset_cooldown(ctx) - return - playlist_name = playlist_name.split(" ")[0].strip('"')[:32] - if playlist_name.isnumeric(): - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Invalid Playlist Name"), - description=_( - "Playlist names must be a single word " - "(up to 32 characters) and not numbers only." - ), - ) - if not self._player_check(ctx): - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=_("Nothing playing.")) - - player = lavalink.get_player(ctx.guild.id) - if not player.queue: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) - tracklist = [] - np_song = track_creator(player, "np") - tracklist.append(np_song) - queue_length = len(player.queue) - to_add = player.queue - not_added = 0 - if queue_length > 10000: - to_add = player.queue[:10000] - not_added = queue_length - 10000 - - for i, track in enumerate(to_add, start=1): - if i % 500 == 0: # TODO: Improve when Toby menu's are merged - await asyncio.sleep(0.02) - queue_idx = player.queue.index(track) - track_obj = track_creator(player, queue_idx) - tracklist.append(track_obj) - playlist = await create_playlist( - ctx, scope, playlist_name, None, tracklist, author, guild - ) - await asyncio.sleep(0) - await self._embed_msg( - ctx, - title=_("Playlist Created"), - description=_( - "Playlist {name} (`{id}`) [**{scope}**] " - "saved from current queue: {num} tracks added." - ).format( - name=playlist.name, num=len(playlist.tracks), id=playlist.id, scope=scope_name - ), - footer=_("Playlist limit reached: Could not add {} tracks.").format(not_added) - if not_added > 0 - else None, - ) - - @playlist.command(name="remove", usage=" [args]") - async def _playlist_remove( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - url: str, - *, - scope_data: ScopeParser = None, - ): - """Remove a track from a playlist by url. - - **Usage**: - ​ ​ ​ ​ `[p]playlist remove playlist_name_OR_id url [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU` - ​ ​ ​ ​ `[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope Global` - ​ ​ ​ ​ `[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - return await self._embed_msg(ctx, title=str(e)) - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - if playlist_id is None: - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): - return - - track_list = playlist.tracks - clean_list = [track for track in track_list if url != track["info"]["uri"]] - if len(track_list) == len(clean_list): - return await self._embed_msg(ctx, title=_("URL not in playlist.")) - del_count = len(track_list) - len(clean_list) - if not clean_list: - await delete_playlist( - scope=playlist.scope, playlist_id=playlist.id, guild=guild, author=playlist.author - ) - return await self._embed_msg(ctx, title=_("No tracks left, removing playlist.")) - update = {"tracks": clean_list, "url": None} - await playlist.edit(update) - if del_count > 1: - await self._embed_msg( - ctx, - title=_("Playlist Modified"), - description=_( - "{num} entries have been removed " - "from the playlist {playlist_name} (`{id}`) [**{scope}**]." - ).format( - num=del_count, playlist_name=playlist.name, id=playlist.id, scope=scope_name - ), - ) - else: - await self._embed_msg( - ctx, - title=_("Playlist Modified"), - description=_( - "The track has been removed from the playlist: " - "{playlist_name} (`{id}`) [**{scope}**]." - ).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name), - ) - - @playlist.command(name="save", usage=" [args]", cooldown_after_parsing=True) - @commands.cooldown(1, 60, commands.BucketType.member) - async def _playlist_save( - self, - ctx: commands.Context, - playlist_name: str, - playlist_url: str, - *, - scope_data: ScopeParser = None, - ): - """Save a playlist from a url. - - **Usage**: - ​ ​ ​ ​ `[p]playlist save name url [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist save MyGuildPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM` - ​ ​ ​ ​ `[p]playlist save MyGlobalPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global` - ​ ​ ​ ​ `[p]playlist save MyPersonalPlaylist https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User` - """ - if scope_data is None: - scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - - temp_playlist = FakePlaylist(author.id, scope) - if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): - return ctx.command.reset_cooldown(ctx) - playlist_name = playlist_name.split(" ")[0].strip('"')[:32] - if playlist_name.isnumeric(): - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Invalid Playlist Name"), - description=_( - "Playlist names must be a single word (up to 32 " - "characters) and not numbers only." - ), - ) - if not await self._playlist_check(ctx): - ctx.command.reset_cooldown(ctx) - return - player = lavalink.get_player(ctx.guild.id) - tracklist = await self._playlist_tracks( - ctx, player, audio_dataclasses.Query.process_input(playlist_url) - ) - if isinstance(tracklist, discord.Message): - return None - if tracklist is not None: - playlist_length = len(tracklist) - not_added = 0 - if playlist_length > 10000: - tracklist = tracklist[:10000] - not_added = playlist_length - 10000 - - playlist = await create_playlist( - ctx, scope, playlist_name, playlist_url, tracklist, author, guild - ) - return await self._embed_msg( - ctx, - title=_("Playlist Created"), - description=_( - "Playlist {name} (`{id}`) [**{scope}**] saved: {num} tracks added." - ).format(name=playlist.name, num=len(tracklist), id=playlist.id, scope=scope_name), - footer=_("Playlist limit reached: Could not add {} tracks.").format(not_added) - if not_added > 0 - else None, - ) - - @commands.cooldown(1, 30, commands.BucketType.member) - @playlist.command( - name="start", - aliases=["play"], - usage=" [args]", - cooldown_after_parsing=True, - ) - async def _playlist_start( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ScopeParser = None, - ): - """Load a playlist into the queue. - - **Usage**: - ​ ​ ​ ​` [p]playlist start playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist start MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist start MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist start MyPersonalPlaylist --scope User` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - dj_enabled = self._dj_status_cache.setdefault( - ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - ctx.command.reset_cooldown(ctx) - await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("You need the DJ role to start playing playlists."), - ) - return False - - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), - ) - - if not await self._playlist_check(ctx): - ctx.command.reset_cooldown(ctx) - return - jukebox_price = await self.config.guild(ctx.guild).jukebox_price() - if not await self._currency_check(ctx, jukebox_price): - ctx.command.reset_cooldown(ctx) - return - maxlength = await self.config.guild(ctx.guild).maxlength() - author_obj = self.bot.get_user(ctx.author.id) - track_len = 0 - playlist = None - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - player = lavalink.get_player(ctx.guild.id) - tracks = playlist.tracks_obj - empty_queue = not player.queue - for i, track in enumerate(tracks, start=1): - if i % 500 == 0: # TODO: Improve when Toby menu's are merged - await asyncio.sleep(0.02) - if len(player.queue) >= 10000: - continue - if not await is_allowed( - ctx.guild, - ( - f"{track.title} {track.author} {track.uri} " - f"{str(audio_dataclasses.Query.process_input(track))}" - ), - ): - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") - continue - query = audio_dataclasses.Query.process_input(track.uri) - if query.is_local: - local_path = audio_dataclasses.LocalPath(track.uri) - if not await self._localtracks_check(ctx): - pass - if not local_path.exists() and not local_path.is_file(): - continue - if maxlength > 0: - if not track_limit(track.length, maxlength): - continue - - player.add(author_obj, track) - self.bot.dispatch( - "red_audio_track_enqueue", player.channel.guild, track, ctx.author - ) - track_len += 1 - await asyncio.sleep(0) - 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 = "" - if scope == PlaylistScope.GUILD.value: - scope_name = f"{guild.name}" - elif scope == PlaylistScope.USER.value: - scope_name = f"{author}" - else: - scope_name = "Global" - - embed = discord.Embed( - title=_("Playlist Enqueued"), - description=_( - "{name} - (`{id}`) [**{scope}**]\nAdded {num} " - "tracks to the queue.{maxlength_msg}" - ).format( - num=track_len, - maxlength_msg=maxlength_msg, - name=playlist.name, - id=playlist.id, - scope=scope_name, - ), - ) - await self._embed_msg(ctx, embed=embed) - if not player.current: - await player.play() - return - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - except TypeError: - if playlist: - return await ctx.invoke(self.play, query=playlist.url) - - @commands.cooldown(1, 60, commands.BucketType.member) - @playlist.command( - name="update", usage=" [args]", cooldown_after_parsing=True - ) - async def _playlist_update( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - *, - scope_data: ScopeParser = None, - ): - """Updates all tracks in a playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist update playlist_name_OR_id [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist update MyGuildPlaylist` - ​ ​ ​ ​ `[p]playlist update MyGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist update MyPersonalPlaylist --scope User` - """ - - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - - if not await self._playlist_check(ctx): - ctx.command.reset_cooldown(ctx) - return - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): - return - if playlist.url: - player = lavalink.get_player(ctx.guild.id) - added, removed, playlist = await self._maybe_update_playlist(ctx, player, playlist) - else: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Invalid Playlist"), - description=_("Custom playlists cannot be updated."), - ) - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - else: - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - if added or removed: - _colour = await ctx.embed_colour() - removed_embeds = [] - added_embeds = [] - total_added = len(added) - total_removed = len(removed) - total_pages = math.ceil(total_removed / 10) + math.ceil(total_added / 10) - page_count = 0 - if removed: - removed_text = "" - for i, track in enumerate(removed, 1): - if len(track.title) > 40: - track_title = str(track.title).replace("[", "") - track_title = "{}...".format((track_title[:40]).rstrip(" ")) - else: - track_title = track.title - removed_text += f"`{i}.` **[{track_title}]({track.uri})**\n" - if i % 10 == 0 or i == total_removed: - page_count += 1 - embed = discord.Embed( - title=_("Tracks removed"), colour=_colour, description=removed_text - ) - text = _("Page {page_num}/{total_pages}").format( - page_num=page_count, total_pages=total_pages - ) - embed.set_footer(text=text) - removed_embeds.append(embed) - removed_text = "" - if added: - added_text = "" - for i, track in enumerate(added, 1): - if len(track.title) > 40: - track_title = str(track.title).replace("[", "") - track_title = "{}...".format((track_title[:40]).rstrip(" ")) - else: - track_title = track.title - added_text += f"`{i}.` **[{track_title}]({track.uri})**\n" - if i % 10 == 0 or i == total_added: - page_count += 1 - embed = discord.Embed( - title=_("Tracks added"), colour=_colour, description=added_text - ) - text = _("Page {page_num}/{total_pages}").format( - page_num=page_count, total_pages=total_pages - ) - embed.set_footer(text=text) - added_embeds.append(embed) - added_text = "" - embeds = removed_embeds + added_embeds - await menu(ctx, embeds, DEFAULT_CONTROLS) - else: - return await self._embed_msg( - ctx, - title=_("Playlist Has Not Been Modified"), - description=_("No changes for {name} (`{id}`) [**{scope}**].").format( - id=playlist.id, name=playlist.name, scope=scope_name - ), - ) - - @checks.is_owner() - @playlist.command(name="upload", usage="[args]") - async def _playlist_upload(self, ctx: commands.Context, *, scope_data: ScopeParser = None): - """Uploads a playlist file as a playlist for the bot. - - V2 and old V3 playlist will be slow. - V3 Playlist made with `[p]playlist download` will load a lot faster. - - **Usage**: - ​ ​ ​ ​ `[p]playlist upload [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist upload` - ​ ​ ​ ​ `[p]playlist upload --scope Global` - ​ ​ ​ ​ `[p]playlist upload --scope User` - """ - if scope_data is None: - scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - temp_playlist = FakePlaylist(author.id, scope) - if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): - return - - if not await self._playlist_check(ctx): - return - player = lavalink.get_player(ctx.guild.id) - - await self._embed_msg( - ctx, - title=_( - "Please upload the playlist file. Any other message will cancel this operation." - ), - ) - - try: - file_message = await ctx.bot.wait_for( - "message", timeout=30.0, check=MessagePredicate.same_context(ctx) - ) - except asyncio.TimeoutError: - return await self._embed_msg(ctx, title=_("No file detected, try again later.")) - try: - file_url = file_message.attachments[0].url - except IndexError: - return await self._embed_msg(ctx, title=_("Upload cancelled.")) - file_suffix = file_url.rsplit(".", 1)[1] - if file_suffix != "txt": - return await self._embed_msg(ctx, title=_("Only Red playlist files can be uploaded.")) - try: - async with self.session.request("GET", file_url) as r: - uploaded_playlist = await r.json(content_type="text/plain", encoding="utf-8") - except UnicodeDecodeError: - return await self._embed_msg(ctx, title=_("Not a valid playlist file.")) - - new_schema = uploaded_playlist.get("schema", 1) >= 2 - version = uploaded_playlist.get("version", "v2") - - if new_schema and version == "v3": - uploaded_playlist_url = uploaded_playlist.get("playlist_url", None) - track_list = uploaded_playlist.get("tracks", []) - else: - uploaded_playlist_url = uploaded_playlist.get("link", None) - track_list = uploaded_playlist.get("playlist", []) - if len(track_list) > 10000: - return await self._embed_msg(ctx, title=_("This playlist is too large.")) - uploaded_playlist_name = uploaded_playlist.get( - "name", (file_url.split("/")[6]).split(".")[0] - ) - if ( - not uploaded_playlist_url - or not match_yt_playlist(uploaded_playlist_url) - or not ( - await self.music_cache.lavalink_query( - ctx, player, audio_dataclasses.Query.process_input(uploaded_playlist_url) - ) - )[0].tracks - ): - if version == "v3": - return await self._load_v3_playlist( - ctx, - scope, - uploaded_playlist_name, - uploaded_playlist_url, - track_list, - author, - guild, - ) - return await self._load_v2_playlist( - ctx, - track_list, - player, - uploaded_playlist_url, - uploaded_playlist_name, - scope, - author, - guild, - ) - return await ctx.invoke( - self._playlist_save, - playlist_name=uploaded_playlist_name, - playlist_url=uploaded_playlist_url, - scope_data=(scope, author, guild, specified_user), - ) - - @commands.cooldown(1, 60, commands.BucketType.member) - @playlist.command( - name="rename", usage=" [args]", cooldown_after_parsing=True - ) - async def _playlist_rename( - self, - ctx: commands.Context, - playlist_matches: PlaylistConverter, - new_name: str, - *, - scope_data: ScopeParser = None, - ): - """Rename an existing playlist. - - **Usage**: - ​ ​ ​ ​ `[p]playlist rename playlist_name_OR_id new_name [args]` - - **Args**: - ​ ​ ​ ​ The following are all optional: - ​ ​ ​ ​ ​ ​ ​ ​ --scope - ​ ​ ​ ​ ​ ​ ​ ​ --author [user] - ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - - **Scope** is one of the following: - ​ ​ ​ ​ Global - ​ ​ ​ ​ Guild - ​ ​ ​ ​ User - - **Author** can be one of the following: - ​ ​ ​ ​ User ID - ​ ​ ​ ​ User Mention - ​ ​ ​ ​ User Name#123 - - **Guild** can be one of the following: - ​ ​ ​ ​ Guild ID - ​ ​ ​ ​ Exact guild name - - Example use: - ​ ​ ​ ​ `[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist` - ​ ​ ​ ​ `[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global` - ​ ​ ​ ​ `[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User` - """ - if scope_data is None: - scope_data = [None, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data - - new_name = new_name.split(" ")[0].strip('"')[:32] - if new_name.isnumeric(): - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Invalid Playlist Name"), - description=_( - "Playlist names must be a single word (up to 32 " - "characters) and not numbers only." - ), - ) - - try: - playlist_id, playlist_arg, scope = await self._get_correct_playlist_id( - ctx, playlist_matches, scope, author, guild, specified_user - ) - except TooManyMatches as e: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, title=str(e)) - if playlist_id is None: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), - ) - - try: - playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) - except RuntimeError: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Playlist Not Found"), - description=_("Playlist does not exist in {scope} scope.").format( - scope=humanize_scope(scope, the=True) - ), - ) - except MissingGuild: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Missing Arguments"), - description=_("You need to specify the Guild ID for the guild to lookup."), - ) - - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): - ctx.command.reset_cooldown(ctx) - return - scope_name = humanize_scope( - scope, ctx=guild if scope == PlaylistScope.GUILD.value else author - ) - old_name = playlist.name - update = {"name": new_name} - await playlist.edit(update) - msg = _("'{old}' playlist has been renamed to '{new}' (`{id}`) [**{scope}**]").format( - old=bold(old_name), new=bold(playlist.name), id=playlist.id, scope=scope_name - ) - await self._embed_msg(ctx, title=_("Playlist Modified"), description=msg) - - async def _load_v3_playlist( - self, - ctx: commands.Context, - scope: str, - uploaded_playlist_name: str, - uploaded_playlist_url: str, - track_list, - author: Union[discord.User, discord.Member], - guild: Union[discord.Guild], - ): - embed1 = discord.Embed(title=_("Please wait, adding tracks...")) - playlist_msg = await self._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, scope, uploaded_playlist_name, uploaded_playlist_url, track_list, author, guild - ) - scope_name = 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()) - for t in 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.music_cache.database.insert("lavalink", 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 = [] - track_count = 0 - successful_count = 0 - uploaded_track_count = len(uploaded_track_list) - - embed1 = discord.Embed(title=_("Please wait, adding tracks...")) - playlist_msg = await self._embed_msg(ctx, embed=embed1) - notifier = Notifier(ctx, playlist_msg, {"playlist": _("Loading track {num}/{total}...")}) - for song_url in uploaded_track_list: - track_count += 1 - try: - try: - result, called_api = await self.music_cache.lavalink_query( - ctx, player, audio_dataclasses.Query.process_input(song_url) - ) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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 - except Exception: - continue - try: - track_obj = track_creator(player, other_track=track[0]) - track_list.append(track_obj) - successful_count += 1 - except Exception: - 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, scope, uploaded_playlist_name, playlist_url, track_list, author, guild - ) - scope_name = 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._playlist_tracks( - ctx, player, audio_dataclasses.Query.process_input(playlist.url) - ) - 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): - if not self._player_check(ctx): - if self._connection_aborted: - msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - await self._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 userlimit(ctx.author.voice.channel) - ): - await self._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._embed_msg( - ctx, - title=_("Unable To Get Playlists"), - description=_("Connection to Lavalink has not yet been established."), - ) - return False - except AttributeError: - await self._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._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._data_check(ctx) - return True - - async def _playlist_tracks( - self, - ctx: commands.Context, - player: lavalink.player_manager.Player, - query: audio_dataclasses.Query, - ): - search = query.is_search - tracklist = [] - - if query.is_spotify: - try: - if self.play_lock[ctx.message.guild.id]: - return await self._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) - - if isinstance(tracks, discord.Message): - return None - - if not tracks: - embed = discord.Embed(title=_("Nothing found.")) - if ( - query.is_local - and query.suffix in audio_dataclasses._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._embed_msg(ctx, embed=embed) - for track in tracks: - track_obj = track_creator(player, other_track=track) - tracklist.append(track_obj) - await asyncio.sleep(0) - self._play_lock(ctx, False) - elif query.is_search: - try: - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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 audio_dataclasses._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._embed_msg(ctx, embed=embed) - else: - try: - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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: - for track in tracks: - track_obj = track_creator(player, other_track=track) - tracklist.append(track_obj) - await asyncio.sleep(0) - elif len(tracklist) == 0: - track_obj = track_creator(player, other_track=tracks[0]) - tracklist.append(track_obj) - return tracklist - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def prev(self, ctx: commands.Context): - """Skip to the start of the previously played track.""" - if not self._player_check(ctx): - return await self._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_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._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: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._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: - if not (can_skip or is_requester) and not is_alone: - return await self._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._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 = get_track_description(player.current) - embed = discord.Embed(title=_("Replaying Track"), description=description) - await self._embed_msg(ctx, embed=embed) - - @commands.group(invoke_without_command=True) - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def 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.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._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 draw_time(ctx) - pos = lavalink.utils.format_time(player.position) - if player.current.is_stream: - dur = "LIVE" - else: - dur = lavalink.utils.format_time(player.current.length) - song = get_track_description(player.current) - 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) - if await self.config.guild(ctx.guild).thumbnail() and player.current: - if player.current.thumbnail: - embed.set_thumbnail(url=player.current.thumbnail) - - 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() - 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._embed_msg(ctx, embed=embed) - 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: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( - ctx - ): - return - - expected = ("⏹", "⏯") - emoji = {"stop": "⏹", "pause": "⏯"} - if player.current: - task = start_adding_reactions(message, expected[:4]) - 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 == "stop": - await self._clear_react(message, emoji) - return await ctx.invoke(self.stop) - elif react == "pause": - await self._clear_react(message, emoji) - return await ctx.invoke(self.pause) - return - elif not player.current and not player.queue: - return await self._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 = [] - for page_num in range(1, len_queue_pages + 1): - embed = await self._build_queue_page(ctx, limited_queue, player, page_num) - queue_page_list.append(embed) - await asyncio.sleep(0) - if page > len_queue_pages: - page = len_queue_pages - return await menu(ctx, queue_page_list, queue_controls, page=(page - 1)) - - async def _build_queue_page( - self, ctx: commands.Context, queue: list, player: lavalink.player_manager.Player, page_num - ): - 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 = "" - - try: - arrow = await draw_time(ctx) - except AttributeError: - return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) - pos = lavalink.utils.format_time(player.position) - - if player.current.is_stream: - dur = "LIVE" - else: - dur = lavalink.utils.format_time(player.current.length) - - query = audio_dataclasses.Query.process_input(player.current) - - if query.is_stream: - queue_list += _("**Currently livestreaming:**\n") - queue_list += "**[{current.title}]({current.uri})**\n".format(current=player.current) - queue_list += _("Requested by: **{user}**").format(user=player.current.requester) - queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n" - - elif query.is_local: - if player.current.title != "Unknown title": - queue_list += "\n".join( - ( - _("Playing: ") - + "**{current.author} - {current.title}**".format(current=player.current), - audio_dataclasses.LocalPath(player.current.uri).to_string_user(), - _("Requested by: **{user}**\n").format(user=player.current.requester), - f"{arrow}`{pos}`/`{dur}`\n\n", - ) - ) - else: - queue_list += "\n".join( - ( - _("Playing: ") - + audio_dataclasses.LocalPath(player.current.uri).to_string_user(), - _("Requested by: **{user}**\n").format(user=player.current.requester), - f"{arrow}`{pos}`/`{dur}`\n\n", - ) - ) - else: - queue_list += _("Playing: ") - queue_list += "**[{current.title}]({current.uri})**\n".format(current=player.current) - queue_list += _("Requested by: **{user}**").format(user=player.current.requester) - queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n" - - for i, track in enumerate(queue[queue_idx_start:queue_idx_end], start=queue_idx_start): - if i % 100 == 0: # TODO: Improve when Toby menu's are merged - await asyncio.sleep(0.1) - - if len(track.title) > 40: - track_title = str(track.title).replace("[", "") - track_title = "{}...".format((track_title[:40]).rstrip(" ")) - else: - track_title = track.title - req_user = track.requester - track_idx = i + 1 - query = audio_dataclasses.Query.process_input(track) - - if query.is_local: - if track.title == "Unknown title": - queue_list += f"`{track_idx}.` " + ", ".join( - ( - bold(audio_dataclasses.LocalPath(track.uri).to_string_user()), - _("requested by **{user}**\n").format(user=req_user), - ) - ) - else: - queue_list += f"`{track_idx}.` **{track.author} - {track_title}**, " + _( - "requested by **{user}**\n" - ).format(user=req_user) - else: - queue_list += f"`{track_idx}.` **[{track_title}]({track.uri})**, " - queue_list += _("requested by **{user}**\n").format(user=req_user) - await asyncio.sleep(0) - - embed = discord.Embed( - colour=await ctx.embed_colour(), - title="Queue for __{guild.name}__".format(guild=ctx.guild), - 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 queue_duration(ctx) - queue_total_duration = lavalink.utils.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 - - @staticmethod - async def _build_queue_search_list(queue_list, search_words): - track_list = [] - queue_idx = 0 - for i, track in enumerate(queue_list, start=1): - if i % 100 == 0: # TODO: Improve when Toby menu's are merged - await asyncio.sleep(0.1) - queue_idx = queue_idx + 1 - if not match_url(track.uri): - query = audio_dataclasses.Query.process_input(track) - if track.title == "Unknown title": - track_title = query.track.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) - await asyncio.sleep(0) - search_results = process.extract(search_words, track_list, limit=50) - search_list = [] - for search, percent_match in search_results: - for queue_position, title in search.items(): - if percent_match > 89: - search_list.append([queue_position, title]) - return search_list - - @staticmethod - async def _build_queue_search_page(ctx: commands.Context, page_num, search_list): - 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 = "" - for i, track in enumerate( - search_list[search_idx_start:search_idx_end], start=search_idx_start - ): - if i % 100 == 0: # TODO: Improve when Toby menu's are merged - await asyncio.sleep(0.1) - track_idx = i + 1 - if type(track) is str: - track_location = audio_dataclasses.LocalPath(track).to_string_user() - track_match += "`{}.` **{}**\n".format(track_idx, track_location) - else: - track_match += "`{}.` **{}**\n".format(track[0], track[1]) - await asyncio.sleep(0) - 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 - - @queue.command(name="clear") - @commands.guild_only() - async def _queue_clear(self, ctx: commands.Context): - """Clears the queue.""" - try: - player = lavalink.get_player(ctx.guild.id) - except KeyError: - return await self._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._embed_msg(ctx, title=_("There's nothing in the queue.")) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg( - ctx, - title=_("Unable To Clear Queue"), - description=_("You need the DJ role to clear the queue."), - ) - player.queue.clear() - await self._embed_msg( - ctx, title=_("Queue Modified"), description=_("The queue has been cleared.") - ) - - @queue.command(name="clean") - @commands.guild_only() - async def _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._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._embed_msg(ctx, title=_("There's nothing in the queue.")) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._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 - for track in 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._embed_msg(ctx, title=_("Removed 0 tracks.")) - else: - await self._embed_msg( - ctx, - title=_("Removed racks from the queue"), - description=_( - "Removed {removed_tracks} tracks queued by members " - "outside of the voice channel." - ).format(removed_tracks=removed_tracks), - ) - - @queue.command(name="cleanself") - @commands.guild_only() - async def _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._embed_msg(ctx, title=_("There's nothing in the queue.")) - if not self._player_check(ctx) or not player.queue: - return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) - - clean_tracks = [] - removed_tracks = 0 - for track in 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._embed_msg(ctx, title=_("Removed 0 tracks.")) - else: - await self._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), - ) - - @queue.command(name="search") - @commands.guild_only() - async def _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._embed_msg(ctx, title=_("There's nothing in the queue.")) - if not self._player_check(ctx) or not player.queue: - return await self._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._embed_msg(ctx, title=_("No matches.")) - - len_search_pages = math.ceil(len(search_list) / 10) - search_page_list = [] - for page_num in 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) - - @queue.command(name="shuffle") - @commands.guild_only() - @commands.cooldown(1, 30, commands.BucketType.guild) - async def _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: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - ctx.command.reset_cooldown(ctx) - return await self._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._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 userlimit(ctx.author.voice.channel) - ): - ctx.command.reset_cooldown(ctx) - return await self._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._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._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._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._embed_msg( - ctx, - title=_("Unable To Shuffle Queue"), - description=_("There's nothing in the queue."), - ) - - player.force_shuffle(0) - return await self._embed_msg(ctx, title=_("Queue has been shuffled.")) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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() - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._has_dj_role( - ctx, ctx.author - ): - return await self._embed_msg( - ctx, - title=_("Unable To Toggle Repeat"), - description=_("You need the DJ role to toggle repeat."), - ) - if self._player_check(ctx): - await self._data_check(ctx) - player = lavalink.get_player(ctx.guild.id) - if ( - not ctx.author.voice or ctx.author.voice.channel != player.channel - ) and not await self._can_instaskip(ctx, ctx.author): - return await self._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._embed_msg(ctx, embed=embed) - if self._player_check(ctx): - await self._data_check(ctx) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def remove(self, ctx: commands.Context, index: int): - """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._embed_msg(ctx, title=_("Nothing playing.")) - player = lavalink.get_player(ctx.guild.id) - if not player.queue: - return await self._embed_msg(ctx, title=_("Nothing queued.")) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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 await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable To Modify Queue"), - description=_("You must be in the voice channel to manage the queue."), - ) - if index > len(player.queue) or index < 1: - return await self._embed_msg( - ctx, - title=_("Unable To Modify Queue"), - description=_("Song number must be greater than 1 and within the queue limit."), - ) - index -= 1 - removed = player.queue.pop(index) - removed_title = get_track_description(removed) - await self._embed_msg( - ctx, - title=_("Removed track from queue"), - description=_("Removed {track} from the queue.").format(track=removed_title), - ) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def search(self, ctx: commands.Context, *, query: str): - """Pick a track with a search. - - Use `[p]search list ` to queue all tracks found on YouTube. - `[p]search sc` 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._connection_aborted: - msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - desc = _("Please check your console or logs for details.") - return await self._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 userlimit(ctx.author.voice.channel) - ): - return await self._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._embed_msg( - ctx, - title=_("Unable To Search For Tracks"), - description=_("Connect to a voice channel first."), - ) - except IndexError: - return await self._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) - if ( - not ctx.author.voice or ctx.author.voice.channel != player.channel - ) and not await self._can_instaskip(ctx, ctx.author): - return await self._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._data_check(ctx) - - before_queue_length = len(player.queue) - - if not isinstance(query, list): - query = audio_dataclasses.Query.process_input(query) - restrict = await self.config.restrict() - if restrict and match_url(query): - valid_url = url_check(query) - if not valid_url: - return await self._embed_msg( - ctx, - title=_("Unable To Play Tracks"), - description=_("That URL is not allowed."), - ) - if not await is_allowed(ctx.guild, f"{query}", query_obj=query): - return await self._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.music_cache.lavalink_query( - ctx, player, query - ) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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: - tracks = await self._folder_tracks(ctx, player, query) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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 audio_dataclasses._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._embed_msg(ctx, embed=embed) - queue_dur = await queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_dur) - if guild_data["dj_enabled"]: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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 - for track in tracks: - if len(player.queue) >= 10000: - continue - if not await is_allowed( - ctx.guild, - ( - f"{track.title} {track.author} {track.uri} " - f"{str(audio_dataclasses.Query.process_input(track))}" - ), - ): - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") - continue - elif guild_data["maxlength"] > 0: - if track_limit(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() - await asyncio.sleep(0) - 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: - songembed.set_footer( - text=_( - "{time} until start of search playback: starts at #{position} in queue" - ).format(time=queue_total_duration, position=before_queue_length + 1) - ) - return await self._embed_msg(ctx, embed=songembed) - elif query.is_local and query.single_track: - tracks = await self._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._folder_list(ctx, query) - else: - try: - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) - except TrackEnqueueError: - self._play_lock(ctx, False) - return await self._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 audio_dataclasses._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._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 = [] - for page_num in range(1, len_search_pages + 1): - embed = await self._build_search_page(ctx, tracks, page_num) - search_page_list.append(embed) - await asyncio.sleep(0) - - if dj_enabled and not await self._can_instaskip(ctx, ctx.author): - return await menu(ctx, search_page_list, DEFAULT_CONTROLS) - - await menu(ctx, search_page_list, search_controls) - - async def _search_button_action(self, ctx: commands.Context, tracks, emoji, page): - if not self._player_check(ctx): - if self._connection_aborted: - msg = _("Connection to Lavalink has failed.") - description = EmptyEmbed - if await ctx.bot.is_owner(ctx.author): - description = _("Please check your console or logs for details.") - return await self._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._embed_msg(ctx, title=_("Connect to a voice channel first.")) - except IndexError: - return await self._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._embed_msg( - ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.") - ) - if not await self._currency_check(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 getattr(search_choice, "uri", None): - description = get_track_description(search_choice) - else: - search_choice = audio_dataclasses.Query.process_input(search_choice) - if search_choice.track.exists() and search_choice.track.is_dir(): - return await ctx.invoke(self.search, query=search_choice) - elif search_choice.track.exists() and search_choice.track.is_file(): - search_choice.invoked_from = "localtrack" - return await ctx.invoke(self.play, query=search_choice) - - songembed = discord.Embed(title=_("Track Enqueued"), description=description) - queue_dur = await queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_dur) - before_queue_length = len(player.queue) - - if not await is_allowed( - ctx.guild, - ( - f"{search_choice.title} {search_choice.author} {search_choice.uri} " - f"{str(audio_dataclasses.Query.process_input(search_choice))}" - ), - ): - log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") - self._play_lock(ctx, False) - return await self._embed_msg(ctx, title=_("This track is not allowed in this server.")) - elif guild_data["maxlength"] > 0: - - if track_limit(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._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._embed_msg(ctx, embed=songembed) - - @staticmethod - def _format_search_options(search_choice): - query = audio_dataclasses.Query.process_input(search_choice) - description = get_track_description(search_choice) - return description, query - - @staticmethod - async def _build_search_page(ctx: commands.Context, tracks, page_num): - 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 - for i, track in enumerate(tracks[search_idx_start:search_idx_end], 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 = audio_dataclasses.Query.process_input(track.uri) - if query.is_local: - search_list += "`{0}.` **{1}**\n[{2}]\n".format( - search_track_num, - track.title, - audio_dataclasses.LocalPath(track.uri).to_string_user(), - ) - else: - search_list += "`{0}.` **[{1}]({2})**\n".format( - search_track_num, track.title, track.uri - ) - except AttributeError: - track = audio_dataclasses.Query.process_input(track) - if track.is_local and command != "search": - search_list += "`{}.` **{}**\n".format( - search_track_num, track.to_string_user() - ) - if track.is_album: - folder = True - elif command == "search": - search_list += "`{}.` **{}**\n".format( - search_track_num, track.to_string_user() - ) - else: - search_list += "`{}.` **{}**\n".format( - search_track_num, track.to_string_user() - ) - await asyncio.sleep(0) - 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 - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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_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._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._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._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._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._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 = time_convert(seconds) - if seconds == 0: - return await self._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._embed_msg( - ctx, - title=_("Moved {num_seconds}s to 00:00:00").format( - num_seconds=seconds - ), - ) - else: - await self._embed_msg( - ctx, - title=_("Moved {num_seconds}s to {time}").format( - num_seconds=seconds, time=lavalink.utils.format_time(seek) - ), - ) - await player.seek(seek) - else: - await self._embed_msg( - ctx, - title=_("Moved to {time}").format( - time=lavalink.utils.format_time(seconds * 1000) - ), - ) - await player.seek(seconds * 1000) - else: - await self._embed_msg(ctx, title=_("Nothing playing.")) - - @commands.group(autohelp=False) - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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() - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable To Toggle Shuffle"), - description=_("You need the DJ role to toggle shuffle."), - ) - if self._player_check(ctx): - await self._data_check(ctx) - player = lavalink.get_player(ctx.guild.id) - if ( - not ctx.author.voice or ctx.author.voice.channel != player.channel - ) and not await self._can_instaskip(ctx, ctx.author): - return await self._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._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._data_check(ctx) - - @shuffle.command(name="bumped") - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def _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() - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable To Toggle Shuffle"), - description=_("You need the DJ role to toggle shuffle."), - ) - if self._player_check(ctx): - await self._data_check(ctx) - player = lavalink.get_player(ctx.guild.id) - if ( - not ctx.author.voice or ctx.author.voice.channel != player.channel - ) and not await self._can_instaskip(ctx, ctx.author): - return await self._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._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._data_check(ctx) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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.play, query=url) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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._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 await self._can_instaskip(ctx, ctx.author): - return await self._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._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_alone(ctx) - is_requester = await self.is_requester(ctx, ctx.author) - can_skip = await self._can_instaskip(ctx, ctx.author) - if dj_enabled and not vote_enabled: - if not (can_skip or is_requester) and not is_alone: - return await self._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._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._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._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._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) - - async def _can_instaskip(self, ctx: commands.Context, member: discord.Member): - - 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: - if await self._has_dj_role(ctx, member): - return True - - if await ctx.bot.is_owner(member): - return True - - if await ctx.bot.is_mod(member): - return True - - if await self._channel_check(ctx): - return True - - return False - - @staticmethod - async def _is_alone(ctx: commands.Context): - channel_members = rgetattr(ctx, "guild.me.voice.channel.members", []) - nonbots = sum(m.id != ctx.author.id for m in channel_members if not m.bot) - return nonbots < 1 - - async def _has_dj_role(self, ctx: commands.Context, member: discord.Member): - 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 - - @staticmethod - async def is_requester(ctx: commands.Context, member: discord.Member): - try: - player = lavalink.get_player(ctx.guild.id) - log.debug(f"Current requester is {player.current}") - return player.current.requester.id == member.id - except Exception as e: - log.error(e) - return False - - async def _skip_action(self, ctx: commands.Context, skip_to_track: int = 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: - return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) - time_remain = lavalink.utils.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 - ) - ) - return await self._embed_msg(ctx, embed=embed) - elif autoplay and not player.queue: - embed = discord.Embed( - title=_("Track Skipped"), description=get_track_description(player.current) - ) - await self._embed_msg(ctx, embed=embed) - return await player.skip() - - queue_to_append = [] - if skip_to_track is not None and skip_to_track != 1: - if skip_to_track < 1: - return await self._embed_msg( - ctx, title=_("Track number must be equal to or greater than 1.") - ) - elif skip_to_track > len(player.queue): - return await self._embed_msg( - ctx, - title=_( - "There are only {queuelen} songs currently queued.".format( - queuelen=len(player.queue) - ) - ), - ) - embed = discord.Embed( - title=_("{skip_to_track} Tracks Skipped".format(skip_to_track=skip_to_track)) - ) - await self._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=get_track_description(player.current) - ) - await self._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 - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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._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 await self._can_instaskip(ctx, ctx.author): - return await self._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: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._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: - if not await self._can_instaskip(ctx, ctx.author): - return await self._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._embed_msg(ctx, title=_("Stopping...")) - - @commands.command() - @commands.guild_only() - @commands.cooldown(1, 15, commands.BucketType.guild) - @commands.bot_has_permissions(embed_links=True) - async def 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_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): - if not can_skip and not is_alone: - ctx.command.reset_cooldown(ctx) - return await self._embed_msg( - ctx, - title=_("Unable To Join Voice Channel"), - description=_("There are other people listening."), - ) - if dj_enabled and not vote_enabled: - if not (can_skip or is_requester) and not is_alone: - ctx.command.reset_cooldown(ctx) - return await self._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 userlimit(ctx.author.voice.channel) - ): - ctx.command.reset_cooldown(ctx) - return await self._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._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._embed_msg( - ctx, - title=_("Unable To Join Voice Channel"), - description=_("Connection to Lavalink has not yet been established."), - ) - - @commands.command() - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - async def 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() - ) - 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._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 await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg( - ctx, - title=_("Unable To Change Volume"), - description=_("You must be in the voice channel to change the volume."), - ) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._has_dj_role( - ctx, ctx.author - ): - return await self._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._embed_msg(ctx, embed=embed) - - @commands.group(aliases=["llset"]) - @commands.guild_only() - @commands.bot_has_permissions(embed_links=True) - @checks.is_owner() - async def llsetup(self, ctx: commands.Context): - """Lavalink server configuration options.""" - - @llsetup.command() - async def 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._embed_msg(ctx, embed=embed) - else: - if self._manager is not None: - await self._manager.shutdown() - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("External lavalink server: {true_or_false}.").format( - true_or_false=_("Enabled") if not external else _("Disabled") - ), - ) - - self._restart_connect() - - @llsetup.command() - async def host(self, ctx: commands.Context, host: str): - """Set the lavalink server host.""" - await self.config.host.set(host) - footer = None - if await self._check_external(): - footer = _("External lavalink server set to True.") - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Host set to {host}.").format(host=host), - footer=footer, - ) - self._restart_connect() - - @llsetup.command() - async def password(self, ctx: commands.Context, password: str): - """Set the lavalink server password.""" - await self.config.password.set(str(password)) - footer = None - if await self._check_external(): - footer = _("External lavalink server set to True.") - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Server password set to {password}.").format(password=password), - footer=footer, - ) - - self._restart_connect() - - @llsetup.command() - async def 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._check_external(): - footer = _("External lavalink server set to True.") - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("REST port set to {port}.").format(port=rest_port), - footer=footer, - ) - - self._restart_connect() - - @llsetup.command() - async def 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._check_external(): - footer = _("External lavalink server set to True.") - await self._embed_msg( - ctx, - title=_("Setting Changed"), - description=_("Websocket port set to {port}.").format(port=ws_port), - footer=footer, - ) - - self._restart_connect() - - @staticmethod - async def _apply_gain(guild_id: int, band, gain): - 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 - - @staticmethod - async def _apply_gains(guild_id: int, gains): - 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 _channel_check(self, ctx: commands.Context): - 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 - - async def _check_api_tokens(self): - 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 _check_external(self): - external = await self.config.use_external_lavalink() - if not external: - if self._manager is not None: - await self._manager.shutdown() - await self.config.use_external_lavalink.set(True) - return True - else: - return False - - async def _clear_react(self, message: discord.Message, emoji: MutableMapping = None): - """Non blocking version of clear_react.""" - return self.bot.loop.create_task(clear_react(self.bot, message, emoji)) - - async def _currency_check(self, ctx: commands.Context, jukebox_price: int): - 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._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 _data_check(self, ctx: commands.Context): - 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 disconnect_timer(self): - stop_times = {} - pause_times = {} - while True: - for p in 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: - log.error( - "Exception raised in Audio's emptypause_timer.", exc_info=True - ) - pause_times.pop(server.id, None) - servers = stop_times.copy() - servers.update(pause_times) - for sid in servers: - 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: - log.error("Exception raised in Audio's emptydc_timer.", exc_info=True) - if "No such player for that guild" in str(err): - stop_times.pop(sid, None) - 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)) >= 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) - log.error( - "Exception raised in Audio's emptypause_timer.", exc_info=True - ) - await asyncio.sleep(5) - - async def _embed_msg(self, ctx: commands.Context, **kwargs): - colour = kwargs.get("colour") or kwargs.get("color") or await self.bot.get_embed_color(ctx) - error = kwargs.get("error", False) - success = kwargs.get("success", False) - 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) - embed = kwargs.get("embed").to_dict() if hasattr(kwargs.get("embed"), "to_dict") else {} - 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) - return await ctx.send(embed=embed) - - async def _eq_check(self, ctx: commands.Context, player: lavalink.Player): - 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, message, selected - ): - 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 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 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 remove_react(message, react_emoji, react_user) - _max = "{:.2f}".format(min(eq.get_gain(selected) + 0.1, 1.0)) - eq.set_gain(selected, float(_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 remove_react(message, react_emoji, react_user) - _min = "{:.2f}".format(max(eq.get_gain(selected) - 0.1, -0.25)) - eq.set_gain(selected, float(_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 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 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 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 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 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 remove_react(message, react_emoji, react_user) - await ctx.send_help(self.eq) - await self._eq_interact(ctx, player, eq, message, selected) - - @staticmethod - async def _eq_msg_clear(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 - - def _play_lock(self, ctx: commands.Context, tf): - if tf: - self.play_lock[ctx.message.guild.id] = True - else: - self.play_lock[ctx.message.guild.id] = False - - def _player_check(self, ctx: commands.Context): - if self._connection_aborted: - return False - try: - lavalink.get_player(ctx.guild.id) - return True - except IndexError: - return False - except KeyError: - return False - - @commands.Cog.listener() - async def on_red_audio_track_start( - self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member - ): - daily_cache = self._daily_playlist_cache.setdefault( - guild.id, await self.config.guild(guild).daily_playlists() - ) - scope = PlaylistScope.GUILD.value - today = datetime.date.today() - midnight = datetime.datetime.combine(today, datetime.datetime.min.time()) - if daily_cache: - name = f"Daily playlist - {today}" - today_id = int(time.mktime(today.timetuple())) - track_identifier = track.track_identifier - track = track_to_json(track) - try: - playlist = await get_playlist( - 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=scope, - author=self.bot.user.id, - playlist_id=today_id, - name=name, - playlist_url=None, - tracks=[track], - guild=guild, - ) - await playlist.save() - - with contextlib.suppress(Exception): - too_old = midnight - datetime.timedelta(days=8) - too_old_id = int(time.mktime(too_old.timetuple())) - await delete_playlist( - scope=scope, playlist_id=too_old_id, guild=guild, author=self.bot.user - ) - - @commands.Cog.listener() - async def on_voice_state_update( - self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState - ): - await self._ready_event.wait() - if after.channel != before.channel: - try: - self.skip_votes[before.channel.guild].remove(member.id) - except (ValueError, KeyError, AttributeError): - pass - - @commands.Cog.listener() - async def on_red_audio_queue_end( - self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member - ): - await self.music_cache.database.clean_up_old_entries() - await asyncio.sleep(5) - dat = get_playlist_database() - if dat: - dat.delete_scheduled() - await asyncio.sleep(5) - - def cog_unload(self): - if not self._cleaned_up: - self.bot.dispatch("red_audio_unload", self) - self.session.detach() - self.bot.loop.create_task(self._close_database()) - if self._disconnect_task: - self._disconnect_task.cancel() - - if self._connect_task: - self._connect_task.cancel() - - if self._init_task: - self._init_task.cancel() - - lavalink.unregister_event_listener(self.event_handler) - self.bot.loop.create_task(lavalink.close()) - if self._manager is not None: - self.bot.loop.create_task(self._manager.shutdown()) - - self._cleaned_up = True - - @bump.error - @disconnect.error - @genre.error - @local_folder.error - @local_play.error - @local_search.error - @play.error - @prev.error - @search.error - @_playlist_append.error - @_playlist_save.error - @_playlist_update.error - @_playlist_upload.error - async def _clear_lock_on_error(self, ctx: commands.Context, error): - # TODO: Change this in a future PR - # FIXME: This seems to be consuming tracebacks and not adding them to last traceback - # which is handled by on_command_error - # Make it so that this can be used to show user friendly errors - if not isinstance( - getattr(error, "original", error), - ( - commands.CheckFailure, - commands.UserInputError, - commands.DisabledCommand, - commands.CommandOnCooldown, - ), - ): - self._play_lock(ctx, False) - await self.music_cache.run_tasks(ctx) - message = "Error in command '{}'. Check your console or logs for details.".format( - ctx.command.qualified_name - ) - await ctx.send(inline(message)) - exception_log = "Exception in command '{}'\n" "".format(ctx.command.qualified_name) - exception_log += "".join( - traceback.format_exception(type(error), error, error.__traceback__) - ) - self.bot._last_exception = exception_log - - await ctx.bot.on_command_error( - ctx, getattr(error, "original", error), unhandled_by_cog=True - ) - - async def cog_after_invoke(self, ctx: commands.Context): - await self._process_db(ctx) - - async def _process_db(self, ctx: commands.Context): - await self.music_cache.run_tasks(ctx) - - async def _close_database(self): - await self.music_cache.run_all_pending_tasks() - self.music_cache.database.close() - - __del__ = cog_unload diff --git a/redbot/cogs/audio/audio_dataclasses.py b/redbot/cogs/audio/audio_dataclasses.py index 21c79e14f..59c318ff6 100644 --- a/redbot/cogs/audio/audio_dataclasses.py +++ b/redbot/cogs/audio/audio_dataclasses.py @@ -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): diff --git a/redbot/cogs/audio/audio_logging.py b/redbot/cogs/audio/audio_logging.py new file mode 100644 index 000000000..5e71ad750 --- /dev/null +++ b/redbot/cogs/audio/audio_logging.py @@ -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) diff --git a/redbot/cogs/audio/checks.py b/redbot/cogs/audio/checks.py deleted file mode 100644 index 8289ca3e3..000000000 --- a/redbot/cogs/audio/checks.py +++ /dev/null @@ -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) diff --git a/redbot/cogs/audio/config.py b/redbot/cogs/audio/config.py deleted file mode 100644 index 6737a1eac..000000000 --- a/redbot/cogs/audio/config.py +++ /dev/null @@ -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) diff --git a/redbot/cogs/audio/converters.py b/redbot/cogs/audio/converters.py index 7e8d2c36e..00bee9fc6 100644 --- a/redbot/cogs/audio/converters.py +++ b/redbot/cogs/audio/converters.py @@ -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"^?$") +_ = 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"^?$") 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, diff --git a/redbot/cogs/audio/core/__init__.py b/redbot/cogs/audio/core/__init__.py new file mode 100644 index 000000000..e1a076490 --- /dev/null +++ b/redbot/cogs/audio/core/__init__.py @@ -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) diff --git a/redbot/cogs/audio/core/abc.py b/redbot/cogs/audio/core/abc.py new file mode 100644 index 000000000..e66568ece --- /dev/null +++ b/redbot/cogs/audio/core/abc.py @@ -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() diff --git a/redbot/cogs/audio/core/cog_utils.py b/redbot/cogs/audio/core/cog_utils.py new file mode 100644 index 000000000..995ef23f6 --- /dev/null +++ b/redbot/cogs/audio/core/cog_utils.py @@ -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 diff --git a/redbot/cogs/audio/core/commands/__init__.py b/redbot/cogs/audio/core/commands/__init__.py new file mode 100644 index 000000000..8c3161531 --- /dev/null +++ b/redbot/cogs/audio/core/commands/__init__.py @@ -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""" diff --git a/redbot/cogs/audio/core/commands/audioset.py b/redbot/cogs/audio/core/commands/audioset.py new file mode 100644 index 000000000..59b0ddc16 --- /dev/null +++ b/redbot/cogs/audio/core/commands/audioset.py @@ -0,0 +1,1307 @@ +import asyncio +import contextlib +import logging +from typing import Union + +import discord +import lavalink + +from redbot.core import bank, commands +from redbot.core.data_manager import cog_data_path +from redbot.core.utils.chat_formatting import box, humanize_number +from redbot.core.utils.menus import DEFAULT_CONTROLS, menu, start_adding_reactions +from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate + +from ...audio_dataclasses import LocalPath +from ...converters import ScopeParser +from ...errors import MissingGuild, TooManyMatches +from ...utils import CacheLevel, PlaylistScope +from ..abc import MixinMeta +from ..cog_utils import CompositeMetaClass, PlaylistConverter, _, __version__ + +log = logging.getLogger("red.cogs.Audio.cog.Commands.audioset") + + +class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): + @commands.group(name="audioset") + @commands.bot_has_permissions(embed_links=True) + async def command_audioset(self, ctx: commands.Context): + """Music configuration options.""" + + @command_audioset.group(name="restrictions") + @commands.mod_or_permissions(manage_guild=True) + async def command_audioset_perms(self, ctx: commands.Context): + """Manages the keyword whitelist and blacklist.""" + + @commands.is_owner() + @command_audioset_perms.group(name="global") + async def command_audioset_perms_global(self, ctx: commands.Context): + """Manages the global keyword whitelist/blacklist.""" + + @command_audioset_perms_global.group(name="whitelist") + async def command_audioset_perms_global_whitelist(self, ctx: commands.Context): + """Manages the global keyword whitelist.""" + + @command_audioset_perms_global_whitelist.command(name="add") + async def command_audioset_perms_global_whitelist_add( + self, ctx: commands.Context, *, keyword: str + ): + """Adds a keyword to the whitelist. + + If anything is added to whitelist, it will blacklist everything else. + """ + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.url_keyword_whitelist() as whitelist: + if keyword in whitelist: + exists = True + else: + whitelist.append(keyword) + if exists: + return await self.send_embed_msg(ctx, title=_("Keyword already in the whitelist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Added `{whitelisted}` to the whitelist.").format( + whitelisted=keyword + ), + ) + + @command_audioset_perms_global_whitelist.command(name="list") + @commands.bot_has_permissions(add_reactions=True) + async def command_audioset_perms_global_whitelist_list(self, ctx: commands.Context): + """List all keywords added to the whitelist.""" + whitelist = await self.config.url_keyword_whitelist() + if not whitelist: + return await self.send_embed_msg(ctx, title=_("Nothing in the whitelist.")) + whitelist.sort() + text = "" + total = len(whitelist) + pages = [] + for i, entry in enumerate(whitelist, 1): + text += f"{i}. [{entry}]" + if i != total: + text += "\n" + if i % 10 == 0: + pages.append(box(text, lang="ini")) + text = "" + else: + pages.append(box(text, lang="ini")) + embed_colour = await ctx.embed_colour() + pages = list( + discord.Embed(title=_("Global Whitelist"), description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @command_audioset_perms_global_whitelist.command(name="clear") + async def command_audioset_perms_global_whitelist_clear(self, ctx: commands.Context): + """Clear all keywords from the whitelist.""" + whitelist = await self.config.url_keyword_whitelist() + if not whitelist: + return await self.send_embed_msg(ctx, title=_("Nothing in the whitelist.")) + await self.config.url_keyword_whitelist.clear() + return await self.send_embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("All entries have been removed from the whitelist."), + ) + + @command_audioset_perms_global_whitelist.command(name="delete", aliases=["del", "remove"]) + async def command_audioset_perms_global_whitelist_delete( + self, ctx: commands.Context, *, keyword: str + ): + """Removes a keyword from the whitelist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = True + async with self.config.url_keyword_whitelist() as whitelist: + if keyword not in whitelist: + exists = False + else: + whitelist.remove(keyword) + if not exists: + return await self.send_embed_msg(ctx, title=_("Keyword already in the whitelist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Removed `{whitelisted}` from the whitelist.").format( + whitelisted=keyword + ), + ) + + @command_audioset_perms_global.group(name="blacklist") + async def command_audioset_perms_global_blacklist(self, ctx: commands.Context): + """Manages the global keyword blacklist.""" + + @command_audioset_perms_global_blacklist.command(name="add") + async def command_audioset_perms_global_blacklist_add( + self, ctx: commands.Context, *, keyword: str + ): + """Adds a keyword to the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.url_keyword_blacklist() as blacklist: + if keyword in blacklist: + exists = True + else: + blacklist.append(keyword) + if exists: + return await self.send_embed_msg(ctx, title=_("Keyword already in the blacklist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Added `{blacklisted}` to the blacklist.").format( + blacklisted=keyword + ), + ) + + @command_audioset_perms_global_blacklist.command(name="list") + @commands.bot_has_permissions(add_reactions=True) + async def command_audioset_perms_global_blacklist_list(self, ctx: commands.Context): + """List all keywords added to the blacklist.""" + blacklist = await self.config.url_keyword_blacklist() + if not blacklist: + return await self.send_embed_msg(ctx, title=_("Nothing in the blacklist.")) + blacklist.sort() + text = "" + total = len(blacklist) + pages = [] + for i, entry in enumerate(blacklist, 1): + text += f"{i}. [{entry}]" + if i != total: + text += "\n" + if i % 10 == 0: + pages.append(box(text, lang="ini")) + text = "" + else: + pages.append(box(text, lang="ini")) + embed_colour = await ctx.embed_colour() + pages = list( + discord.Embed(title=_("Global Blacklist"), description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @command_audioset_perms_global_blacklist.command(name="clear") + async def command_audioset_perms_global_blacklist_clear(self, ctx: commands.Context): + """Clear all keywords added to the blacklist.""" + blacklist = await self.config.url_keyword_blacklist() + if not blacklist: + return await self.send_embed_msg(ctx, title=_("Nothing in the blacklist.")) + await self.config.url_keyword_blacklist.clear() + return await self.send_embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("All entries have been removed from the blacklist."), + ) + + @command_audioset_perms_global_blacklist.command(name="delete", aliases=["del", "remove"]) + async def command_audioset_perms_global_blacklist_delete( + self, ctx: commands.Context, *, keyword: str + ): + """Removes a keyword from the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = True + async with self.config.url_keyword_blacklist() as blacklist: + if keyword not in blacklist: + exists = False + else: + blacklist.remove(keyword) + if not exists: + return await self.send_embed_msg(ctx, title=_("Keyword is not in the blacklist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Removed `{blacklisted}` from the blacklist.").format( + blacklisted=keyword + ), + ) + + @command_audioset_perms.group(name="whitelist") + @commands.guild_only() + async def command_audioset_perms_whitelist(self, ctx: commands.Context): + """Manages the keyword whitelist.""" + + @command_audioset_perms_whitelist.command(name="add") + async def command_audioset_perms_whitelist_add(self, ctx: commands.Context, *, keyword: str): + """Adds a keyword to the whitelist. + + If anything is added to whitelist, it will blacklist everything else. + """ + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.guild(ctx.guild).url_keyword_whitelist() as whitelist: + if keyword in whitelist: + exists = True + else: + whitelist.append(keyword) + if exists: + return await self.send_embed_msg(ctx, title=_("Keyword already in the whitelist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Added `{whitelisted}` to the whitelist.").format( + whitelisted=keyword + ), + ) + + @command_audioset_perms_whitelist.command(name="list") + @commands.bot_has_permissions(add_reactions=True) + async def command_audioset_perms_whitelist_list(self, ctx: commands.Context): + """List all keywords added to the whitelist.""" + whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() + if not whitelist: + return await self.send_embed_msg(ctx, title=_("Nothing in the whitelist.")) + whitelist.sort() + text = "" + total = len(whitelist) + pages = [] + for i, entry in enumerate(whitelist, 1): + text += f"{i}. [{entry}]" + if i != total: + text += "\n" + if i % 10 == 0: + pages.append(box(text, lang="ini")) + text = "" + else: + pages.append(box(text, lang="ini")) + embed_colour = await ctx.embed_colour() + pages = list( + discord.Embed(title=_("Whitelist"), description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @command_audioset_perms_whitelist.command(name="clear") + async def command_audioset_perms_whitelist_clear(self, ctx: commands.Context): + """Clear all keywords from the whitelist.""" + whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() + if not whitelist: + return await self.send_embed_msg(ctx, title=_("Nothing in the whitelist.")) + await self.config.guild(ctx.guild).url_keyword_whitelist.clear() + return await self.send_embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("All entries have been removed from the whitelist."), + ) + + @command_audioset_perms_whitelist.command(name="delete", aliases=["del", "remove"]) + async def command_audioset_perms_whitelist_delete( + self, ctx: commands.Context, *, keyword: str + ): + """Removes a keyword from the whitelist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = True + async with self.config.guild(ctx.guild).url_keyword_whitelist() as whitelist: + if keyword not in whitelist: + exists = False + else: + whitelist.remove(keyword) + if not exists: + return await self.send_embed_msg(ctx, title=_("Keyword already in the whitelist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Removed `{whitelisted}` from the whitelist.").format( + whitelisted=keyword + ), + ) + + @command_audioset_perms.group(name="blacklist") + @commands.guild_only() + async def command_audioset_perms_blacklist(self, ctx: commands.Context): + """Manages the keyword blacklist.""" + + @command_audioset_perms_blacklist.command(name="add") + async def command_audioset_perms_blacklist_add(self, ctx: commands.Context, *, keyword: str): + """Adds a keyword to the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.guild(ctx.guild).url_keyword_blacklist() as blacklist: + if keyword in blacklist: + exists = True + else: + blacklist.append(keyword) + if exists: + return await self.send_embed_msg(ctx, title=_("Keyword already in the blacklist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Added `{blacklisted}` to the blacklist.").format( + blacklisted=keyword + ), + ) + + @command_audioset_perms_blacklist.command(name="list") + @commands.bot_has_permissions(add_reactions=True) + async def command_audioset_perms_blacklist_list(self, ctx: commands.Context): + """List all keywords added to the blacklist.""" + blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() + if not blacklist: + return await self.send_embed_msg(ctx, title=_("Nothing in the blacklist.")) + blacklist.sort() + text = "" + total = len(blacklist) + pages = [] + for i, entry in enumerate(blacklist, 1): + text += f"{i}. [{entry}]" + if i != total: + text += "\n" + if i % 10 == 0: + pages.append(box(text, lang="ini")) + text = "" + else: + pages.append(box(text, lang="ini")) + embed_colour = await ctx.embed_colour() + pages = list( + discord.Embed(title=_("Blacklist"), description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @command_audioset_perms_blacklist.command(name="clear") + async def command_audioset_perms_blacklist_clear(self, ctx: commands.Context): + """Clear all keywords added to the blacklist.""" + blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() + if not blacklist: + return await self.send_embed_msg(ctx, title=_("Nothing in the blacklist.")) + await self.config.guild(ctx.guild).url_keyword_blacklist.clear() + return await self.send_embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("All entries have been removed from the blacklist."), + ) + + @command_audioset_perms_blacklist.command(name="delete", aliases=["del", "remove"]) + async def command_audioset_perms_blacklist_delete( + self, ctx: commands.Context, *, keyword: str + ): + """Removes a keyword from the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = True + async with self.config.guild(ctx.guild).url_keyword_blacklist() as blacklist: + if keyword not in blacklist: + exists = False + else: + blacklist.remove(keyword) + if not exists: + return await self.send_embed_msg(ctx, title=_("Keyword is not in the blacklist.")) + else: + return await self.send_embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Removed `{blacklisted}` from the blacklist.").format( + blacklisted=keyword + ), + ) + + @command_audioset.group(name="globaldb", enabled=False, hidden=True) + @commands.is_owner() + async def command_audioset_audiodb(self, ctx: commands.Context): + """Change global db settings.""" + + @command_audioset.group(name="autoplay") + @commands.mod_or_permissions(manage_guild=True) + async def command_audioset_autoplay(self, ctx: commands.Context): + """Change auto-play setting.""" + + @command_audioset_autoplay.command(name="toggle") + async def command_audioset_autoplay_toggle(self, ctx: commands.Context): + """Toggle auto-play when there no songs in queue.""" + autoplay = await self.config.guild(ctx.guild).auto_play() + repeat = await self.config.guild(ctx.guild).repeat() + disconnect = await self.config.guild(ctx.guild).disconnect() + msg = _("Auto-play when queue ends: {true_or_false}.").format( + true_or_false=_("Enabled") if not autoplay else _("Disabled") + ) + await self.config.guild(ctx.guild).auto_play.set(not autoplay) + if autoplay is not True and repeat is True: + msg += _("\nRepeat has been disabled.") + await self.config.guild(ctx.guild).repeat.set(False) + if autoplay is not True and disconnect is True: + msg += _("\nAuto-disconnecting at queue end has been disabled.") + await self.config.guild(ctx.guild).disconnect.set(False) + + await self.send_embed_msg(ctx, title=_("Setting Changed"), description=msg) + if self._player_check(ctx): + await self.set_player_settings(ctx) + + @command_audioset_autoplay.command(name="playlist", usage=" [args]") + @commands.bot_has_permissions(add_reactions=True) + async def command_audioset_autoplay_playlist( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Set a playlist to auto-play songs from. + + **Usage**: + ​ ​ ​ ​ `[p]audioset autoplay playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]audioset autoplay MyGuildPlaylist` + ​ ​ ​ ​ `[p]audioset autoplay MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]audioset autoplay PersonalPlaylist --scope User --author Draper` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + + scope, author, guild, specified_user = scope_data + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + return await self.send_embed_msg(ctx, title=str(e)) + if playlist is None: + return await self.send_embed_msg( + ctx, + title=_("No Playlist Found"), + description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), + ) + try: + tracks = playlist.tracks + if not tracks: + return await self.send_embed_msg( + ctx, + title=_("No Tracks Found"), + description=_("Playlist {name} has no tracks.").format(name=playlist.name), + ) + playlist_data = dict(enabled=True, id=playlist.id, name=playlist.name, scope=scope) + await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) + except RuntimeError: + return await self.send_embed_msg( + ctx, + title=_("No Playlist Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( + id=playlist_arg, scope=self.humanize_scope(scope, the=True) + ), + ) + except MissingGuild: + return await self.send_embed_msg( + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), + ) + else: + return await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_( + "Playlist {name} (`{id}`) [**{scope}**] will be used for autoplay." + ).format( + name=playlist.name, + id=playlist.id, + scope=self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ), + ), + ) + + @command_audioset_autoplay.command(name="reset") + async def command_audioset_autoplay_reset(self, ctx: commands.Context): + """Resets auto-play to the default playlist.""" + playlist_data = dict(enabled=False, id=None, name=None, scope=None) + await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) + return await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Set auto-play playlist to play recently played tracks."), + ) + + @command_audioset.command(name="globaldailyqueue") + @commands.is_owner() + async def command_audioset_global_historical_queue(self, ctx: commands.Context): + """Toggle global daily queues. + + Global daily queues creates a playlist for all tracks played today. + """ + daily_playlists = self._daily_global_playlist_cache.setdefault( + self.bot.user.id, await self.config.daily_playlists() + ) + await self.config.daily_playlists.set(not daily_playlists) + self._daily_global_playlist_cache[self.bot.user.id] = not daily_playlists + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Global daily queues: {true_or_false}.").format( + true_or_false=_("Enabled") if not daily_playlists else _("Disabled") + ), + ) + + @command_audioset.command(name="dailyqueue") + @commands.guild_only() + @commands.admin() + async def command_audioset_historical_queue(self, ctx: commands.Context): + """Toggle daily queues. + + Daily queues creates a playlist for all tracks played today. + """ + daily_playlists = self._daily_playlist_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).daily_playlists() + ) + await self.config.guild(ctx.guild).daily_playlists.set(not daily_playlists) + self._daily_playlist_cache[ctx.guild.id] = not daily_playlists + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Daily queues: {true_or_false}.").format( + true_or_false=_("Enabled") if not daily_playlists else _("Disabled") + ), + ) + + @command_audioset.command(name="dc") + @commands.guild_only() + @commands.mod_or_permissions(manage_guild=True) + async def command_audioset_dc(self, ctx: commands.Context): + """Toggle the bot auto-disconnecting when done playing. + + This setting takes precedence over `[p]audioset emptydisconnect`. + """ + + disconnect = await self.config.guild(ctx.guild).disconnect() + autoplay = await self.config.guild(ctx.guild).auto_play() + msg = "" + msg += _("Auto-disconnection at queue end: {true_or_false}.").format( + true_or_false=_("Enabled") if not disconnect else _("Disabled") + ) + if disconnect is not True and autoplay is True: + msg += _("\nAuto-play has been disabled.") + await self.config.guild(ctx.guild).auto_play.set(False) + + await self.config.guild(ctx.guild).disconnect.set(not disconnect) + + await self.send_embed_msg(ctx, title=_("Setting Changed"), description=msg) + + @command_audioset.command(name="dj") + @commands.guild_only() + @commands.admin_or_permissions(manage_roles=True) + async def command_audioset_dj(self, ctx: commands.Context): + """Toggle DJ mode. + + DJ mode allows users with the DJ role to use audio commands. + """ + dj_role = self._dj_role_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_role() + ) + dj_role = ctx.guild.get_role(dj_role) + if dj_role is None: + await self.send_embed_msg( + ctx, + title=_("Missing DJ Role"), + description=_( + "Please set a role to use with DJ mode. Enter the role name or ID now." + ), + ) + + try: + pred = MessagePredicate.valid_role(ctx) + await self.bot.wait_for("message", timeout=15.0, check=pred) + await ctx.invoke(self.command_audioset_role, role_name=pred.result) + except asyncio.TimeoutError: + return await self.send_embed_msg( + ctx, title=_("Response timed out, try again later.") + ) + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) + await self.config.guild(ctx.guild).dj_enabled.set(not dj_enabled) + self._dj_status_cache[ctx.guild.id] = not dj_enabled + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("DJ role: {true_or_false}.").format( + true_or_false=_("Enabled") if not dj_enabled else _("Disabled") + ), + ) + + @command_audioset.command(name="emptydisconnect") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_emptydisconnect(self, ctx: commands.Context, seconds: int): + """Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable. + + `[p]audioset dc` takes precedence over this setting. + """ + if seconds < 0: + return await self.send_embed_msg( + ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") + ) + if 10 > seconds > 0: + seconds = 10 + if seconds == 0: + enabled = False + await self.send_embed_msg( + ctx, title=_("Setting Changed"), description=_("Empty disconnect disabled.") + ) + else: + enabled = True + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Empty disconnect timer set to {num_seconds}.").format( + num_seconds=self.get_time_string(seconds) + ), + ) + + await self.config.guild(ctx.guild).emptydc_timer.set(seconds) + await self.config.guild(ctx.guild).emptydc_enabled.set(enabled) + + @command_audioset.command(name="emptypause") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_emptypause(self, ctx: commands.Context, seconds: int): + """Auto-pause after x seconds when room is empty, 0 to disable.""" + if seconds < 0: + return await self.send_embed_msg( + ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") + ) + if 10 > seconds > 0: + seconds = 10 + if seconds == 0: + enabled = False + await self.send_embed_msg( + ctx, title=_("Setting Changed"), description=_("Empty pause disabled.") + ) + else: + enabled = True + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Empty pause timer set to {num_seconds}.").format( + num_seconds=self.get_time_string(seconds) + ), + ) + await self.config.guild(ctx.guild).emptypause_timer.set(seconds) + await self.config.guild(ctx.guild).emptypause_enabled.set(enabled) + + @command_audioset.command(name="lyrics") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_lryics(self, ctx: commands.Context): + """Prioritise tracks with lyrics.""" + prefer_lyrics = await self.config.guild(ctx.guild).prefer_lyrics() + await self.config.guild(ctx.guild).prefer_lyrics.set(not prefer_lyrics) + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Prefer tracks with lryics: {true_or_false}.").format( + true_or_false=_("Enabled") if not prefer_lyrics else _("Disabled") + ), + ) + + @command_audioset.command(name="jukebox") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_jukebox(self, ctx: commands.Context, price: int): + """Set a price for queueing tracks for non-mods, 0 to disable.""" + if price < 0: + return await self.send_embed_msg( + ctx, title=_("Invalid Price"), description=_("Price can't be less than zero.") + ) + if price == 0: + jukebox = False + await self.send_embed_msg( + ctx, title=_("Setting Changed"), description=_("Jukebox mode disabled.") + ) + else: + jukebox = True + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Track queueing command price set to {price} {currency}.").format( + price=humanize_number(price), currency=await bank.get_currency_name(ctx.guild) + ), + ) + + await self.config.guild(ctx.guild).jukebox_price.set(price) + await self.config.guild(ctx.guild).jukebox.set(jukebox) + + @command_audioset.command(name="localpath") + @commands.is_owner() + @commands.bot_has_permissions(add_reactions=True) + async def command_audioset_localpath(self, ctx: commands.Context, *, local_path=None): + """Set the localtracks path if the Lavalink.jar is not run from the Audio data folder. + + Leave the path blank to reset the path to the default, the Audio data directory. + """ + + if not local_path: + await self.config.localpath.set(str(cog_data_path(raw_name="Audio"))) + self.local_folder_current_path = cog_data_path(raw_name="Audio") + return await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_( + "The localtracks path location has been reset to {localpath}" + ).format(localpath=str(cog_data_path(raw_name="Audio").absolute())), + ) + + info_msg = _( + "This setting is only for bot owners to set a localtracks folder location " + "In the example below, the full path for 'ParentDirectory' " + "must be passed to this command.\n" + "```\n" + "ParentDirectory\n" + " |__ localtracks (folder)\n" + " | |__ Awesome Album Name (folder)\n" + " | |__01 Cool Song.mp3\n" + " | |__02 Groovy Song.mp3\n" + "```\n" + "The folder path given to this command must contain the localtracks folder.\n" + "**This folder and files need to be visible to the user where `" + "Lavalink.jar` is being run from.**\n" + "Use this command with no path given to reset it to the default, " + "the Audio data directory for this bot.\n" + "Do you want to continue to set the provided path for local tracks?" + ) + info = await ctx.maybe_send_embed(info_msg) + + start_adding_reactions(info, ReactionPredicate.YES_OR_NO_EMOJIS) + pred = ReactionPredicate.yes_or_no(info, ctx.author) + await self.bot.wait_for("reaction_add", check=pred) + + if not pred.result: + with contextlib.suppress(discord.HTTPException): + await info.delete() + return + temp = LocalPath(local_path, self.local_folder_current_path, forced=True) + if not temp.exists() or not temp.is_dir(): + return await self.send_embed_msg( + ctx, + title=_("Invalid Path"), + description=_("{local_path} does not seem like a valid path.").format( + local_path=local_path + ), + ) + + if not temp.localtrack_folder.exists(): + warn_msg = _( + "`{localtracks}` does not exist. " + "The path will still be saved, but please check the path and " + "create a localtracks folder in `{localfolder}` before attempting " + "to play local tracks." + ).format(localfolder=temp.absolute(), localtracks=temp.localtrack_folder.absolute()) + await self.send_embed_msg(ctx, title=_("Invalid Environment"), description=warn_msg) + local_path = str(temp.localtrack_folder.absolute()) + await self.config.localpath.set(local_path) + self.local_folder_current_path = temp.localtrack_folder.absolute() + return await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("The localtracks path location has been set to {localpath}").format( + localpath=local_path + ), + ) + + @command_audioset.command(name="maxlength") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_maxlength(self, ctx: commands.Context, seconds: Union[int, str]): + """Max length of a track to queue in seconds, 0 to disable. + + Accepts seconds or a value formatted like 00:00:00 (`hh:mm:ss`) or 00:00 (`mm:ss`). Invalid + input will turn the max length setting off. + """ + if not isinstance(seconds, int): + seconds = self.time_convert(seconds) + if seconds < 0: + return await self.send_embed_msg( + ctx, title=_("Invalid length"), description=_("Length can't be less than zero.") + ) + if seconds == 0: + await self.send_embed_msg( + ctx, title=_("Setting Changed"), description=_("Track max length disabled.") + ) + else: + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Track max length set to {seconds}.").format( + seconds=self.get_time_string(seconds) + ), + ) + await self.config.guild(ctx.guild).maxlength.set(seconds) + + @command_audioset.command(name="notify") + @commands.guild_only() + @commands.mod_or_permissions(manage_guild=True) + async def command_audioset_notify(self, ctx: commands.Context): + """Toggle track announcement and other bot messages.""" + notify = await self.config.guild(ctx.guild).notify() + await self.config.guild(ctx.guild).notify.set(not notify) + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Notify mode: {true_or_false}.").format( + true_or_false=_("Enabled") if not notify else _("Disabled") + ), + ) + + @command_audioset.command(name="restrict") + @commands.is_owner() + @commands.guild_only() + async def command_audioset_restrict(self, ctx: commands.Context): + """Toggle the domain restriction on Audio. + + When toggled off, users will be able to play songs from non-commercial websites and links. + When toggled on, users are restricted to YouTube, SoundCloud, Mixer, Vimeo, Twitch, and + Bandcamp links. + """ + restrict = await self.config.restrict() + await self.config.restrict.set(not restrict) + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Commercial links only: {true_or_false}.").format( + true_or_false=_("Enabled") if not restrict else _("Disabled") + ), + ) + + @command_audioset.command(name="role") + @commands.guild_only() + @commands.admin_or_permissions(manage_roles=True) + async def command_audioset_role(self, ctx: commands.Context, *, role_name: discord.Role): + """Set the role to use for DJ mode.""" + await self.config.guild(ctx.guild).dj_role.set(role_name.id) + self._dj_role_cache[ctx.guild.id] = role_name.id + 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) + await self.send_embed_msg( + ctx, + title=_("Settings Changed"), + description=_("DJ role set to: {role.name}.").format(role=dj_role_obj), + ) + + @command_audioset.command(name="settings", aliases=["info"]) + @commands.guild_only() + async def command_audioset_settings(self, ctx: commands.Context): + """Show the current settings.""" + is_owner = await self.bot.is_owner(ctx.author) + global_data = await self.config.all() + data = await self.config.guild(ctx.guild).all() + dj_role_obj = ctx.guild.get_role(data["dj_role"]) + dj_enabled = data["dj_enabled"] + emptydc_enabled = data["emptydc_enabled"] + emptydc_timer = data["emptydc_timer"] + emptypause_enabled = data["emptypause_enabled"] + emptypause_timer = data["emptypause_timer"] + jukebox = data["jukebox"] + jukebox_price = data["jukebox_price"] + thumbnail = data["thumbnail"] + dc = data["disconnect"] + autoplay = data["auto_play"] + maxlength = data["maxlength"] + vote_percent = data["vote_percent"] + current_level = CacheLevel(global_data["cache_level"]) + song_repeat = _("Enabled") if data["repeat"] else _("Disabled") + song_shuffle = _("Enabled") if data["shuffle"] else _("Disabled") + bumpped_shuffle = _("Enabled") if data["shuffle_bumped"] else _("Disabled") + song_notify = _("Enabled") if data["notify"] else _("Disabled") + song_status = _("Enabled") if global_data["status"] else _("Disabled") + countrycode = data["country_code"] + + spotify_cache = CacheLevel.set_spotify() + youtube_cache = CacheLevel.set_youtube() + lavalink_cache = CacheLevel.set_lavalink() + has_spotify_cache = current_level.is_superset(spotify_cache) + has_youtube_cache = current_level.is_superset(youtube_cache) + has_lavalink_cache = current_level.is_superset(lavalink_cache) + cache_enabled = CacheLevel.set_lavalink().is_subset(current_level) + autoplaylist = data["autoplaylist"] + vote_enabled = data["vote_enabled"] + msg = "----" + _("Server Settings") + "---- \n" + msg += _("Auto-disconnect: [{dc}]\n").format(dc=_("Enabled") if dc else _("Disabled")) + msg += _("Auto-play: [{autoplay}]\n").format( + autoplay=_("Enabled") if autoplay else _("Disabled") + ) + if emptydc_enabled: + msg += _("Disconnect timer: [{num_seconds}]\n").format( + num_seconds=self.get_time_string(emptydc_timer) + ) + if emptypause_enabled: + msg += _("Auto Pause timer: [{num_seconds}]\n").format( + num_seconds=self.get_time_string(emptypause_timer) + ) + if dj_enabled and dj_role_obj: + msg += _("DJ Role: [{role.name}]\n").format(role=dj_role_obj) + if jukebox: + msg += _("Jukebox: [{jukebox_name}]\n").format(jukebox_name=jukebox) + msg += _("Command price: [{jukebox_price}]\n").format( + jukebox_price=humanize_number(jukebox_price) + ) + if maxlength > 0: + msg += _("Max track length: [{tracklength}]\n").format( + tracklength=self.get_time_string(maxlength) + ) + msg += _( + "Repeat: [{repeat}]\n" + "Shuffle: [{shuffle}]\n" + "Shuffle bumped: [{bumpped_shuffle}]\n" + "Song notify msgs: [{notify}]\n" + "Songs as status: [{status}]\n" + "Spotify search: [{countrycode}]\n" + ).format( + countrycode=countrycode, + repeat=song_repeat, + shuffle=song_shuffle, + notify=song_notify, + status=song_status, + bumpped_shuffle=bumpped_shuffle, + ) + if thumbnail: + msg += _("Thumbnails: [{0}]\n").format( + _("Enabled") if thumbnail else _("Disabled") + ) + if vote_percent > 0: + msg += _( + "Vote skip: [{vote_enabled}]\nSkip percentage: [{vote_percent}%]\n" + ).format( + vote_percent=vote_percent, + vote_enabled=_("Enabled") if vote_enabled else _("Disabled"), + ) + + if autoplay or autoplaylist["enabled"]: + if autoplaylist["enabled"]: + pname = autoplaylist["name"] + pid = autoplaylist["id"] + pscope = autoplaylist["scope"] + if pscope == PlaylistScope.GUILD.value: + pscope = _("Server") + elif pscope == PlaylistScope.USER.value: + pscope = _("User") + else: + pscope = _("Global") + elif cache_enabled: + pname = _("Cached") + pid = _("Cached") + pscope = _("Cached") + else: + pname = _("US Top 100") + pid = _("US Top 100") + pscope = _("US Top 100") + msg += ( + "\n---" + + _("Auto-play Settings") + + "--- \n" + + _("Playlist name: [{pname}]\n") + + _("Playlist ID: [{pid}]\n") + + _("Playlist scope: [{pscope}]\n") + ).format(pname=pname, pid=pid, pscope=pscope) + + if is_owner: + msg += ( + "\n---" + + _("Cache Settings") + + "--- \n" + + _("Max age: [{max_age}]\n") + + _("Local Spotify cache: [{spotify_status}]\n") + + _("Local Youtube cache: [{youtube_status}]\n") + + _("Local Lavalink cache: [{lavalink_status}]\n") + # + _("Global cache status: [{global_cache}]\n") + # + _("Global timeout: [{num_seconds}]\n") + ).format( + max_age=str(await self.config.cache_age()) + " " + _("days"), + spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), + youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), + lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), + # global_cache=_("Enabled") if global_data["global_db_enabled"] else _("Disabled"), + # num_seconds=self.get_time_string(global_data["global_db_get_timeout"]), + ) + + msg += ( + "\n---" + + _("Lavalink Settings") + + "--- \n" + + _("Cog version: [{version}]\n") + + _("Red-Lavalink: [{lavalink_version}]\n") + + _("External server: [{use_external_lavalink}]\n") + ).format( + version=__version__, + lavalink_version=lavalink.__version__, + use_external_lavalink=_("Enabled") + if global_data["use_external_lavalink"] + else _("Disabled"), + ) + if is_owner: + msg += _("Localtracks path: [{localpath}]\n").format(**global_data) + + await self.send_embed_msg(ctx, description=box(msg, lang="ini")) + + @command_audioset.command(name="status") + @commands.is_owner() + @commands.guild_only() + async def command_audioset_status(self, ctx: commands.Context): + """Enable/disable tracks' titles as status.""" + status = await self.config.status() + await self.config.status.set(not status) + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Song titles as status: {true_or_false}.").format( + true_or_false=_("Enabled") if not status else _("Disabled") + ), + ) + + @command_audioset.command(name="thumbnail") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_thumbnail(self, ctx: commands.Context): + """Toggle displaying a thumbnail on audio messages.""" + thumbnail = await self.config.guild(ctx.guild).thumbnail() + await self.config.guild(ctx.guild).thumbnail.set(not thumbnail) + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Thumbnail display: {true_or_false}.").format( + true_or_false=_("Enabled") if not thumbnail else _("Disabled") + ), + ) + + @command_audioset.command(name="vote") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_vote(self, ctx: commands.Context, percent: int): + """Percentage needed for non-mods to skip tracks, 0 to disable.""" + if percent < 0: + return await self.send_embed_msg( + ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") + ) + elif percent > 100: + percent = 100 + if percent == 0: + enabled = False + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Voting disabled. All users can use queue management commands."), + ) + else: + enabled = True + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Vote percentage set to {percent}%.").format(percent=percent), + ) + + await self.config.guild(ctx.guild).vote_percent.set(percent) + await self.config.guild(ctx.guild).vote_enabled.set(enabled) + + @command_audioset.command(name="youtubeapi") + @commands.is_owner() + async def command_audioset_youtubeapi(self, ctx: commands.Context): + """Instructions to set the YouTube API key.""" + message = _( + f"1. Go to Google Developers Console and log in with your Google account.\n" + "(https://console.developers.google.com/)\n" + "2. You should be prompted to create a new project (name does not matter).\n" + "3. Click on Enable APIs and Services at the top.\n" + "4. In the list of APIs choose or search for YouTube Data API v3 and " + "click on it. Choose Enable.\n" + "5. Click on Credentials on the left navigation bar.\n" + "6. Click on Create Credential at the top.\n" + '7. At the top click the link for "API key".\n' + "8. No application restrictions are needed. Click Create at the bottom.\n" + "9. You now have a key to add to `{prefix}set api youtube api_key `" + ).format(prefix=ctx.prefix) + await ctx.maybe_send_embed(message) + + @command_audioset.command(name="spotifyapi") + @commands.is_owner() + async def command_audioset_spotifyapi(self, ctx: commands.Context): + """Instructions to set the Spotify API tokens.""" + message = _( + "1. Go to Spotify developers and log in with your Spotify account.\n" + "(https://developer.spotify.com/dashboard/applications)\n" + '2. Click "Create An App".\n' + "3. Fill out the form provided with your app name, etc.\n" + '4. When asked if you\'re developing commercial integration select "No".\n' + "5. Accept the terms and conditions.\n" + "6. Copy your client ID and your client secret into:\n" + "`{prefix}set api spotify client_id " + "client_secret `" + ).format(prefix=ctx.prefix) + await ctx.maybe_send_embed(message) + + @command_audioset.command(name="countrycode") + @commands.guild_only() + @commands.mod_or_permissions(administrator=True) + async def command_audioset_countrycode(self, ctx: commands.Context, country: str): + """Set the country code for Spotify searches.""" + if len(country) != 2: + return await self.send_embed_msg( + ctx, + title=_("Invalid Country Code"), + description=_( + "Please use an official [ISO 3166-1 alpha-2]" + "(https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) code." + ), + ) + country = country.upper() + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Country Code set to {country}.").format(country=country), + ) + + await self.config.guild(ctx.guild).country_code.set(country) + + @command_audioset.command(name="cache") + @commands.is_owner() + async def command_audioset_cache(self, ctx: commands.Context, *, level: int = None): + """Sets the caching level. + + Level can be one of the following: + + 0: Disables all caching + 1: Enables Spotify Cache + 2: Enables YouTube Cache + 3: Enables Lavalink Cache + 5: Enables all Caches + + If you wish to disable a specific cache use a negative number. + """ + current_level = CacheLevel(await self.config.cache_level()) + spotify_cache = CacheLevel.set_spotify() + youtube_cache = CacheLevel.set_youtube() + lavalink_cache = CacheLevel.set_lavalink() + has_spotify_cache = current_level.is_superset(spotify_cache) + has_youtube_cache = current_level.is_superset(youtube_cache) + has_lavalink_cache = current_level.is_superset(lavalink_cache) + + if level is None: + msg = ( + _("Max age: [{max_age}]\n") + + _("Spotify cache: [{spotify_status}]\n") + + _("Youtube cache: [{youtube_status}]\n") + + _("Lavalink cache: [{lavalink_status}]\n") + ).format( + max_age=str(await self.config.cache_age()) + " " + _("days"), + spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), + youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), + lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), + ) + await self.send_embed_msg( + ctx, title=_("Cache Settings"), description=box(msg, lang="ini") + ) + return await ctx.send_help() + if level not in [5, 3, 2, 1, 0, -1, -2, -3]: + return await ctx.send_help() + + removing = level < 0 + + if level == 5: + newcache = CacheLevel.all() + elif level == 0: + newcache = CacheLevel.none() + elif level in [-3, 3]: + if removing: + newcache = current_level - lavalink_cache + else: + newcache = current_level + lavalink_cache + elif level in [-2, 2]: + if removing: + newcache = current_level - youtube_cache + else: + newcache = current_level + youtube_cache + elif level in [-1, 1]: + if removing: + newcache = current_level - spotify_cache + else: + newcache = current_level + spotify_cache + else: + return await ctx.send_help() + + has_spotify_cache = newcache.is_superset(spotify_cache) + has_youtube_cache = newcache.is_superset(youtube_cache) + has_lavalink_cache = newcache.is_superset(lavalink_cache) + msg = ( + _("Max age: [{max_age}]\n") + + _("Spotify cache: [{spotify_status}]\n") + + _("Youtube cache: [{youtube_status}]\n") + + _("Lavalink cache: [{lavalink_status}]\n") + ).format( + max_age=str(await self.config.cache_age()) + " " + _("days"), + spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), + youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), + lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), + ) + + await self.send_embed_msg(ctx, title=_("Cache Settings"), description=box(msg, lang="ini")) + + await self.config.cache_level.set(newcache.value) + + @command_audioset.command(name="cacheage") + @commands.is_owner() + async def command_audioset_cacheage(self, ctx: commands.Context, age: int): + """Sets the cache max age. + + This commands allows you to set the max number of days before an entry in the cache becomes + invalid. + """ + msg = "" + if age < 7: + msg = _( + "Cache age cannot be less than 7 days. If you wish to disable it run " + "{prefix}audioset cache.\n" + ).format(prefix=ctx.prefix) + age = 7 + msg += _("I've set the cache age to {age} days").format(age=age) + await self.config.cache_age.set(age) + await self.send_embed_msg(ctx, title=_("Setting Changed"), description=msg) diff --git a/redbot/cogs/audio/core/commands/controller.py b/redbot/cogs/audio/core/commands/controller.py new file mode 100644 index 000000000..b436b212f --- /dev/null +++ b/redbot/cogs/audio/core/commands/controller.py @@ -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 + ) diff --git a/redbot/cogs/audio/core/commands/equalizer.py b/redbot/cogs/audio/core/commands/equalizer.py new file mode 100644 index 000000000..72d44b1b1 --- /dev/null +++ b/redbot/cogs/audio/core/commands/equalizer.py @@ -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) diff --git a/redbot/cogs/audio/core/commands/llset.py b/redbot/cogs/audio/core/commands/llset.py new file mode 100644 index 000000000..0eccecde2 --- /dev/null +++ b/redbot/cogs/audio/core/commands/llset.py @@ -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 + ), + ) diff --git a/redbot/cogs/audio/core/commands/localtracks.py b/redbot/cogs/audio/core/commands/localtracks.py new file mode 100644 index 000000000..a4ee38992 --- /dev/null +++ b/redbot/cogs/audio/core/commands/localtracks.py @@ -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) diff --git a/redbot/cogs/audio/core/commands/miscellaneous.py b/redbot/cogs/audio/core/commands/miscellaneous.py new file mode 100644 index 000000000..620778def --- /dev/null +++ b/redbot/cogs/audio/core/commands/miscellaneous.py @@ -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 + ) diff --git a/redbot/cogs/audio/core/commands/player.py b/redbot/cogs/audio/core/commands/player.py new file mode 100644 index 000000000..465d4bb3e --- /dev/null +++ b/redbot/cogs/audio/core/commands/player.py @@ -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 ` to queue all tracks found on YouTube. + Use `[p]search sc` 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) diff --git a/redbot/cogs/audio/core/commands/playlists.py b/redbot/cogs/audio/core/commands/playlists.py new file mode 100644 index 000000000..5e2200d50 --- /dev/null +++ b/redbot/cogs/audio/core/commands/playlists.py @@ -0,0 +1,1951 @@ +import asyncio +import json +import logging +import math +import os +import tarfile +from io import BytesIO +from typing import Optional, cast + +import discord +import lavalink +from redbot.core.utils import AsyncIter + +from redbot.core import commands +from redbot.core.data_manager import cog_data_path +from redbot.core.utils.chat_formatting import bold, pagify +from redbot.core.utils.menus import DEFAULT_CONTROLS, menu +from redbot.core.utils.predicates import MessagePredicate + +from ...apis.api_utils import FakePlaylist +from ...apis.playlist_interface import create_playlist, delete_playlist, get_all_playlist, Playlist +from ...audio_dataclasses import LocalPath, Query +from ...audio_logging import IS_DEBUG, debug_exc_log +from ...converters import ComplexScopeParser, ScopeParser +from ...errors import MissingGuild, TooManyMatches +from ...utils import PlaylistScope +from ..abc import MixinMeta +from ..cog_utils import CompositeMetaClass, LazyGreedyConverter, PlaylistConverter, _ + +log = logging.getLogger("red.cogs.Audio.cog.Commands.playlist") + + +class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): + @commands.group(name="playlist") + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True, add_reactions=True) + async def command_playlist(self, ctx: commands.Context): + """Playlist configuration options. + + Scope info: + ​ ​ ​ ​ **Global**: + ​ ​ ​ ​ ​ ​ ​ ​ Visible to all users of this bot. + ​ ​ ​ ​ ​ ​ ​ ​ Only editable by bot owner. + ​ ​ ​ ​ **Guild**: + ​ ​ ​ ​ ​ ​ ​ ​ Visible to all users in this guild. + ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner, guild owner, guild admins, guild mods, DJ role and playlist creator. + ​ ​ ​ ​ **User**: + ​ ​ ​ ​ ​ ​ ​ ​ Visible to all bot users, if --author is passed. + ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner and creator. + + """ + + @command_playlist.command( + name="append", usage=" [args]" + ) + async def command_playlist_append( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + query: LazyGreedyConverter, + *, + scope_data: ScopeParser = None, + ): + """Add a track URL, playlist link, or quick search to a playlist. + + The track(s) will be appended to the end of the playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist append playlist_name_OR_id track_name_OR_url [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist append MyGuildPlaylist Hello by Adele` + ​ ​ ​ ​ `[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global` + ​ ​ ​ ​ `[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + (scope, author, guild, specified_user) = scope_data + if not await self._playlist_check(ctx): + return + try: + (playlist, playlist_arg, scope) = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + return await self.send_embed_msg(ctx, title=str(e)) + if playlist is None: + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), + ) + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + player = lavalink.get_player(ctx.guild.id) + to_append = await self.fetch_playlist_tracks( + ctx, player, Query.process_input(query, self.local_folder_current_path) + ) + + if isinstance(to_append, discord.Message): + return None + + if not to_append: + return await self.send_embed_msg( + ctx, title=_("Could not find a track matching your query.") + ) + track_list = playlist.tracks + current_count = len(track_list) + to_append_count = len(to_append) + tracks_obj_list = playlist.tracks_obj + not_added = 0 + if current_count + to_append_count > 10000: + to_append = to_append[: 10000 - current_count] + not_added = to_append_count - len(to_append) + to_append_count = len(to_append) + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + appended = 0 + + if to_append and to_append_count == 1: + to = lavalink.Track(to_append[0]) + if to in tracks_obj_list: + return await self.send_embed_msg( + ctx, + title=_("Skipping track"), + description=_( + "{track} is already in {playlist} (`{id}`) [**{scope}**]." + ).format( + track=to.title, playlist=playlist.name, id=playlist.id, scope=scope_name + ), + footer=_("Playlist limit reached: Could not add track.").format(not_added) + if not_added > 0 + else None, + ) + else: + appended += 1 + if to_append and to_append_count > 1: + to_append_temp = [] + async for t in AsyncIter(to_append): + to = lavalink.Track(t) + if to not in tracks_obj_list: + appended += 1 + to_append_temp.append(t) + to_append = to_append_temp + if appended > 0: + track_list.extend(to_append) + update = {"tracks": track_list, "url": None} + await playlist.edit(update) + + if to_append_count == 1 and appended == 1: + track_title = to_append[0]["info"]["title"] + return await self.send_embed_msg( + ctx, + title=_("Track added"), + description=_("{track} appended to {playlist} (`{id}`) [**{scope}**].").format( + track=track_title, playlist=playlist.name, id=playlist.id, scope=scope_name + ), + ) + + desc = _("{num} tracks appended to {playlist} (`{id}`) [**{scope}**].").format( + num=appended, playlist=playlist.name, id=playlist.id, scope=scope_name + ) + if to_append_count > appended: + diff = to_append_count - appended + desc += _("\n{existing} {plural} already in the playlist and were skipped.").format( + existing=diff, plural=_("tracks are") if diff != 1 else _("track is") + ) + + embed = discord.Embed(title=_("Playlist Modified"), description=desc) + await self.send_embed_msg( + ctx, + embed=embed, + footer=_("Playlist limit reached: Could not add track.").format(not_added) + if not_added > 0 + else None, + ) + + @commands.cooldown(1, 150, commands.BucketType.member) + @command_playlist.command( + name="copy", usage=" [args]", cooldown_after_parsing=True + ) + async def command_playlist_copy( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ComplexScopeParser = None, + ): + + """Copy a playlist from one scope to another. + + **Usage**: + ​ ​ ​ ​ `[p]playlist copy playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --from-scope + ​ ​ ​ ​ ​ ​ ​ ​ --from-author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --from-guild [guild] **Only the bot owner can use this** + + ​ ​ ​ ​ ​ ​ ​ ​ --to-scope + ​ ​ ​ ​ ​ ​ ​ ​ --to-author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --to-guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global` + ​ ​ ​ ​ `[p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 --to-scope User` + ​ ​ ​ ​ `[p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666 --to-scope Guild --to-guild Red - Discord Bot` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [ + PlaylistScope.GUILD.value, + ctx.author, + ctx.guild, + False, + PlaylistScope.GUILD.value, + ctx.author, + ctx.guild, + False, + ] + ( + from_scope, + from_author, + from_guild, + specified_from_user, + to_scope, + to_author, + to_guild, + specified_to_user, + ) = scope_data + to_scope = to_scope or PlaylistScope.GUILD.value + try: + from_playlist, playlist_arg, from_scope = await self.get_playlist_match( + ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + + if from_playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + + temp_playlist = cast(Playlist, FakePlaylist(to_author.id, to_scope)) + if not await self.can_manage_playlist(to_scope, temp_playlist, ctx, to_author, to_guild): + ctx.command.reset_cooldown(ctx) + return + + to_playlist = await create_playlist( + ctx, + self.playlist_api, + to_scope, + from_playlist.name, + from_playlist.url, + from_playlist.tracks, + to_author, + to_guild, + ) + if to_scope == PlaylistScope.GLOBAL.value: + to_scope_name = _("the Global") + elif to_scope == PlaylistScope.USER.value: + to_scope_name = to_author + else: + to_scope_name = to_guild + + if from_scope == PlaylistScope.GLOBAL.value: + from_scope_name = _("the Global") + elif from_scope == PlaylistScope.USER.value: + from_scope_name = from_author + else: + from_scope_name = from_guild + + return await self.send_embed_msg( + ctx, + title=_("Playlist Copied"), + description=_( + "Playlist {name} (`{from_id}`) copied from {from_scope} to {to_scope} (`{to_id}`)." + ).format( + name=from_playlist.name, + from_id=from_playlist.id, + from_scope=self.humanize_scope(from_scope, ctx=from_scope_name), + to_scope=self.humanize_scope(to_scope, ctx=to_scope_name), + to_id=to_playlist.id, + ), + ) + + @command_playlist.command(name="create", usage=" [args]") + async def command_playlist_create( + self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None + ): + """Create an empty playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist create playlist_name [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist create MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist create MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist create MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope = scope or PlaylistScope.GUILD.value + temp_playlist = cast(Playlist, FakePlaylist(author.id, scope)) + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return + playlist_name = playlist_name.split(" ")[0].strip('"')[:32] + if playlist_name.isnumeric(): + return await self.send_embed_msg( + ctx, + title=_("Invalid Playlist Name"), + description=_( + "Playlist names must be a single word (up to 32 " + "characters) and not numbers only." + ), + ) + playlist = await create_playlist( + ctx, self.playlist_api, scope, playlist_name, None, None, author, guild + ) + return await self.send_embed_msg( + ctx, + title=_("Playlist Created"), + description=_("Empty playlist {name} (`{id}`) [**{scope}**] created.").format( + name=playlist.name, id=playlist.id, scope=scope_name + ), + ) + + @command_playlist.command(name="delete", aliases=["del"], usage=" [args]") + async def command_playlist_delete( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Delete a saved playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist delete playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist delete MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist delete MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist delete MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + return await self.send_embed_msg(ctx, title=str(e)) + if playlist is None: + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + await delete_playlist( + self.bot, + self.playlist_api, + scope, + playlist.id, + guild or ctx.guild, + author or ctx.author, + ) + + await self.send_embed_msg( + ctx, + title=_("Playlist Deleted"), + description=_("{name} (`{id}`) [**{scope}**] playlist deleted.").format( + name=playlist.name, id=playlist.id, scope=scope_name + ), + ) + + @commands.cooldown(1, 30, commands.BucketType.member) + @command_playlist.command( + name="dedupe", usage=" [args]", cooldown_after_parsing=True + ) + async def command_playlist_remdupe( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Remove duplicate tracks from a saved playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist dedupe playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist dedupe MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist dedupe MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist dedupe MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + async with ctx.typing(): + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format( + arg=playlist_arg + ), + ) + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + ctx.command.reset_cooldown(ctx) + return + + track_objects = playlist.tracks_obj + original_count = len(track_objects) + unique_tracks = set() + unique_tracks_add = unique_tracks.add + track_objects = [ + x for x in track_objects if not (x in unique_tracks or unique_tracks_add(x)) + ] + + tracklist = [] + async for track in AsyncIter(track_objects): + 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 + tracklist.append(track_obj) + + final_count = len(tracklist) + if original_count - final_count != 0: + await playlist.edit({"tracks": tracklist}) + await self.send_embed_msg( + ctx, + title=_("Playlist Modified"), + description=_( + "Removed {track_diff} duplicated " + "tracks from {name} (`{id}`) [**{scope}**] playlist." + ).format( + name=playlist.name, + id=playlist.id, + track_diff=original_count - final_count, + scope=scope_name, + ), + ) + else: + await self.send_embed_msg( + ctx, + title=_("Playlist Has Not Been Modified"), + description=_( + "{name} (`{id}`) [**{scope}**] playlist has no duplicate tracks." + ).format(name=playlist.name, id=playlist.id, scope=scope_name), + ) + + @command_playlist.command( + name="download", + usage=" [v2=False] [args]", + cooldown_after_parsing=True, + ) + @commands.is_owner() + @commands.bot_has_permissions(attach_files=True) + @commands.cooldown(1, 30, commands.BucketType.guild) + async def command_playlist_download( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + v2: Optional[bool] = False, + *, + scope_data: ScopeParser = None, + ): + """Download a copy of a playlist. + + These files can be used with the `[p]playlist upload` command. + Red v2-compatible playlists can be generated by passing True + for the v2 variable. + + **Usage**: + ​ ​ ​ ​ `[p]playlist download playlist_name_OR_id [v2=True_OR_False] [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist download MyGuildPlaylist True` + ​ ​ ​ ​ `[p]playlist download MyGlobalPlaylist False --scope Global` + ​ ​ ​ ​ `[p]playlist download MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + if playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + + schema = 2 + version = "v3" if v2 is False else "v2" + + if not playlist.tracks: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=_("That playlist has no tracks.")) + if version == "v2": + v2_valid_urls = ["https://www.youtube.com/watch?v=", "https://soundcloud.com/"] + song_list = [] + async for track in AsyncIter(playlist.tracks): + if track["info"]["uri"].startswith(tuple(v2_valid_urls)): + song_list.append(track["info"]["uri"]) + playlist_data = { + "author": playlist.author, + "link": playlist.url, + "playlist": song_list, + "name": playlist.name, + } + file_name = playlist.name + else: + # TODO: Keep new playlists backwards compatible, Remove me in a few releases + playlist_data = playlist.to_json() + playlist_songs_backwards_compatible = [ + track["info"]["uri"] for track in playlist.tracks + ] + playlist_data["playlist"] = playlist_songs_backwards_compatible + playlist_data["link"] = playlist.url + file_name = playlist.id + playlist_data.update({"schema": schema, "version": version}) + playlist_data = json.dumps(playlist_data).encode("utf-8") + to_write = BytesIO() + to_write.write(playlist_data) + to_write.seek(0) + if to_write.getbuffer().nbytes > ctx.guild.filesize_limit - 10000: + datapath = cog_data_path(raw_name="Audio") + temp_file = datapath / f"{file_name}.txt" + temp_tar = datapath / f"{file_name}.tar.gz" + with temp_file.open("wb") as playlist_file: + playlist_file.write(to_write.read()) + + with tarfile.open(str(temp_tar), "w:gz") as tar: + tar.add( + str(temp_file), arcname=str(temp_file.relative_to(datapath)), recursive=False + ) + try: + if os.path.getsize(str(temp_tar)) > ctx.guild.filesize_limit - 10000: + await ctx.send(_("This playlist is too large to be send in this server.")) + else: + await ctx.send( + content=_("Playlist is too large, here is the compressed version."), + file=discord.File(str(temp_tar)), + ) + except Exception as exc: + debug_exc_log(log, exc, "Failed to send playlist to channel") + temp_file.unlink() + temp_tar.unlink() + else: + await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt")) + to_write.close() + + @commands.cooldown(1, 10, commands.BucketType.member) + @command_playlist.command( + name="info", usage=" [args]", cooldown_after_parsing=True + ) + async def command_playlist_info( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Retrieve information from a saved playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist info playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist info MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist info MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist info MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + + if playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + track_len = len(playlist.tracks) + + msg = "​" + if track_len > 0: + spaces = "\N{EN SPACE}" * (len(str(len(playlist.tracks))) + 2) + async for track_idx, track in AsyncIter(playlist.tracks).enumerate(start=1): + query = Query.process_input(track["info"]["uri"], self.local_folder_current_path) + if query.is_local: + if track["info"]["title"] != "Unknown title": + msg += "`{}.` **{} - {}**\n{}{}\n".format( + track_idx, + track["info"]["author"], + track["info"]["title"], + spaces, + query.to_string_user(), + ) + else: + msg += "`{}.` {}\n".format(track_idx, query.to_string_user()) + else: + msg += "`{}.` **[{}]({})**\n".format( + track_idx, track["info"]["title"], track["info"]["uri"] + ) + + else: + msg = "No tracks." + + if not playlist.url: + embed_title = _("Playlist info for {playlist_name} (`{id}`) [**{scope}**]:\n").format( + playlist_name=playlist.name, id=playlist.id, scope=scope_name + ) + else: + embed_title = _( + "Playlist info for {playlist_name} (`{id}`) [**{scope}**]:\nURL: {url}" + ).format( + playlist_name=playlist.name, url=playlist.url, id=playlist.id, scope=scope_name + ) + + page_list = [] + pages = list(pagify(msg, delims=["\n"], page_length=2000)) + total_pages = len(pages) + async for numb, page in AsyncIter(pages).enumerate(start=1): + embed = discord.Embed( + colour=await ctx.embed_colour(), title=embed_title, description=page + ) + author_obj = self.bot.get_user(playlist.author) or playlist.author or _("Unknown") + embed.set_footer( + text=_("Page {page}/{pages} | Author: {author_name} | {num} track(s)").format( + author_name=author_obj, num=track_len, pages=total_pages, page=numb + ) + ) + page_list.append(embed) + await menu(ctx, page_list, DEFAULT_CONTROLS) + + @commands.cooldown(1, 15, commands.BucketType.guild) + @command_playlist.command(name="list", usage="[args]", cooldown_after_parsing=True) + async def command_playlist_list( + self, ctx: commands.Context, *, scope_data: ScopeParser = None + ): + """List saved playlists. + + **Usage**: + ​ ​ ​ ​ `[p]playlist list [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist list` + ​ ​ ​ ​ `[p]playlist list --scope Global` + ​ ​ ​ ​ `[p]playlist list --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + if scope is None: + + global_matches = await get_all_playlist( + scope=PlaylistScope.GLOBAL.value, + bot=self.bot, + guild=guild, + author=author, + specified_user=specified_user, + playlist_api=self.playlist_api, + ) + guild_matches = await get_all_playlist( + scope=PlaylistScope.GUILD.value, + bot=self.bot, + guild=guild, + author=author, + specified_user=specified_user, + playlist_api=self.playlist_api, + ) + user_matches = await get_all_playlist( + scope=PlaylistScope.USER.value, + bot=self.bot, + guild=guild, + author=author, + specified_user=specified_user, + playlist_api=self.playlist_api, + ) + playlists = [*global_matches, *guild_matches, *user_matches] + name = None + if not playlists: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("No saved playlists available in this server.").format( + scope=name + ), + ) + else: + try: + playlists = await get_all_playlist( + scope=scope, + bot=self.bot, + guild=guild, + author=author, + specified_user=specified_user, + playlist_api=self.playlist_api, + ) + except MissingGuild: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), + ) + + if scope == PlaylistScope.GUILD.value: + name = f"{guild.name}" + elif scope == PlaylistScope.USER.value: + name = f"{author}" + else: + name = _("Global") + + if not playlists and specified_user: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("No saved playlists for {scope} created by {author}.").format( + scope=name, author=author + ), + ) + elif not playlists: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("No saved playlists for {scope}.").format(scope=name), + ) + + playlist_list = [] + space = "\N{EN SPACE}" + async for playlist in AsyncIter(playlists): + playlist_list.append( + ("\n" + space * 4).join( + ( + bold(playlist.name), + _("ID: {id}").format(id=playlist.id), + _("Tracks: {num}").format(num=len(playlist.tracks)), + _("Author: {name}").format( + name=self.bot.get_user(playlist.author) + or playlist.author + or _("Unknown") + ), + _("Scope: {scope}\n").format(scope=self.humanize_scope(playlist.scope)), + ) + ) + ) + abc_names = sorted(playlist_list, key=str.lower) + len_playlist_list_pages = math.ceil(len(abc_names) / 5) + playlist_embeds = [] + + async for page_num in AsyncIter(range(1, len_playlist_list_pages + 1)): + embed = await self._build_playlist_list_page(ctx, page_num, abc_names, name) + playlist_embeds.append(embed) + await menu(ctx, playlist_embeds, DEFAULT_CONTROLS) + + @command_playlist.command(name="queue", usage=" [args]", cooldown_after_parsing=True) + @commands.cooldown(1, 300, commands.BucketType.member) + async def command_playlist_queue( + self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None + ): + """Save the queue to a playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist queue playlist_name [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist queue MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist queue MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist queue MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + async with ctx.typing(): + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope = scope or PlaylistScope.GUILD.value + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + temp_playlist = cast(Playlist, FakePlaylist(author.id, scope)) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + ctx.command.reset_cooldown(ctx) + return + playlist_name = playlist_name.split(" ")[0].strip('"')[:32] + if playlist_name.isnumeric(): + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Invalid Playlist Name"), + description=_( + "Playlist names must be a single word " + "(up to 32 characters) and not numbers only." + ), + ) + if not self._player_check(ctx): + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=_("Nothing playing.")) + + player = lavalink.get_player(ctx.guild.id) + if not player.queue: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=_("There's nothing in the queue.")) + tracklist = [] + np_song = self.get_track_json(player, "np") + tracklist.append(np_song) + queue_length = len(player.queue) + to_add = player.queue + not_added = 0 + if queue_length > 10000: + to_add = player.queue[:10000] + not_added = queue_length - 10000 + + async for track in AsyncIter(to_add): + queue_idx = player.queue.index(track) + track_obj = self.get_track_json(player, queue_idx) + tracklist.append(track_obj) + playlist = await create_playlist( + ctx, self.playlist_api, scope, playlist_name, None, tracklist, author, guild + ) + await self.send_embed_msg( + ctx, + title=_("Playlist Created"), + description=_( + "Playlist {name} (`{id}`) [**{scope}**] " + "saved from current queue: {num} tracks added." + ).format( + name=playlist.name, num=len(playlist.tracks), id=playlist.id, scope=scope_name + ), + footer=_("Playlist limit reached: Could not add {} tracks.").format(not_added) + if not_added > 0 + else None, + ) + + @command_playlist.command(name="remove", usage=" [args]") + async def command_playlist_remove( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + url: str, + *, + scope_data: ScopeParser = None, + ): + """Remove a track from a playlist by url. + + **Usage**: + ​ ​ ​ ​ `[p]playlist remove playlist_name_OR_id url [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU` + ​ ​ ​ ​ `[p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope Global` + ​ ​ ​ ​ `[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + return await self.send_embed_msg(ctx, title=str(e)) + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if playlist is None: + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + + track_list = playlist.tracks + clean_list = [track for track in track_list if url != track["info"]["uri"]] + if len(track_list) == len(clean_list): + return await self.send_embed_msg(ctx, title=_("URL not in playlist.")) + del_count = len(track_list) - len(clean_list) + if not clean_list: + await delete_playlist( + playlist_api=self.playlist_api, + bot=self.bot, + scope=playlist.scope, + playlist_id=playlist.id, + guild=guild, + author=playlist.author, + ) + return await self.send_embed_msg(ctx, title=_("No tracks left, removing playlist.")) + update = {"tracks": clean_list, "url": None} + await playlist.edit(update) + if del_count > 1: + await self.send_embed_msg( + ctx, + title=_("Playlist Modified"), + description=_( + "{num} entries have been removed " + "from the playlist {playlist_name} (`{id}`) [**{scope}**]." + ).format( + num=del_count, playlist_name=playlist.name, id=playlist.id, scope=scope_name + ), + ) + else: + await self.send_embed_msg( + ctx, + title=_("Playlist Modified"), + description=_( + "The track has been removed from the playlist: " + "{playlist_name} (`{id}`) [**{scope}**]." + ).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name), + ) + + @command_playlist.command( + name="save", usage=" [args]", cooldown_after_parsing=True + ) + @commands.cooldown(1, 60, commands.BucketType.member) + async def command_playlist_save( + self, + ctx: commands.Context, + playlist_name: str, + playlist_url: str, + *, + scope_data: ScopeParser = None, + ): + """Save a playlist from a url. + + **Usage**: + ​ ​ ​ ​ `[p]playlist save name url [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist save MyGuildPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM` + ​ ​ ​ ​ `[p]playlist save MyGlobalPlaylist https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global` + ​ ​ ​ ​ `[p]playlist save MyPersonalPlaylist https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope = scope or PlaylistScope.GUILD.value + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + temp_playlist = cast(Playlist, FakePlaylist(author.id, scope)) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return ctx.command.reset_cooldown(ctx) + playlist_name = playlist_name.split(" ")[0].strip('"')[:32] + if playlist_name.isnumeric(): + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Invalid Playlist Name"), + description=_( + "Playlist names must be a single word (up to 32 " + "characters) and not numbers only." + ), + ) + if not await self._playlist_check(ctx): + ctx.command.reset_cooldown(ctx) + return + player = lavalink.get_player(ctx.guild.id) + tracklist = await self.fetch_playlist_tracks( + ctx, player, Query.process_input(playlist_url, self.local_folder_current_path) + ) + if isinstance(tracklist, discord.Message): + return None + if tracklist is not None: + playlist_length = len(tracklist) + not_added = 0 + if playlist_length > 10000: + tracklist = tracklist[:10000] + not_added = playlist_length - 10000 + + playlist = await create_playlist( + ctx, + self.playlist_api, + scope, + playlist_name, + playlist_url, + tracklist, + author, + guild, + ) + if playlist is not None: + return await self.send_embed_msg( + ctx, + title=_("Playlist Created"), + description=_( + "Playlist {name} (`{id}`) [**{scope}**] saved: {num} tracks added." + ).format( + name=playlist.name, num=len(tracklist), id=playlist.id, scope=scope_name + ), + footer=_("Playlist limit reached: Could not add {} tracks.").format(not_added) + if not_added > 0 + else None, + ) + else: + return await self.send_embed_msg( + ctx, + title=_("Playlist Couldn't be created"), + description=_("Unable to create your playlist."), + ) + + @commands.cooldown(1, 30, commands.BucketType.member) + @command_playlist.command( + name="start", + aliases=["play"], + usage=" [args]", + cooldown_after_parsing=True, + ) + async def command_playlist_start( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Load a playlist into the queue. + + **Usage**: + ​ ​ ​ ​` [p]playlist start playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist start MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist start MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist start MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + 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) + await self.send_embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to start playing playlists."), + ) + return False + + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + if playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), + ) + + if not await self._playlist_check(ctx): + ctx.command.reset_cooldown(ctx) + return + jukebox_price = await self.config.guild(ctx.guild).jukebox_price() + if not await self.maybe_charge_requester(ctx, jukebox_price): + ctx.command.reset_cooldown(ctx) + return + maxlength = await self.config.guild(ctx.guild).maxlength() + author_obj = self.bot.get_user(ctx.author.id) + track_len = 0 + try: + player = lavalink.get_player(ctx.guild.id) + tracks = playlist.tracks_obj + 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 + query = Query.process_input(track.uri, self.local_folder_current_path) + if query.is_local: + local_path = LocalPath(track.uri, self.local_folder_current_path) + if not await self.localtracks_folder_exists(ctx): + pass + if not local_path.exists() and not local_path.is_file(): + continue + if maxlength > 0 and not self.is_track_too_long(track.length, maxlength): + continue + + player.add(author_obj, track) + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, track, ctx.author + ) + track_len += 1 + 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 = "" + if scope == PlaylistScope.GUILD.value: + scope_name = f"{guild.name}" + elif scope == PlaylistScope.USER.value: + scope_name = f"{author}" + else: + scope_name = "Global" + + embed = discord.Embed( + title=_("Playlist Enqueued"), + description=_( + "{name} - (`{id}`) [**{scope}**]\nAdded {num} " + "tracks to the queue.{maxlength_msg}" + ).format( + num=track_len, + maxlength_msg=maxlength_msg, + name=playlist.name, + id=playlist.id, + scope=scope_name, + ), + ) + await self.send_embed_msg(ctx, embed=embed) + if not player.current: + await player.play() + return + except RuntimeError: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( + id=playlist_arg, scope=self.humanize_scope(scope, the=True) + ), + ) + except MissingGuild: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), + ) + except TypeError: + if playlist: + return await ctx.invoke(self.command_play, query=playlist.url) + + @commands.cooldown(1, 60, commands.BucketType.member) + @command_playlist.command( + name="update", usage=" [args]", cooldown_after_parsing=True + ) + async def command_playlist_update( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Updates all tracks in a playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist update playlist_name_OR_id [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist update MyGuildPlaylist` + ​ ​ ​ ​ `[p]playlist update MyGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist update MyPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + + if playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + + if not await self._playlist_check(ctx): + ctx.command.reset_cooldown(ctx) + return + try: + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + if playlist.url: + player = lavalink.get_player(ctx.guild.id) + added, removed, playlist = await self._maybe_update_playlist(ctx, player, playlist) + else: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Invalid Playlist"), + description=_("Custom playlists cannot be updated."), + ) + except RuntimeError: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( + id=playlist_arg, scope=self.humanize_scope(scope, the=True) + ), + ) + except MissingGuild: + return await self.send_embed_msg( + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), + ) + else: + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if added or removed: + _colour = await ctx.embed_colour() + removed_embeds = [] + added_embeds = [] + total_added = len(added) + total_removed = len(removed) + total_pages = math.ceil(total_removed / 10) + math.ceil(total_added / 10) + page_count = 0 + if removed: + removed_text = "" + async for i, track in AsyncIter(removed).enumerate(start=1): + if len(track.title) > 40: + track_title = str(track.title).replace("[", "") + track_title = "{}...".format((track_title[:40]).rstrip(" ")) + else: + track_title = track.title + removed_text += f"`{i}.` **[{track_title}]({track.uri})**\n" + if i % 10 == 0 or i == total_removed: + page_count += 1 + embed = discord.Embed( + title=_("Tracks removed"), colour=_colour, description=removed_text + ) + text = _("Page {page_num}/{total_pages}").format( + page_num=page_count, total_pages=total_pages + ) + embed.set_footer(text=text) + removed_embeds.append(embed) + removed_text = "" + if added: + added_text = "" + async for i, track in AsyncIter(added).enumerate(start=1): + if len(track.title) > 40: + track_title = str(track.title).replace("[", "") + track_title = "{}...".format((track_title[:40]).rstrip(" ")) + else: + track_title = track.title + added_text += f"`{i}.` **[{track_title}]({track.uri})**\n" + if i % 10 == 0 or i == total_added: + page_count += 1 + embed = discord.Embed( + title=_("Tracks added"), colour=_colour, description=added_text + ) + text = _("Page {page_num}/{total_pages}").format( + page_num=page_count, total_pages=total_pages + ) + embed.set_footer(text=text) + added_embeds.append(embed) + added_text = "" + embeds = removed_embeds + added_embeds + await menu(ctx, embeds, DEFAULT_CONTROLS) + else: + return await self.send_embed_msg( + ctx, + title=_("Playlist Has Not Been Modified"), + description=_("No changes for {name} (`{id}`) [**{scope}**].").format( + id=playlist.id, name=playlist.name, scope=scope_name + ), + ) + + @command_playlist.command(name="upload", usage="[args]") + @commands.is_owner() + async def command_playlist_upload( + self, ctx: commands.Context, *, scope_data: ScopeParser = None + ): + """Uploads a playlist file as a playlist for the bot. + + V2 and old V3 playlist will be slow. + V3 Playlist made with `[p]playlist download` will load a lot faster. + + **Usage**: + ​ ​ ​ ​ `[p]playlist upload [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist upload` + ​ ​ ​ ​ `[p]playlist upload --scope Global` + ​ ​ ​ ​ `[p]playlist upload --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope = scope or PlaylistScope.GUILD.value + temp_playlist = cast(Playlist, FakePlaylist(author.id, scope)) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return + + if not await self._playlist_check(ctx): + return + player = lavalink.get_player(ctx.guild.id) + + if not ctx.message.attachments: + await self.send_embed_msg( + ctx, + title=_( + "Please upload the playlist file. Any other message will cancel this " + "operation." + ), + ) + try: + file_message = await self.bot.wait_for( + "message", timeout=30.0, check=MessagePredicate.same_context(ctx) + ) + except asyncio.TimeoutError: + return await self.send_embed_msg( + ctx, title=_("No file detected, try again later.") + ) + else: + file_message = ctx.message + try: + file_url = file_message.attachments[0].url + except IndexError: + return await self.send_embed_msg(ctx, title=_("Upload cancelled.")) + file_suffix = file_url.rsplit(".", 1)[1] + if file_suffix != "txt": + return await self.send_embed_msg( + ctx, title=_("Only Red playlist files can be uploaded.") + ) + try: + async with self.session.request("GET", file_url) as r: + uploaded_playlist = await r.json(content_type="text/plain", encoding="utf-8") + except UnicodeDecodeError: + return await self.send_embed_msg(ctx, title=_("Not a valid playlist file.")) + + new_schema = uploaded_playlist.get("schema", 1) >= 2 + version = uploaded_playlist.get("version", "v2") + + if new_schema and version == "v3": + uploaded_playlist_url = uploaded_playlist.get("playlist_url", None) + track_list = uploaded_playlist.get("tracks", []) + else: + uploaded_playlist_url = uploaded_playlist.get("link", None) + track_list = uploaded_playlist.get("playlist", []) + if len(track_list) > 10000: + return await self.send_embed_msg(ctx, title=_("This playlist is too large.")) + uploaded_playlist_name = uploaded_playlist.get( + "name", (file_url.split("/")[6]).split(".")[0] + ) + if self.api_interface is not None and ( + not uploaded_playlist_url + or not self.match_yt_playlist(uploaded_playlist_url) + or not ( + await self.api_interface.fetch_track( + ctx, + player, + Query.process_input(uploaded_playlist_url, self.local_folder_current_path), + ) + )[0].tracks + ): + if version == "v3": + return await self._load_v3_playlist( + ctx, + scope, + uploaded_playlist_name, + uploaded_playlist_url, + track_list, + author, + guild, + ) + return await self._load_v2_playlist( + ctx, + track_list, + player, + uploaded_playlist_url, + uploaded_playlist_name, + scope, + author, + guild, + ) + return await ctx.invoke( + self.command_playlist_save, + playlist_name=uploaded_playlist_name, + playlist_url=uploaded_playlist_url, + scope_data=(scope, author, guild, specified_user), + ) + + @commands.cooldown(1, 60, commands.BucketType.member) + @command_playlist.command( + name="rename", usage=" [args]", cooldown_after_parsing=True + ) + async def command_playlist_rename( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + new_name: str, + *, + scope_data: ScopeParser = None, + ): + """Rename an existing playlist. + + **Usage**: + ​ ​ ​ ​ `[p]playlist rename playlist_name_OR_id new_name [args]` + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + **Scope** is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + **Author** can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + **Guild** can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ `[p]playlist rename MyGuildPlaylist RenamedGuildPlaylist` + ​ ​ ​ ​ `[p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global` + ​ ​ ​ ​ `[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User` + """ + if self.playlist_api is None: + return await self.send_embed_msg( + ctx, + title=_("Playlists Are Not Available"), + description=_("The playlist section of Audio is currently unavailable"), + footer=discord.Embed.Empty + if not await self.bot.is_owner(ctx.author) + else _("Check your logs."), + ) + if scope_data is None: + scope_data = [None, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + new_name = new_name.split(" ")[0].strip('"')[:32] + if new_name.isnumeric(): + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Invalid Playlist Name"), + description=_( + "Playlist names must be a single word (up to 32 " + "characters) and not numbers only." + ), + ) + + try: + playlist, playlist_arg, scope = await self.get_playlist_match( + ctx, playlist_matches, scope, author, guild, specified_user + ) + except TooManyMatches as e: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg(ctx, title=str(e)) + if playlist is None: + ctx.command.reset_cooldown(ctx) + return await self.send_embed_msg( + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), + ) + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + ctx.command.reset_cooldown(ctx) + return + scope_name = self.humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + old_name = playlist.name + update = {"name": new_name} + await playlist.edit(update) + msg = _("'{old}' playlist has been renamed to '{new}' (`{id}`) [**{scope}**]").format( + old=bold(old_name), new=bold(playlist.name), id=playlist.id, scope=scope_name + ) + await self.send_embed_msg(ctx, title=_("Playlist Modified"), description=msg) diff --git a/redbot/cogs/audio/core/commands/queue.py b/redbot/cogs/audio/core/commands/queue.py new file mode 100644 index 000000000..43b414ba2 --- /dev/null +++ b/redbot/cogs/audio/core/commands/queue.py @@ -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.")) diff --git a/redbot/cogs/audio/core/events/__init__.py b/redbot/cogs/audio/core/events/__init__.py new file mode 100644 index 000000000..e502a0d5f --- /dev/null +++ b/redbot/cogs/audio/core/events/__init__.py @@ -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""" diff --git a/redbot/cogs/audio/core/events/cog.py b/redbot/cogs/audio/core/events/cog.py new file mode 100644 index 000000000..6267331b8 --- /dev/null +++ b/redbot/cogs/audio/core/events/cog.py @@ -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() diff --git a/redbot/cogs/audio/core/events/dpy.py b/redbot/cogs/audio/core/events/dpy.py new file mode 100644 index 000000000..8a8e67cbb --- /dev/null +++ b/redbot/cogs/audio/core/events/dpy.py @@ -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 diff --git a/redbot/cogs/audio/core/events/lavalink.py b/redbot/cogs/audio/core/events/lavalink.py new file mode 100644 index 000000000..45fd7cb02 --- /dev/null +++ b/redbot/cogs/audio/core/events/lavalink.py @@ -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() diff --git a/redbot/cogs/audio/core/events/red.py b/redbot/cogs/audio/core/events/red.py new file mode 100644 index 000000000..562f80432 --- /dev/null +++ b/redbot/cogs/audio/core/events/red.py @@ -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) diff --git a/redbot/cogs/audio/core/tasks/__init__.py b/redbot/cogs/audio/core/tasks/__init__.py new file mode 100644 index 000000000..64757740a --- /dev/null +++ b/redbot/cogs/audio/core/tasks/__init__.py @@ -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""" diff --git a/redbot/cogs/audio/core/tasks/lavalink.py b/redbot/cogs/audio/core/tasks/lavalink.py new file mode 100644 index 000000000..1be63aa89 --- /dev/null +++ b/redbot/cogs/audio/core/tasks/lavalink.py @@ -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." + ) diff --git a/redbot/cogs/audio/core/tasks/player.py b/redbot/cogs/audio/core/tasks/player.py new file mode 100644 index 000000000..a880aaeeb --- /dev/null +++ b/redbot/cogs/audio/core/tasks/player.py @@ -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) diff --git a/redbot/cogs/audio/core/tasks/startup.py b/redbot/cogs/audio/core/tasks/startup.py new file mode 100644 index 000000000..0e7417762 --- /dev/null +++ b/redbot/cogs/audio/core/tasks/startup.py @@ -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() diff --git a/redbot/cogs/audio/core/utilities/__init__.py b/redbot/cogs/audio/core/utilities/__init__.py new file mode 100644 index 000000000..b7a88dbc2 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/__init__.py @@ -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""" diff --git a/redbot/cogs/audio/core/utilities/equalizer.py b/redbot/cogs/audio/core/utilities/equalizer.py new file mode 100644 index 000000000..03198608e --- /dev/null +++ b/redbot/cogs/audio/core/utilities/equalizer.py @@ -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 diff --git a/redbot/cogs/audio/core/utilities/formatting.py b/redbot/cogs/audio/core/utilities/formatting.py new file mode 100644 index 000000000..dafd12e67 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/formatting.py @@ -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 diff --git a/redbot/cogs/audio/core/utilities/local_tracks.py b/redbot/cogs/audio/core/utilities/local_tracks.py new file mode 100644 index 000000000..401ac09f1 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/local_tracks.py @@ -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 diff --git a/redbot/cogs/audio/core/utilities/menus/__init__.py b/redbot/cogs/audio/core/utilities/menus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/redbot/cogs/audio/core/utilities/miscellaneous.py b/redbot/cogs/audio/core/utilities/miscellaneous.py new file mode 100644 index 000000000..e8eb84764 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/miscellaneous.py @@ -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) diff --git a/redbot/cogs/audio/core/utilities/player.py b/redbot/cogs/audio/core/utilities/player.py new file mode 100644 index 000000000..823dd6630 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/player.py @@ -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 , 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 diff --git a/redbot/cogs/audio/core/utilities/playlists.py b/redbot/cogs/audio/core/utilities/playlists.py new file mode 100644 index 000000000..d95f9339c --- /dev/null +++ b/redbot/cogs/audio/core/utilities/playlists.py @@ -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") diff --git a/redbot/cogs/audio/core/utilities/queue.py b/redbot/cogs/audio/core/utilities/queue.py new file mode 100644 index 000000000..2726d3682 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/queue.py @@ -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 diff --git a/redbot/cogs/audio/core/utilities/validation.py b/redbot/cogs/audio/core/utilities/validation.py new file mode 100644 index 000000000..b20b9be59 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/validation.py @@ -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 diff --git a/redbot/cogs/audio/databases.py b/redbot/cogs/audio/databases.py deleted file mode 100644 index 6951a91b7..000000000 --- a/redbot/cogs/audio/databases.py +++ /dev/null @@ -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), - }, - ) diff --git a/redbot/cogs/audio/equalizer.py b/redbot/cogs/audio/equalizer.py index 3aea24f1c..3f77a89c7 100644 --- a/redbot/cogs/audio/equalizer.py +++ b/redbot/cogs/audio/equalizer.py @@ -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] diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index b44012d6e..4348720a8 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -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\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\d+)") +_RE_JAVA_VERSION_LINE: Final[Pattern] = re.compile( r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"' ) -_RE_JAVA_SHORT_VERSION = re.compile(r'version "(?P\d+)"') +_RE_JAVA_SHORT_VERSION: Final[Pattern] = re.compile(r'version "(?P\d+)"') class ServerManager: diff --git a/redbot/cogs/audio/sql_statements.py b/redbot/cogs/audio/sql_statements.py index c384f9866..dcf55663c 100644 --- a/redbot/cogs/audio/sql_statements.py +++ b/redbot/cogs/audio/sql_statements.py @@ -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 """ diff --git a/redbot/cogs/audio/utils.py b/redbot/cogs/audio/utils.py index 79a0b00e7..f52bf7ddc 100644 --- a/redbot/cogs/audio/utils.py +++ b/redbot/cogs/audio/utils.py @@ -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")