From 2da9b502d83d64facf9dfd3be6293d6dde6460d3 Mon Sep 17 00:00:00 2001 From: Draper <27962761+Drapersniper@users.noreply.github.com> Date: Mon, 12 Oct 2020 19:39:39 +0100 Subject: [PATCH] Audio Cog - v2.3.0 (#4446) * First commit - Bring everything from dev cog minus NSFW support * Add a toggle for auto deafen * Add a one off Send to Owners * aaaaaaa * Update this to ensure `get_perms` is not called if the API is disabled * Apply suggestions from code review Co-authored-by: Vuks <51289041+Vuks69@users.noreply.github.com> * silence any errors here (in case API is down so it doesnt affect audio) * update the message to tell the mto join the Official Red server. * remove useless sutff, and change dj check order to ensure bot doesnt join VC for non DJ's * ffs * Update redbot/cogs/audio/core/tasks/startup.py Co-authored-by: Twentysix * Aikas Review * Add #3995 in here * update * *sigh* * lock behind owner * to help with debugging * Revert "to help with debugging" This reverts commit 8cbf17be * resolve last review Co-authored-by: Vuks <51289041+Vuks69@users.noreply.github.com> Co-authored-by: Twentysix --- redbot/cogs/audio/apis/api_utils.py | 21 +- redbot/cogs/audio/apis/global_db.py | 151 +++++++++++- redbot/cogs/audio/apis/interface.py | 222 ++++++++++++------ redbot/cogs/audio/apis/local_db.py | 8 +- .../cogs/audio/apis/persist_queue_wrapper.py | 133 +++++++++++ redbot/cogs/audio/apis/playlist_interface.py | 3 +- redbot/cogs/audio/apis/playlist_wrapper.py | 26 +- redbot/cogs/audio/apis/spotify.py | 45 ++-- redbot/cogs/audio/apis/youtube.py | 14 +- redbot/cogs/audio/audio_dataclasses.py | 17 +- redbot/cogs/audio/audio_logging.py | 1 + redbot/cogs/audio/converters.py | 5 +- redbot/cogs/audio/core/__init__.py | 26 +- redbot/cogs/audio/core/abc.py | 34 ++- redbot/cogs/audio/core/cog_utils.py | 36 ++- redbot/cogs/audio/core/commands/audioset.py | 133 ++++++++++- redbot/cogs/audio/core/commands/controller.py | 44 +++- redbot/cogs/audio/core/commands/llset.py | 68 ++++++ .../cogs/audio/core/commands/miscellaneous.py | 4 +- redbot/cogs/audio/core/commands/player.py | 151 +++++++----- redbot/cogs/audio/core/commands/playlists.py | 34 ++- redbot/cogs/audio/core/commands/queue.py | 21 +- redbot/cogs/audio/core/events/cog.py | 26 ++ redbot/cogs/audio/core/events/dpy.py | 103 +++++++- redbot/cogs/audio/core/events/lavalink.py | 25 +- redbot/cogs/audio/core/tasks/lavalink.py | 15 +- redbot/cogs/audio/core/tasks/player.py | 3 + redbot/cogs/audio/core/tasks/startup.py | 104 +++++++- redbot/cogs/audio/core/utilities/__init__.py | 2 + redbot/cogs/audio/core/utilities/equalizer.py | 1 + .../cogs/audio/core/utilities/formatting.py | 73 ++++-- .../cogs/audio/core/utilities/local_tracks.py | 11 +- .../audio/core/utilities/miscellaneous.py | 17 +- redbot/cogs/audio/core/utilities/parsers.py | 35 +++ redbot/cogs/audio/core/utilities/player.py | 83 +++++-- redbot/cogs/audio/core/utilities/playlists.py | 12 +- redbot/cogs/audio/core/utilities/queue.py | 10 +- .../cogs/audio/core/utilities/validation.py | 22 +- redbot/cogs/audio/manager.py | 44 ++-- redbot/cogs/audio/sql_statements.py | 89 +++++++ redbot/cogs/audio/utils.py | 12 + 41 files changed, 1553 insertions(+), 331 deletions(-) create mode 100644 redbot/cogs/audio/apis/persist_queue_wrapper.py create mode 100644 redbot/cogs/audio/core/utilities/parsers.py diff --git a/redbot/cogs/audio/apis/api_utils.py b/redbot/cogs/audio/apis/api_utils.py index 444f3b789..40d550c08 100644 --- a/redbot/cogs/audio/apis/api_utils.py +++ b/redbot/cogs/audio/apis/api_utils.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from typing import List, MutableMapping, Optional, Union import discord +import lavalink from redbot.core.bot import Red from redbot.core.utils.chat_formatting import humanize_list @@ -74,8 +75,22 @@ class PlaylistFetchResult: self.tracks = json.loads(self.tracks) +@dataclass +class QueueFetchResult: + guild_id: int + room_id: int + track: dict = field(default_factory=lambda: {}) + track_object: lavalink.Track = None + + def __post_init__(self): + if isinstance(self.track, str): + self.track = json.loads(self.track) + if self.track: + self.track_object = lavalink.Track(self.track) + + def standardize_scope(scope: str) -> str: - """Convert any of the used scopes into one we are expecting""" + """Convert any of the used scopes into one we are expecting.""" scope = scope.upper() valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"] @@ -103,7 +118,7 @@ def prepare_config_scope( author: Union[discord.abc.User, int] = None, guild: Union[discord.Guild, int] = None, ): - """Return the scope used by Playlists""" + """Return the scope used by Playlists.""" scope = standardize_scope(scope) if scope == PlaylistScope.GLOBAL.value: config_scope = [PlaylistScope.GLOBAL.value, bot.user.id] @@ -121,7 +136,7 @@ def prepare_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""" + """Return the scope used by Playlists.""" scope = standardize_scope(scope) if scope == PlaylistScope.GLOBAL.value: diff --git a/redbot/cogs/audio/apis/global_db.py b/redbot/cogs/audio/apis/global_db.py index 271ce1d90..fe6f1f5d6 100644 --- a/redbot/cogs/audio/apis/global_db.py +++ b/redbot/cogs/audio/apis/global_db.py @@ -1,8 +1,10 @@ import asyncio import contextlib +import json import logging -import urllib.parse -from typing import Mapping, Optional, TYPE_CHECKING, Union + +from copy import copy +from typing import TYPE_CHECKING, Mapping, Optional, Union import aiohttp from lavalink.rest_api import LoadResult @@ -17,7 +19,7 @@ from ..audio_logging import IS_DEBUG, debug_exc_log if TYPE_CHECKING: from .. import Audio -_API_URL = "https://redbot.app/" +_API_URL = "https://api.redbot.app/" log = logging.getLogger("red.cogs.Audio.api.GlobalDB") @@ -32,11 +34,150 @@ class GlobalCacheWrapper: 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 + + async def _get_api_key( + self, + ) -> Optional[str]: + if not self._token: + self._token = await self.bot.get_shared_api_tokens("audiodb") + self.api_key = self._token.get("api_key", None) + self.has_api_key = self.cog.global_api_user.get("can_post") + id_list = list(self.bot.owner_ids) + self._handshake_token = "||".join(map(str, id_list)) + return self.api_key + + async def get_call(self, query: Optional[Query] = None) -> dict: + api_url = f"{_API_URL}api/v2/queries" + if not self.cog.global_api_user.get("can_read"): + return {} + try: + query = Query.process_input(query, self.cog.local_folder_current_path) + if any([not query or not query.valid or query.is_spotify or query.is_local]): + return {} + await self._get_api_key() + if self.api_key is None: + return {} + search_response = "error" + query = query.lavalink_query + with contextlib.suppress(aiohttp.ContentTypeError, asyncio.TimeoutError): + async with self.session.get( + api_url, + timeout=aiohttp.ClientTimeout(total=await self.config.global_db_get_timeout()), + headers={"Authorization": self.api_key, "X-Token": self._handshake_token}, + params={"query": query}, + ) as r: + search_response = await r.json(loads=json.loads) + if IS_DEBUG and "x-process-time" in r.headers: + log.debug( + f"GET || Ping {r.headers.get('x-process-time')} || " + f"Status code {r.status} || {query}" + ) + if "tracks" not in search_response: + return {} + return search_response + except Exception as err: + debug_exc_log(log, err, f"Failed to Get query: {api_url}/{query}") + return {} + + async def get_spotify(self, title: str, author: Optional[str]) -> dict: + if not self.cog.global_api_user.get("can_read"): + return {} + api_url = f"{_API_URL}api/v2/queries/spotify" + try: + search_response = "error" + params = {"title": title, "author": author} + await self._get_api_key() + if self.api_key is None: + return {} + with contextlib.suppress(aiohttp.ContentTypeError, asyncio.TimeoutError): + async with self.session.get( + api_url, + timeout=aiohttp.ClientTimeout(total=await self.config.global_db_get_timeout()), + headers={"Authorization": self.api_key, "X-Token": self._handshake_token}, + params=params, + ) as r: + search_response = await r.json(loads=json.loads) + if IS_DEBUG and "x-process-time" in r.headers: + log.debug( + f"GET/spotify || Ping {r.headers.get('x-process-time')} || " + f"Status code {r.status} || {title} - {author}" + ) + if "tracks" not in search_response: + return {} + return search_response + except Exception as err: + debug_exc_log(log, err, f"Failed to Get query: {api_url}") + return {} + + async def post_call(self, llresponse: LoadResult, query: Optional[Query]) -> None: + try: + if not self.cog.global_api_user.get("can_post"): + return + query = Query.process_input(query, self.cog.local_folder_current_path) + if llresponse.has_error or llresponse.load_type.value in ["NO_MATCHES", "LOAD_FAILED"]: + return + if query and query.valid and query.is_youtube: + query = query.lavalink_query + else: + return None + await self._get_api_key() + if self.api_key is None: + return None + api_url = f"{_API_URL}api/v2/queries" + async with self.session.post( + api_url, + json=llresponse._raw, + headers={"Authorization": self.api_key, "X-Token": self._handshake_token}, + params={"query": query}, + ) as r: + await r.read() + if IS_DEBUG and "x-process-time" in r.headers: + log.debug( + f"POST || Ping {r.headers.get('x-process-time')} ||" + f" Status code {r.status} || {query}" + ) + except Exception as err: + debug_exc_log(log, err, f"Failed to post query: {query}") + await asyncio.sleep(0) + + async def update_global(self, llresponse: LoadResult, query: Optional[Query] = None): + await self.post_call(llresponse=llresponse, query=query) + + async def report_invalid(self, id: str) -> None: + if not self.cog.global_api_user.get("can_delete"): + return + api_url = f"{_API_URL}api/v2/queries/es/id" + with contextlib.suppress(Exception): + async with self.session.delete( + api_url, + headers={"Authorization": self.api_key, "X-Token": self._handshake_token}, + params={"id": id}, + ) as r: + await r.read() + + async def get_perms(self): + global_api_user = copy(self.cog.global_api_user) + await self._get_api_key() + is_enabled = await self.config.global_db_enabled() + await self._get_api_key() + if (not is_enabled) or self.api_key is None: + return global_api_user + with contextlib.suppress(Exception): + async with aiohttp.ClientSession(json_serialize=json.dumps) as session: + async with session.get( + f"{_API_URL}api/v2/users/me", + headers={"Authorization": self.api_key, "X-Token": self._handshake_token}, + ) as resp: + if resp.status == 200: + search_response = await resp.json(loads=json.loads) + global_api_user["fetched"] = True + global_api_user["can_read"] = search_response.get("can_read", False) + global_api_user["can_post"] = search_response.get("can_post", False) + global_api_user["can_delete"] = search_response.get("can_delete", False) + return global_api_user diff --git a/redbot/cogs/audio/apis/interface.py b/redbot/cogs/audio/apis/interface.py index b243b5819..45d07d456 100644 --- a/redbot/cogs/audio/apis/interface.py +++ b/redbot/cogs/audio/apis/interface.py @@ -1,30 +1,34 @@ import asyncio +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, cast +from typing import TYPE_CHECKING, Callable, List, MutableMapping, Optional, Tuple, Union, cast import aiohttp import discord import lavalink -from lavalink.rest_api import LoadResult -from redbot.core.utils import AsyncIter +from lavalink.rest_api import LoadResult, LoadType from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.commands import Cog, Context from redbot.core.i18n import Translator +from redbot.core.utils import AsyncIter from redbot.core.utils.dbtools import APSWConnectionWrapper 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 .api_utils import LavalinkCacheFetchForGlobalResult from .global_db import GlobalCacheWrapper from .local_db import LocalCacheWrapper +from .persist_queue_wrapper import QueueInterface from .playlist_interface import get_playlist from .playlist_wrapper import PlaylistWrapper from .spotify import SpotifyWrapper @@ -36,6 +40,7 @@ if TYPE_CHECKING: _ = Translator("Audio", __file__) log = logging.getLogger("red.cogs.Audio.api.AudioAPIInterface") _TOP_100_US = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn5rWitrRWFKdm-ulaFiIyoK" +# TODO: Get random from global Cache class AudioAPIInterface: @@ -60,20 +65,22 @@ class AudioAPIInterface: 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.persistent_queue_api = QueueInterface(self.bot, self.config, self.conn, self.cog) self._session: aiohttp.ClientSession = session self._tasks: MutableMapping = {} self._lock: asyncio.Lock = asyncio.Lock() async def initialize(self) -> None: - """Initialises the Local Cache connection""" + """Initialises the Local Cache connection.""" await self.local_cache_api.lavalink.init() + await self.persistent_queue_api.init() def close(self) -> None: - """Closes the Local Cache connection""" + """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""" + async def get_random_track_from_db(self, tries=0) -> Optional[MutableMapping]: + """Get a random track from the local database and return it.""" track: Optional[MutableMapping] = {} try: query_data = {} @@ -106,7 +113,7 @@ class AudioAPIInterface: action_type: str = None, data: Union[List[MutableMapping], MutableMapping] = None, ) -> None: - """Separate the tasks and run them in the appropriate functions""" + """Separate the tasks and run them in the appropriate functions.""" if not data: return @@ -126,9 +133,11 @@ class AudioAPIInterface: await self.local_cache_api.youtube.update(data) elif table == "spotify": await self.local_cache_api.spotify.update(data) + elif action_type == "global" and isinstance(data, list): + await asyncio.gather(*[self.global_cache_api.update_global(**d) for d in data]) async def run_tasks(self, ctx: Optional[commands.Context] = None, message_id=None) -> None: - """Run tasks for a specific context""" + """Run tasks for a specific context.""" if message_id is not None: lock_id = message_id elif ctx is not None: @@ -143,7 +152,7 @@ class AudioAPIInterface: try: tasks = self._tasks[lock_id] tasks = [self.route_tasks(a, tasks[a]) for a in tasks] - await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*tasks, return_exceptions=False) del self._tasks[lock_id] except Exception as exc: debug_exc_log( @@ -154,7 +163,7 @@ class AudioAPIInterface: log.debug(f"Completed database writes for {lock_id} ({lock_author})") async def run_all_pending_tasks(self) -> None: - """Run all pending tasks left in the cache, called on cog_unload""" + """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") @@ -166,7 +175,7 @@ class AudioAPIInterface: self._tasks = {} coro_tasks = [self.route_tasks(a, tasks[a]) for a in tasks] - await asyncio.gather(*coro_tasks, return_exceptions=True) + await asyncio.gather(*coro_tasks, return_exceptions=False) except Exception as exc: debug_exc_log(log, exc, "Failed database writes") @@ -175,7 +184,7 @@ class AudioAPIInterface: 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""" + """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": []} @@ -190,7 +199,7 @@ class AudioAPIInterface: skip_youtube: bool = False, current_cache_level: CacheLevel = CacheLevel.none(), ) -> List[str]: - """Return youtube URLS for the spotify URL provided""" + """Return youtube URLS for the spotify URL provided.""" youtube_urls = [] tracks = await self.fetch_from_spotify_api( query_type, uri, params=None, notifier=notifier, ctx=ctx @@ -266,7 +275,7 @@ class AudioAPIInterface: notifier: Optional[Notifier] = None, ctx: Context = None, ) -> Union[List[MutableMapping], List[str]]: - """Gets track info from spotify API""" + """Gets track info from spotify API.""" if recursive is False: (call, params) = self.spotify_api.spotify_format_call(query_type, uri) @@ -394,9 +403,10 @@ class AudioAPIInterface: lock: Callable, notifier: Optional[Notifier] = None, forced: bool = False, - query_global: bool = False, + query_global: bool = True, ) -> List[lavalink.Track]: - """Queries the Database then falls back to Spotify and YouTube APIs then Enqueued matched tracks. + """Queries the Database then falls back to Spotify and YouTube APIs then Enqueued matched + tracks. Parameters ---------- @@ -423,7 +433,9 @@ class AudioAPIInterface: List[str] List of Youtube URLs. """ - # globaldb_toggle = await self.config.global_db_enabled() + await self.global_cache_api._get_api_key() + globaldb_toggle = await self.config.global_db_enabled() + global_entry = globaldb_toggle and query_global track_list: List = [] has_not_allowed = False try: @@ -485,7 +497,14 @@ class AudioAPIInterface: ) except Exception as exc: debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table") - + should_query_global = globaldb_toggle and query_global and val is None + if should_query_global: + llresponse = await self.global_cache_api.get_spotify(track_name, artist_name) + if llresponse: + if llresponse.get("loadType") == "V2_COMPACT": + llresponse["loadType"] = "V2_COMPAT" + llresponse = LoadResult(llresponse) + val = llresponse or None if val is None: val = await self.fetch_youtube_query( ctx, track_info, current_cache_level=current_cache_level @@ -494,34 +513,44 @@ class AudioAPIInterface: task = ("update", ("youtube", {"track": track_info})) self.append_task(ctx, *task) - if llresponse is not None: + if isinstance(llresponse, LoadResult): track_object = llresponse.tracks elif val: - try: - (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) - error_embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("The connection was reset while loading the playlist."), - ) - if notifier is not None: - await notifier.update_embed(error_embed) - break - except asyncio.TimeoutError: - lock(ctx, False) - error_embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Player timeout, skipping remaining tracks."), - ) - if notifier is not None: - await notifier.update_embed(error_embed) - break + result = None + if should_query_global: + llresponse = await self.global_cache_api.get_call(val) + if llresponse: + if llresponse.get("loadType") == "V2_COMPACT": + llresponse["loadType"] = "V2_COMPAT" + llresponse = LoadResult(llresponse) + result = llresponse or None + if not result: + try: + (result, called_api) = await self.fetch_track( + ctx, + player, + Query.process_input(val, self.cog.local_folder_current_path), + forced=forced, + should_query_global=not should_query_global, + ) + except (RuntimeError, aiohttp.ServerDisconnectedError): + lock(ctx, False) + error_embed = discord.Embed( + colour=await ctx.embed_colour(), + title=_("The connection was reset while loading the playlist."), + ) + if notifier is not None: + await notifier.update_embed(error_embed) + break + except asyncio.TimeoutError: + lock(ctx, False) + error_embed = discord.Embed( + colour=await ctx.embed_colour(), + title=_("Player timeout, skipping remaining tracks."), + ) + if notifier is not None: + await notifier.update_embed(error_embed) + break track_object = result.tracks else: track_object = [] @@ -538,7 +567,7 @@ class AudioAPIInterface: seconds=seconds, ) - if consecutive_fails >= 10: + if consecutive_fails >= (100 if global_entry else 10): error_embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Failing to get tracks, skipping remaining."), @@ -551,13 +580,12 @@ class AudioAPIInterface: continue consecutive_fails = 0 single_track = track_object[0] + query = Query.process_input(single_track, self.cog.local_folder_current_path) if not await self.cog.is_query_allowed( self.config, - ctx.guild, - ( - f"{single_track.title} {single_track.author} {single_track.uri} " - f"{Query.process_input(single_track, self.cog.local_folder_current_path)}" - ), + ctx, + f"{single_track.title} {single_track.author} {single_track.uri} {query}", + query_obj=query, ): has_not_allowed = True if IS_DEBUG: @@ -570,6 +598,13 @@ class AudioAPIInterface: if guild_data["maxlength"] > 0: if self.cog.is_track_length_allowed(single_track, guild_data["maxlength"]): enqueued_tracks += 1 + single_track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, single_track) self.bot.dispatch( "red_audio_track_enqueue", @@ -579,6 +614,13 @@ class AudioAPIInterface: ) else: enqueued_tracks += 1 + single_track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, single_track) self.bot.dispatch( "red_audio_track_enqueue", @@ -642,9 +684,7 @@ class AudioAPIInterface: track_info: str, current_cache_level: CacheLevel = CacheLevel.none(), ) -> Optional[str]: - """ - Call the Youtube API and returns the youtube URL that the query matched - """ + """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()) @@ -668,9 +708,7 @@ class AudioAPIInterface: async def fetch_from_youtube_api( self, ctx: commands.Context, track_info: str ) -> Optional[str]: - """ - Gets an YouTube URL from for the query - """ + """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 @@ -727,6 +765,7 @@ class AudioAPIInterface: val = None query = Query.process_input(query, self.cog.local_folder_current_path) query_string = str(query) + globaldb_toggle = await self.config.global_db_enabled() valid_global_entry = False results = None called_api = False @@ -754,7 +793,31 @@ class AudioAPIInterface: called_api = False else: val = None - + if ( + globaldb_toggle + and not val + and should_query_global + and not forced + and not query.is_local + and not query.is_spotify + ): + valid_global_entry = False + with contextlib.suppress(Exception): + global_entry = await self.global_cache_api.get_call(query=query) + if global_entry.get("loadType") == "V2_COMPACT": + global_entry["loadType"] = "V2_COMPAT" + results = LoadResult(global_entry) + if results.load_type in [ + LoadType.PLAYLIST_LOADED, + LoadType.TRACK_LOADED, + LoadType.SEARCH_RESULT, + LoadType.V2_COMPAT, + ]: + valid_global_entry = True + if valid_global_entry: + if IS_DEBUG: + log.debug(f"Querying Global DB api for {query}") + results, called_api = results, False if valid_global_entry: pass elif lazy is True: @@ -769,6 +832,7 @@ class AudioAPIInterface: if results.has_error: # If cached value has an invalid entry make a new call so that it gets updated results, called_api = await self.fetch_track(ctx, player, query, forced=True) + valid_global_entry = False else: if IS_DEBUG: log.debug(f"Querying Lavalink api for {query_string}") @@ -781,7 +845,19 @@ class AudioAPIInterface: raise TrackEnqueueError if results is None: results = LoadResult({"loadType": "LOAD_FAILED", "playlistInfo": {}, "tracks": []}) - + valid_global_entry = False + update_global = ( + globaldb_toggle and not valid_global_entry and self.global_cache_api.has_api_key + ) + with contextlib.suppress(Exception): + if ( + update_global + and not query.is_local + and not results.has_error + and len(results.tracks) >= 1 + ): + global_task = ("global", dict(llresponse=results, query=query)) + self.append_task(ctx, *global_task) if ( cache_enabled and results.load_type @@ -817,9 +893,7 @@ class AudioAPIInterface: return results, called_api async def autoplay(self, player: lavalink.Player, playlist_api: PlaylistWrapper): - """ - Enqueue a random track - """ + """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) @@ -865,19 +939,18 @@ class AudioAPIInterface: track = random.choice(tracks) query = Query.process_input(track, self.cog.local_folder_current_path) await asyncio.sleep(0.001) - if not query.valid or ( + if (not query.valid) or ( query.is_local and query.local_track_path is not None and not query.local_track_path.exists() ): continue + notify_channel = self.bot.get_channel(player.fetch("channel")) if not await self.cog.is_query_allowed( self.config, - player.channel.guild, - ( - f"{track.title} {track.author} {track.uri} " - f"{str(Query.process_input(track, self.cog.local_folder_current_path))}" - ), + notify_channel, + f"{track.title} {track.author} {track.uri} {query}", + query_obj=query, ): if IS_DEBUG: log.debug( @@ -886,11 +959,20 @@ class AudioAPIInterface: ) continue valid = True - - track.extras["autoplay"] = True + track.extras.update( + { + "autoplay": True, + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": player.channel.guild.me.id, + } + ) 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() + + async def fetch_all_contribute(self) -> List[LavalinkCacheFetchForGlobalResult]: + return await self.local_cache_api.lavalink.fetch_all_for_global() diff --git a/redbot/cogs/audio/apis/local_db.py b/redbot/cogs/audio/apis/local_db.py index c9ef378f6..39c1d6a98 100644 --- a/redbot/cogs/audio/apis/local_db.py +++ b/redbot/cogs/audio/apis/local_db.py @@ -4,14 +4,14 @@ 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 types import SimpleNamespace +from typing import TYPE_CHECKING, Callable, List, MutableMapping, Optional, Tuple, Union from redbot.core import Config from redbot.core.bot import Red from redbot.core.commands import Cog +from redbot.core.utils import AsyncIter from redbot.core.utils.dbtools import APSWConnectionWrapper from ..audio_logging import debug_exc_log @@ -313,7 +313,7 @@ class LavalinkTableWrapper(BaseWrapper): 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 + self.fetch_for_global: Optional[Callable] = LavalinkCacheFetchForGlobalResult async def fetch_one( self, values: MutableMapping diff --git a/redbot/cogs/audio/apis/persist_queue_wrapper.py b/redbot/cogs/audio/apis/persist_queue_wrapper.py new file mode 100644 index 000000000..0d9e99109 --- /dev/null +++ b/redbot/cogs/audio/apis/persist_queue_wrapper.py @@ -0,0 +1,133 @@ +import concurrent +import json +import logging +import time + +from types import SimpleNamespace +from typing import TYPE_CHECKING, List, Union + +import lavalink + +from redbot.core import Config +from redbot.core.bot import Red +from redbot.core.commands import Cog +from redbot.core.utils import AsyncIter +from redbot.core.utils.dbtools import APSWConnectionWrapper + +from ..audio_logging import debug_exc_log +from ..sql_statements import ( + PERSIST_QUEUE_BULK_PLAYED, + PERSIST_QUEUE_CREATE_INDEX, + PERSIST_QUEUE_CREATE_TABLE, + PERSIST_QUEUE_DELETE_SCHEDULED, + PERSIST_QUEUE_DROP_TABLE, + PERSIST_QUEUE_FETCH_ALL, + PERSIST_QUEUE_PLAYED, + PERSIST_QUEUE_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 QueueFetchResult + +log = logging.getLogger("red.cogs.Audio.api.PersistQueueWrapper") + +if TYPE_CHECKING: + from .. import Audio + + +class QueueInterface: + def __init__( + self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog] + ): + self.bot = bot + self.database = conn + self.config = config + self.cog = cog + 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 = PERSIST_QUEUE_CREATE_TABLE + self.statement.create_index = PERSIST_QUEUE_CREATE_INDEX + + self.statement.upsert = PERSIST_QUEUE_UPSERT + self.statement.update_bulk_player = PERSIST_QUEUE_BULK_PLAYED + self.statement.delete_scheduled = PERSIST_QUEUE_DELETE_SCHEDULED + self.statement.drop_table = PERSIST_QUEUE_DROP_TABLE + + self.statement.get_all = PERSIST_QUEUE_FETCH_ALL + self.statement.get_player = PERSIST_QUEUE_PLAYED + + async def init(self) -> None: + """Initialize the PersistQueue 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) + + async def fetch_all(self) -> List[QueueFetchResult]: + """Fetch all playlists""" + 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, + ) + ] + ): + try: + row_result = future.result() + except Exception as exc: + debug_exc_log(log, exc, "Failed to complete playlist fetch from database") + return [] + + async for index, row in AsyncIter(row_result).enumerate(start=1): + output.append(QueueFetchResult(*row)) + return output + + async def played(self, guild_id: int, track_id: str) -> None: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit( + self.database.cursor().execute, + PERSIST_QUEUE_PLAYED, + {"guild_id": guild_id, "track_id": track_id}, + ) + + async def delete_scheduled(self): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(self.database.cursor().execute, PERSIST_QUEUE_DELETE_SCHEDULED) + + async def drop(self, guild_id: int): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit( + self.database.cursor().execute, PERSIST_QUEUE_BULK_PLAYED, ({"guild_id": guild_id}) + ) + + async def enqueued(self, guild_id: int, room_id: int, track: lavalink.Track): + enqueue_time = track.extras.get("enqueue_time", 0) + if enqueue_time == 0: + track.extras["enqueue_time"] = int(time.time()) + track_identifier = track.track_identifier + track = self.cog.track_to_json(track) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit( + self.database.cursor().execute, + PERSIST_QUEUE_UPSERT, + { + "guild_id": int(guild_id), + "room_id": int(room_id), + "played": False, + "time": enqueue_time, + "track": json.dumps(track), + "track_id": track_identifier, + }, + ) diff --git a/redbot/cogs/audio/apis/playlist_interface.py b/redbot/cogs/audio/apis/playlist_interface.py index 263981762..bfc7e3ccf 100644 --- a/redbot/cogs/audio/apis/playlist_interface.py +++ b/redbot/cogs/audio/apis/playlist_interface.py @@ -1,12 +1,13 @@ 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.utils import AsyncIter from ..errors import NotAllowed from ..utils import PlaylistScope diff --git a/redbot/cogs/audio/apis/playlist_wrapper.py b/redbot/cogs/audio/apis/playlist_wrapper.py index 11f11e0b7..a12694643 100644 --- a/redbot/cogs/audio/apis/playlist_wrapper.py +++ b/redbot/cogs/audio/apis/playlist_wrapper.py @@ -1,17 +1,18 @@ 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 import AsyncIter from redbot.core.utils.dbtools import APSWConnectionWrapper from ..audio_logging import debug_exc_log from ..sql_statements import ( + HANDLE_DISCORD_DATA_DELETION_QUERY, PLAYLIST_CREATE_INDEX, PLAYLIST_CREATE_TABLE, PLAYLIST_DELETE, @@ -27,7 +28,6 @@ from ..sql_statements import ( PRAGMA_SET_read_uncommitted, PRAGMA_SET_temp_store, PRAGMA_SET_user_version, - HANDLE_DISCORD_DATA_DELETION_QUERY, ) from ..utils import PlaylistScope from .api_utils import PlaylistFetchResult @@ -62,7 +62,7 @@ class PlaylistWrapper: self.statement.drop_user_playlists = HANDLE_DISCORD_DATA_DELETION_QUERY async def init(self) -> None: - """Initialize the Playlist table""" + """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) @@ -72,7 +72,7 @@ class PlaylistWrapper: @staticmethod def get_scope_type(scope: str) -> int: - """Convert a scope to a numerical identifier""" + """Convert a scope to a numerical identifier.""" if scope == PlaylistScope.GLOBAL.value: table = 1 elif scope == PlaylistScope.USER.value: @@ -82,7 +82,7 @@ class PlaylistWrapper: return table async def fetch(self, scope: str, playlist_id: int, scope_id: int) -> PlaylistFetchResult: - """Fetch a single playlist""" + """Fetch a single playlist.""" scope_type = self.get_scope_type(scope) with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: @@ -113,7 +113,7 @@ class PlaylistWrapper: async def fetch_all( self, scope: str, scope_id: int, author_id=None ) -> List[PlaylistFetchResult]: - """Fetch all playlists""" + """Fetch all playlists.""" scope_type = self.get_scope_type(scope) output = [] with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: @@ -160,7 +160,7 @@ class PlaylistWrapper: async def fetch_all_converter( self, scope: str, playlist_name, playlist_id ) -> List[PlaylistFetchResult]: - """Fetch all playlists with the specified filter""" + """Fetch all playlists with the specified filter.""" scope_type = self.get_scope_type(scope) try: playlist_id = int(playlist_id) @@ -195,7 +195,7 @@ class PlaylistWrapper: return output async def delete(self, scope: str, playlist_id: int, scope_id: int): - """Deletes a single playlists""" + """Deletes a single playlists.""" scope_type = self.get_scope_type(scope) with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: executor.submit( @@ -205,12 +205,12 @@ class PlaylistWrapper: ) async def delete_scheduled(self): - """Clean up database from all deleted playlists""" + """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""" + """Delete all playlists in a scope.""" scope_type = self.get_scope_type(scope) with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: executor.submit( @@ -220,7 +220,7 @@ class PlaylistWrapper: ) async def create_table(self): - """Create the playlist table""" + """Create the playlist table.""" with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: executor.submit(self.database.cursor().execute, PLAYLIST_CREATE_TABLE) @@ -234,7 +234,7 @@ class PlaylistWrapper: playlist_url: Optional[str], tracks: List[MutableMapping], ): - """Insert or update a playlist into the database""" + """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( diff --git a/redbot/cogs/audio/apis/spotify.py b/redbot/cogs/audio/apis/spotify.py index 63bfaacef..edce7899b 100644 --- a/redbot/cogs/audio/apis/spotify.py +++ b/redbot/cogs/audio/apis/spotify.py @@ -1,16 +1,18 @@ import base64 import contextlib +import json import logging import time -from typing import List, Mapping, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union + +from typing import TYPE_CHECKING, List, Mapping, MutableMapping, Optional, 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 redbot.core.i18n import Translator +from redbot.core.utils import AsyncIter from ..errors import SpotifyFetchError @@ -46,7 +48,7 @@ class SpotifyWrapper: @staticmethod def spotify_format_call(query_type: str, key: str) -> Tuple[str, MutableMapping]: - """Format the spotify endpoint""" + """Format the spotify endpoint.""" params: MutableMapping = {} if query_type == "album": query = f"{ALBUMS_ENDPOINT}/{key}/tracks" @@ -59,7 +61,7 @@ class SpotifyWrapper: async def get_spotify_track_info( self, track_data: MutableMapping, ctx: Context ) -> Tuple[str, ...]: - """Extract track info from spotify response""" + """Extract track info from spotify response.""" prefer_lyrics = await self.cog.get_lyrics_status(ctx) track_name = track_data["name"] if prefer_lyrics: @@ -75,14 +77,14 @@ class SpotifyWrapper: @staticmethod async def is_access_token_valid(token: MutableMapping) -> bool: - """Check if current token is not too old""" + """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""" + """Make Authorization header for spotify token.""" if client_id is None: client_id = "" if client_secret is None: @@ -93,11 +95,11 @@ class SpotifyWrapper: async def get( self, url: str, headers: MutableMapping = None, params: MutableMapping = None ) -> MutableMapping[str, str]: - """Make a GET request to the spotify API""" + """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() + data = await r.json(loads=json.loads) if r.status != 200: log.debug(f"Issue making GET request to {url}: [{r.status}] {data}") return data @@ -106,7 +108,7 @@ class SpotifyWrapper: self._token = new_token async def get_token(self) -> None: - """Get the stored spotify tokens""" + """Get the stored spotify tokens.""" if not self._token: self._token = await self.bot.get_shared_api_tokens("spotify") @@ -114,10 +116,17 @@ class SpotifyWrapper: 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" + return ( + ( + await self.config.user(ctx.author).country_code() + or 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""" + """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) @@ -125,7 +134,7 @@ class SpotifyWrapper: return r async def get_access_token(self) -> Optional[str]: - """Get the access_token""" + """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() @@ -142,20 +151,20 @@ class SpotifyWrapper: async def post( self, url: str, payload: MutableMapping, headers: MutableMapping = None ) -> MutableMapping: - """Make a POST call to spotify""" + """Make a POST call to spotify.""" async with self.session.post(url, data=payload, headers=headers) as r: - data = await r.json() + data = await r.json(loads=json.loads) 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""" + """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""" + """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) @@ -171,7 +180,7 @@ class SpotifyWrapper: 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""" + """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 {} diff --git a/redbot/cogs/audio/apis/youtube.py b/redbot/cogs/audio/apis/youtube.py index 0f97dcf94..aadeb70ea 100644 --- a/redbot/cogs/audio/apis/youtube.py +++ b/redbot/cogs/audio/apis/youtube.py @@ -1,5 +1,7 @@ +import json import logging -from typing import Mapping, Optional, TYPE_CHECKING, Union + +from typing import TYPE_CHECKING, Mapping, Optional, Union import aiohttp @@ -33,15 +35,17 @@ class YouTubeWrapper: 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""" + 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""" + """Make a Get call to youtube data api.""" params = { "q": query, "part": "id", @@ -57,7 +61,7 @@ class YouTubeWrapper: raise YouTubeApiError("Your YouTube Data API quota has been reached.") return None else: - search_response = await r.json() + search_response = await r.json(loads=json.loads) 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']}" diff --git a/redbot/cogs/audio/audio_dataclasses.py b/redbot/cogs/audio/audio_dataclasses.py index b753c2bdd..7dc2a863d 100644 --- a/redbot/cogs/audio/audio_dataclasses.py +++ b/redbot/cogs/audio/audio_dataclasses.py @@ -5,21 +5,23 @@ import ntpath import os import posixpath import re + from pathlib import Path, PosixPath, WindowsPath from typing import ( AsyncIterator, + Callable, Final, Iterator, MutableMapping, Optional, + Pattern, Tuple, Union, - Callable, - Pattern, ) from urllib.parse import urlparse import lavalink + from redbot.core.utils import AsyncIter _RE_REMOVE_START: Final[Pattern] = re.compile(r"^(sc|list) ") @@ -80,8 +82,8 @@ 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 @@ -335,6 +337,7 @@ class Query: self.is_mixer: bool = kwargs.get("mixer", False) self.is_twitch: bool = kwargs.get("twitch", False) self.is_other: bool = kwargs.get("other", False) + self.is_pornhub: bool = kwargs.get("pornhub", False) self.is_playlist: bool = kwargs.get("playlist", False) self.is_album: bool = kwargs.get("album", False) self.is_search: bool = kwargs.get("search", False) @@ -350,7 +353,6 @@ class Query: self.start_time: int = kwargs.get("start_time", 0) self.track_index: Optional[int] = kwargs.get("track_index", None) - if self.invoked_from == "sc search": self.is_youtube = False self.is_soundcloud = True @@ -403,8 +405,7 @@ class Query: _local_folder_current_path: Path, **kwargs, ) -> "Query": - """ - Process the input query into its type + """Process the input query into its type. Parameters ---------- @@ -442,7 +443,7 @@ class Query: @staticmethod def _parse(track, _local_folder_current_path: Path, **kwargs) -> MutableMapping: - """Parse a track into all the relevant metadata""" + """Parse a track into all the relevant metadata.""" returning: MutableMapping = {} if ( type(track) == type(LocalPath) diff --git a/redbot/cogs/audio/audio_logging.py b/redbot/cogs/audio/audio_logging.py index 5e71ad750..8652b6580 100644 --- a/redbot/cogs/audio/audio_logging.py +++ b/redbot/cogs/audio/audio_logging.py @@ -1,5 +1,6 @@ import logging import sys + from typing import Final IS_DEBUG: Final[bool] = "--debug" in sys.argv diff --git a/redbot/cogs/audio/converters.py b/redbot/cogs/audio/converters.py index 00bee9fc6..320732952 100644 --- a/redbot/cogs/audio/converters.py +++ b/redbot/cogs/audio/converters.py @@ -1,14 +1,15 @@ import argparse import functools import re -from typing import Final, MutableMapping, Optional, Tuple, Union, Pattern + +from typing import Final, MutableMapping, Optional, Pattern, Tuple, Union import discord -from redbot.core.utils import AsyncIter from redbot.core import commands from redbot.core.bot import Red from redbot.core.i18n import Translator +from redbot.core.utils import AsyncIter from .apis.api_utils import standardize_scope from .apis.playlist_interface import get_all_playlist_converter diff --git a/redbot/cogs/audio/core/__init__.py b/redbot/cogs/audio/core/__init__.py index e1a076490..fbc215e50 100644 --- a/redbot/cogs/audio/core/__init__.py +++ b/redbot/cogs/audio/core/__init__.py @@ -1,8 +1,11 @@ import asyncio +import json + from collections import Counter from typing import Mapping import aiohttp +import discord from redbot.core import Config from redbot.core.bot import Red @@ -49,6 +52,7 @@ class Audio( self._disconnected_players = {} self._daily_playlist_cache = {} self._daily_global_playlist_cache = {} + self._persist_queue_cache = {} self._dj_status_cache = {} self._dj_role_cache = {} self.skip_votes = {} @@ -58,30 +62,47 @@ class Audio( self.player_automated_timer_task = None self.cog_cleaned_up = False self.lavalink_connection_aborted = False + self.permission_cache = discord.Permissions( + embed_links=True, + read_messages=True, + send_messages=True, + read_message_history=True, + add_reactions=True, + ) - self.session = aiohttp.ClientSession() + self.session = aiohttp.ClientSession(json_serialize=json.dumps) self.cog_ready_event = asyncio.Event() self.cog_init_task = None + self.global_api_user = { + "fetched": False, + "can_read": False, + "can_post": False, + "can_delete": False, + } default_global = dict( schema_version=1, + owner_notification=0, 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 + global_db_get_timeout=5, status=False, use_external_lavalink=False, restrict=True, localpath=str(cog_data_path(raw_name="Audio")), url_keyword_blacklist=[], url_keyword_whitelist=[], + java_exc_path="java", **self._default_lavalink_settings, ) default_guild = dict( auto_play=False, + auto_deafen=True, autoplaylist={"enabled": False, "id": None, "name": None, "scope": None}, + persist_queue=True, disconnect=False, dj_enabled=False, dj_role=None, @@ -119,3 +140,4 @@ class Audio( self.config.register_custom(PlaylistScope.USER.value, **_playlist) self.config.register_guild(**default_guild) self.config.register_global(**default_global) + self.config.register_user(country_code=None) diff --git a/redbot/cogs/audio/core/abc.py b/redbot/cogs/audio/core/abc.py index b7a20d3a4..e5cb70f2e 100644 --- a/redbot/cogs/audio/core/abc.py +++ b/redbot/cogs/audio/core/abc.py @@ -1,10 +1,11 @@ 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 +from typing import TYPE_CHECKING, Any, List, Mapping, MutableMapping, Optional, Tuple, Union import aiohttp import discord @@ -25,8 +26,7 @@ if TYPE_CHECKING: class MixinMeta(ABC): - """ - Base class for well behaved type hint detection with composite class. + """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. """ @@ -44,10 +44,12 @@ class MixinMeta(ABC): play_lock: MutableMapping[int, bool] _daily_playlist_cache: MutableMapping[int, bool] _daily_global_playlist_cache: MutableMapping[int, bool] + _persist_queue_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] + global_api_user: MutableMapping[str, Any] cog_cleaned_up: bool lavalink_connection_aborted: bool @@ -60,6 +62,7 @@ class MixinMeta(ABC): cog_ready_event: asyncio.Event _default_lavalink_settings: Mapping + permission_cache = discord.Permissions @abstractmethod async def command_llsetup(self, ctx: commands.Context): @@ -74,7 +77,7 @@ class MixinMeta(ABC): raise NotImplementedError() @abstractmethod - def get_active_player_count(self) -> Tuple[str, int]: + async def get_active_player_count(self) -> Tuple[str, int]: raise NotImplementedError() @abstractmethod @@ -160,16 +163,20 @@ class MixinMeta(ABC): @abstractmethod async def is_query_allowed( - self, config: Config, guild: discord.Guild, query: str, query_obj: "Query" = None + self, + config: Config, + ctx_or_channel: Optional[Union[Context, discord.TextChannel]], + query: str, + query_obj: Query, ) -> bool: raise NotImplementedError() @abstractmethod - def is_track_length_allowed(self, track: lavalink.Track, maxlength: int) -> bool: + def is_track_length_allowed(self, track: Union[lavalink.Track, int], maxlength: int) -> bool: raise NotImplementedError() @abstractmethod - def get_track_description( + async def get_track_description( self, track: Union[lavalink.rest_api.Track, "Query"], local_folder_current_path: Path, @@ -178,7 +185,7 @@ class MixinMeta(ABC): raise NotImplementedError() @abstractmethod - def get_track_description_unformatted( + async def get_track_description_unformatted( self, track: Union[lavalink.rest_api.Track, "Query"], local_folder_current_path: Path ) -> Optional[str]: raise NotImplementedError() @@ -495,6 +502,10 @@ class MixinMeta(ABC): async def get_lyrics_status(self, ctx: Context) -> bool: raise NotImplementedError() + @abstractmethod + async def restore_players(self) -> bool: + raise NotImplementedError() + @abstractmethod async def command_skip(self, ctx: commands.Context, skip_to_track: int = None): raise NotImplementedError() @@ -502,3 +513,10 @@ class MixinMeta(ABC): @abstractmethod async def command_prev(self, ctx: commands.Context): raise NotImplementedError() + + @abstractmethod + async def icyparser(self, url: str) -> Optional[str]: + raise NotImplementedError() + + async def self_deafen(self, player: lavalink.Player) -> None: + raise NotImplementedError() diff --git a/redbot/cogs/audio/core/cog_utils.py b/redbot/cogs/audio/core/cog_utils.py index 995ef23f6..845e05290 100644 --- a/redbot/cogs/audio/core/cog_utils.py +++ b/redbot/cogs/audio/core/cog_utils.py @@ -8,15 +8,49 @@ 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"}) +__version__ = VersionInfo.from_json({"major": 2, "minor": 3, "micro": 0, "releaselevel": "final"}) __author__ = ["aikaterna", "Draper"] _ = Translator("Audio", Path(__file__).parent) _SCHEMA_VERSION: Final[int] = 3 +_OWNER_NOTIFICATION: Final[int] = 1 LazyGreedyConverter = get_lazy_converter("--") PlaylistConverter = get_playlist_converter() +HUMANIZED_PERM = { + "create_instant_invite": "Create Instant Invite", + "kick_members": "Kick Members", + "ban_members": "Ban Members", + "administrator": "Administrator", + "manage_channels": "Manage Channels", + "manage_guild": "Manage Server", + "add_reactions": "Add Reactions", + "view_audit_log": "View Audit Log", + "priority_speaker": "Priority Speaker", + "stream": "Go Live", + "read_messages": "Read Text Channels & See Voice Channels", + "send_messages": "Send Messages", + "send_tts_messages": "Send TTS Messages", + "manage_messages": "Manage Messages", + "embed_links": "Embed Links", + "attach_files": "Attach Files", + "read_message_history": "Read Message History", + "mention_everyone": "Mention @everyone, @here, and All Roles", + "external_emojis": "Use External Emojis", + "view_guild_insights": "View Server Insights", + "connect": "Connect", + "speak": "Speak", + "mute_members": "Mute Members", + "deafen_members": "Deafen Members", + "move_members": "Move Members", + "use_voice_activation": "Use Voice Activity", + "change_nickname": "Change Nickname", + "manage_nicknames": "Manage Nicknames", + "manage_roles": "Manage Roles", + "manage_webhooks": "Manage Webhooks", + "manage_emojis": "Manage Emojis", +} class CompositeMetaClass(type(commands.Cog), type(ABC)): diff --git a/redbot/cogs/audio/core/commands/audioset.py b/redbot/cogs/audio/core/commands/audioset.py index fc45e2f31..cf148f0ad 100644 --- a/redbot/cogs/audio/core/commands/audioset.py +++ b/redbot/cogs/audio/core/commands/audioset.py @@ -1,6 +1,7 @@ import asyncio import contextlib import logging + from typing import Union import discord @@ -888,6 +889,21 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): ), ) + @command_audioset.command(name="autodeafen") + @commands.guild_only() + @commands.mod_or_permissions(manage_guild=True) + async def command_audioset_auto_deafen(self, ctx: commands.Context): + """Toggle whether the bot will be auto deafened upon joining the voice channel.""" + auto_deafen = await self.config.guild(ctx.guild).auto_deafen() + await self.config.guild(ctx.guild).auto_deafen.set(not auto_deafen) + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Auto Deafen: {true_or_false}.").format( + true_or_false=_("Enabled") if not auto_deafen else _("Disabled") + ), + ) + @command_audioset.command(name="restrict") @commands.is_owner() @commands.guild_only() @@ -951,6 +967,9 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): 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") + persist_queue = _("Enabled") if data["persist_queue"] else _("Disabled") + auto_deafen = _("Enabled") if data["auto_deafen"] else _("Disabled") + countrycode = data["country_code"] spotify_cache = CacheLevel.set_spotify() @@ -992,7 +1011,9 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): "Shuffle bumped: [{bumpped_shuffle}]\n" "Song notify msgs: [{notify}]\n" "Songs as status: [{status}]\n" + "Persist queue: [{persist_queue}]\n" "Spotify search: [{countrycode}]\n" + "Auto-Deafen: [{auto_deafen}]\n" ).format( countrycode=countrycode, repeat=song_repeat, @@ -1000,6 +1021,8 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): notify=song_notify, status=song_status, bumpped_shuffle=bumpped_shuffle, + persist_queue=persist_queue, + auto_deafen=auto_deafen, ) if thumbnail: msg += _("Thumbnails: [{0}]\n").format( @@ -1050,16 +1073,22 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): + _("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") + + _("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"]), + 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---" + + _("User Settings") + + "--- \n" + + _("Spotify search: [{country_code}]\n") + ).format(country_code=await self.config.user(ctx.author).country_code()) msg += ( "\n---" @@ -1075,19 +1104,21 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): if global_data["use_external_lavalink"] else _("Disabled"), ) - if not global_data["use_external_lavalink"] and self.player_manager.ll_build: + if is_owner and not global_data["use_external_lavalink"] and self.player_manager.ll_build: msg += _( "Lavalink build: [{llbuild}]\n" "Lavalink branch: [{llbranch}]\n" "Release date: [{build_time}]\n" "Lavaplayer version: [{lavaplayer}]\n" "Java version: [{jvm}]\n" + "Java Executable: [{jv_exec}]\n" ).format( build_time=self.player_manager.build_time, llbuild=self.player_manager.ll_build, llbranch=self.player_manager.ll_branch, lavaplayer=self.player_manager.lavaplayer, jvm=self.player_manager.jvm, + jv_exec=self.player_manager.path, ) if is_owner: msg += _("Localtracks path: [{localpath}]\n").format(**global_data) @@ -1212,6 +1243,28 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): await self.config.guild(ctx.guild).country_code.set(country) + @command_audioset.command(name="mycountrycode") + @commands.guild_only() + async def command_audioset_countrycode_user(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.user(ctx.author).country_code.set(country) + @command_audioset.command(name="cache") @commands.is_owner() async def command_audioset_cache(self, ctx: commands.Context, *, level: int = None): @@ -1315,3 +1368,73 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): 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) + + @commands.is_owner() + @command_audioset.group(name="globalapi") + async def command_audioset_audiodb(self, ctx: commands.Context): + """Change globalapi settings.""" + + @command_audioset_audiodb.command(name="toggle") + async def command_audioset_audiodb_toggle(self, ctx: commands.Context): + """Toggle the server settings. + + Default is ON + """ + state = await self.config.global_db_enabled() + await self.config.global_db_enabled.set(not state) + if not state: # Ensure a call is made if the API is enabled to update user perms + self.global_api_user = await self.api_interface.global_cache_api.get_perms() + await ctx.send( + _("Global DB is {status}").format(status=_("enabled") if not state else _("disabled")) + ) + + @command_audioset_audiodb.command(name="timeout") + async def command_audioset_audiodb_timeout( + self, ctx: commands.Context, timeout: Union[float, int] + ): + """Set GET request timeout. + + Example: 0.1 = 100ms 1 = 1 second + """ + + await self.config.global_db_get_timeout.set(timeout) + await ctx.send(_("Request timeout set to {time} second(s)").format(time=timeout)) + + @command_audioset.command(name="persistqueue") + @commands.admin() + async def command_audioset_persist_queue(self, ctx: commands.Context): + """Toggle persistent queues. + + Persistent queues allows the current queue to be restored when the queue closes. + """ + persist_cache = self._persist_queue_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).persist_queue() + ) + await self.config.guild(ctx.guild).persist_queue.set(not persist_cache) + self._persist_queue_cache[ctx.guild.id] = not persist_cache + await self.send_embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Persisting queues: {true_or_false}.").format( + true_or_false=_("Enabled") if not persist_cache else _("Disabled") + ), + ) + + @command_audioset.command(name="restart") + @commands.is_owner() + async def command_audioset_restart(self, ctx: commands.Context): + """Restarts the lavalink connection.""" + async with ctx.typing(): + lavalink.unregister_event_listener(self.lavalink_event_handler) + await lavalink.close() + if self.player_manager is not None: + await self.player_manager.shutdown() + + self.lavalink_restart_connect() + lavalink.register_event_listener(self.lavalink_event_handler) + await self.restore_players() + await self.send_embed_msg( + ctx, + title=_("Restarting Lavalink"), + description=_("It can take a couple of minutes for Lavalink to fully start up."), + ) diff --git a/redbot/cogs/audio/core/commands/controller.py b/redbot/cogs/audio/core/commands/controller.py index dbd116399..5d1cea9a3 100644 --- a/redbot/cogs/audio/core/commands/controller.py +++ b/redbot/cogs/audio/core/commands/controller.py @@ -2,13 +2,15 @@ import asyncio import contextlib import datetime import logging -from typing import Optional, Tuple, Union +import time + +from typing import Optional, Union import discord import lavalink -from redbot.core.utils import AsyncIter from redbot.core import commands +from redbot.core.utils import AsyncIter 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 @@ -67,6 +69,7 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands) await player.stop() await player.disconnect() + await self.api_interface.persistent_queue_api.drop(ctx.guild.id) @commands.command(name="now") @commands.guild_only() @@ -91,7 +94,10 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): dur = "LIVE" else: dur = self.format_time(player.current.length) - song = self.get_track_description(player.current, self.local_folder_current_path) or "" + song = ( + await 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) @@ -206,7 +212,9 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): 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) + description = await self.get_track_description( + player.current, self.local_folder_current_path + ) if player.current and not player.paused: await player.pause() @@ -262,6 +270,13 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): ) else: track = player.fetch("prev_song") + track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) 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) @@ -269,7 +284,7 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): player.queue.insert(0, bump_song) player.queue.pop(queue_len) await player.skip() - description = self.get_track_description( + description = await self.get_track_description( player.current, self.local_folder_current_path ) embed = discord.Embed(title=_("Replaying Track"), description=description) @@ -406,8 +421,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): 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`. + 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() @@ -581,6 +596,7 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): player.store("requester", None) await player.stop() await self.send_embed_msg(ctx, title=_("Stopping...")) + await self.api_interface.persistent_queue_api.drop(ctx.guild.id) @commands.command(name="summon") @commands.guild_only() @@ -626,12 +642,14 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) 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) + await self.self_deafen(player) except AttributeError: ctx.command.reset_cooldown(ctx) return await self.send_embed_msg( @@ -774,7 +792,12 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): ) 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.api_interface.persistent_queue_api.played( + ctx.guild.id, removed.extras.get("enqueue_time") + ) + removed_title = await self.get_track_description( + removed, self.local_folder_current_path + ) await self.send_embed_msg( ctx, title=_("Removed track from queue"), @@ -787,6 +810,9 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): if track.uri != index_or_url: clean_tracks.append(track) else: + await self.api_interface.persistent_queue_api.played( + ctx.guild.id, track.extras.get("enqueue_time") + ) removed_tracks += 1 player.queue = clean_tracks if removed_tracks == 0: @@ -841,7 +867,7 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): 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) + description = await 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/llset.py b/redbot/cogs/audio/core/commands/llset.py index 0eccecde2..16f579a18 100644 --- a/redbot/cogs/audio/core/commands/llset.py +++ b/redbot/cogs/audio/core/commands/llset.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path import discord @@ -18,6 +19,73 @@ class LavalinkSetupCommands(MixinMeta, metaclass=CompositeMetaClass): async def command_llsetup(self, ctx: commands.Context): """Lavalink server configuration options.""" + @command_llsetup.command(name="java") + async def command_llsetup_java(self, ctx: commands.Context, *, java_path: str = None): + """Change your Java executable path + + Enter nothing to reset to default. + """ + external = await self.config.use_external_lavalink() + if external: + return await self.send_embed_msg( + ctx, + title=_("Invalid Environment"), + description=_( + "You cannot changed the Java executable path of " + "external Lavalink instances from the Audio Cog." + ), + ) + if java_path is None: + await self.config.java_exc_path.clear() + await self.send_embed_msg( + ctx, + title=_("Java Executable Reset"), + description=_("Audio will now use `java` to run your Lavalink.jar"), + ) + else: + exc = Path(java_path) + exc_absolute = exc.absolute() + if not exc.exists() or not exc.is_file(): + return await self.send_embed_msg( + ctx, + title=_("Invalid Environment"), + description=_("`{java_path}` is not a valid executable").format( + java_path=exc_absolute + ), + ) + await self.config.java_exc_path.set(exc_absolute) + await self.send_embed_msg( + ctx, + title=_("Java Executable Changed"), + description=_("Audio will now use `{exc}` to run your Lavalink.jar").format( + exc=exc_absolute + ), + ) + 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=_( + "For it to take effect please reload " "Audio (`{prefix}reload audio`)." + ).format( + prefix=ctx.prefix, + ), + ) + else: + 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="external") async def command_llsetup_external(self, ctx: commands.Context): """Toggle using external Lavalink servers.""" diff --git a/redbot/cogs/audio/core/commands/miscellaneous.py b/redbot/cogs/audio/core/commands/miscellaneous.py index 620778def..604b14d91 100644 --- a/redbot/cogs/audio/core/commands/miscellaneous.py +++ b/redbot/cogs/audio/core/commands/miscellaneous.py @@ -6,9 +6,9 @@ import random import discord import lavalink -from redbot.core.utils import AsyncIter from redbot.core import commands +from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import humanize_number, pagify from redbot.core.utils.menus import DEFAULT_CONTROLS, menu @@ -52,7 +52,7 @@ class MiscellaneousCommands(MixinMeta, metaclass=CompositeMetaClass): try: if not p.current: raise AttributeError - current_title = self.get_track_description( + current_title = await self.get_track_description( p.current, self.local_folder_current_path ) msg += "{} [`{}`]: {}\n".format(p.channel.guild.name, connect_dur, current_title) diff --git a/redbot/cogs/audio/core/commands/player.py b/redbot/cogs/audio/core/commands/player.py index 4b986ed2a..7ee97eda0 100644 --- a/redbot/cogs/audio/core/commands/player.py +++ b/redbot/cogs/audio/core/commands/player.py @@ -2,20 +2,27 @@ import contextlib import datetime import logging import math -from typing import MutableMapping, Optional +import time + +from typing import MutableMapping import discord import lavalink -from discord.embeds import EmptyEmbed -from redbot.core.utils import AsyncIter +from discord.embeds import EmptyEmbed from redbot.core import commands from redbot.core.commands import UserInputOptional +from redbot.core.utils import AsyncIter 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 ...errors import ( + DatabaseError, + QueryUnauthorized, + SpotifyFetchError, + TrackEnqueueError, +) from ..abc import MixinMeta from ..cog_utils import CompositeMetaClass, _ @@ -39,10 +46,17 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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): + elif not await self.is_query_allowed(self.config, ctx, f"{query}", query_obj=query): return await self.send_embed_msg( ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.") ) + 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."), + ) if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink has failed") @@ -64,6 +78,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: return await self.send_embed_msg( ctx, @@ -76,15 +91,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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) @@ -143,10 +150,17 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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): + elif not await self.is_query_allowed(self.config, ctx, f"{query}", query_obj=query): return await self.send_embed_msg( ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.") ) + 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."), + ) if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink has failed") @@ -168,6 +182,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: return await self.send_embed_msg( ctx, @@ -180,15 +195,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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) @@ -258,13 +265,12 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): ) if seek and seek > 0: single_track.start_timestamp = seek * 1000 + query = Query.process_input(single_track, self.local_folder_current_path) 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))}" - ), + ctx, + f"{single_track.title} {single_track.author} {single_track.uri} " f"{str(query)}", + query_obj=query, ): if IS_DEBUG: log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") @@ -277,6 +283,13 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): elif guild_data["maxlength"] > 0: if self.is_track_length_allowed(single_track, guild_data["maxlength"]): single_track.requester = ctx.author + single_track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.queue.insert(0, single_track) player.maybe_shuffle() self.bot.dispatch( @@ -293,12 +306,21 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): else: single_track.requester = ctx.author single_track.extras["bumped"] = True + single_track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) 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) + description = await 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( @@ -395,6 +417,12 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): ).format(prefix=ctx.prefix), ) 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."), + ) if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink has failed") @@ -416,6 +444,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: return await self.send_embed_msg( ctx, @@ -428,12 +457,6 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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) @@ -509,6 +532,13 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.mod_or_permissions(manage_guild=True) async def command_autoplay(self, ctx: commands.Context): """Starts auto play.""" + 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."), + ) if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink has failed") @@ -530,6 +560,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: return await self.send_embed_msg( ctx, @@ -542,13 +573,6 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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) @@ -583,7 +607,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, try again in a few " + "I'm unable to get a track from Lavalink at the moment, try again in a few " "minutes." ), ) @@ -603,10 +627,15 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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. + Use `[p]search list ` to queue all tracks found on YouTube. Use `[p]search sc + ` to search on SoundCloud instead of YouTube. """ + if not isinstance(query, (str, Query)): + raise RuntimeError( + f"Expected 'query' to be a string or Query object but received: {type(query)} - this is an unexpected argument type, please report it." + ) + async def _search_menu( ctx: commands.Context, pages: list, @@ -654,6 +683,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: return await self.send_embed_msg( ctx, @@ -693,9 +723,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): 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 - ): + if not await self.is_query_allowed(self.config, ctx, f"{query}", query_obj=query): return await self.send_embed_msg( ctx, title=_("Unable To Play Tracks"), @@ -713,7 +741,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, " + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), ) @@ -729,7 +757,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, " + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), ) @@ -762,13 +790,12 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): async for track in AsyncIter(tracks): if len(player.queue) >= 10000: continue + query = Query.process_input(track, self.local_folder_current_path) 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))}" - ), + ctx, + f"{track.title} {track.author} {track.uri} " f"{str(query)}", + query_obj=query, ): if IS_DEBUG: log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") @@ -776,12 +803,26 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): elif guild_data["maxlength"] > 0: if self.is_track_length_allowed(track, guild_data["maxlength"]): track_len += 1 + track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, track) self.bot.dispatch( "red_audio_track_enqueue", player.channel.guild, track, ctx.author ) else: track_len += 1 + track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, track) self.bot.dispatch( "red_audio_track_enqueue", player.channel.guild, track, ctx.author @@ -832,7 +873,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment," + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), ) diff --git a/redbot/cogs/audio/core/commands/playlists.py b/redbot/cogs/audio/core/commands/playlists.py index fb742fa8a..e4974f053 100644 --- a/redbot/cogs/audio/core/commands/playlists.py +++ b/redbot/cogs/audio/core/commands/playlists.py @@ -4,22 +4,24 @@ import logging import math import os import tarfile +import time + from io import BytesIO -from typing import Optional, cast +from typing import cast import discord import lavalink -from redbot.core.utils import AsyncIter from redbot.core import commands from redbot.core.commands import UserInputOptional from redbot.core.data_manager import cog_data_path +from redbot.core.utils import AsyncIter 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 ...apis.playlist_interface import Playlist, create_playlist, delete_playlist, get_all_playlist from ...audio_dataclasses import LocalPath, Query from ...audio_logging import IS_DEBUG, debug_exc_log from ...converters import ComplexScopeParser, ScopeParser @@ -48,7 +50,6 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ​ ​ ​ ​ **User**: ​ ​ ​ ​ ​ ​ ​ ​ Visible to all bot users, if --author is passed. ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner and creator. - """ @command_playlist.command( @@ -1368,7 +1369,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): else: return await self.send_embed_msg( ctx, - title=_("Playlist Couldn't Be Created"), + title=_("Playlist Couldn't be created"), description=_("Unable to create your playlist."), ) @@ -1472,13 +1473,12 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): async for track in AsyncIter(tracks): if len(player.queue) >= 10000: continue + query = Query.process_input(track, self.local_folder_current_path) 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))}" - ), + ctx, + f"{track.title} {track.author} {track.uri} " f"{str(query)}", + query_obj=query, ): if IS_DEBUG: log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") @@ -1492,7 +1492,13 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): continue if maxlength > 0 and not self.is_track_length_allowed(track, maxlength): continue - + track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(author_obj, track) self.bot.dispatch( "red_audio_track_enqueue", player.channel.guild, track, ctx.author @@ -1800,7 +1806,9 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ) try: async with self.session.request("GET", file_url) as r: - uploaded_playlist = await r.json(content_type="text/plain", encoding="utf-8") + uploaded_playlist = await r.json( + content_type="text/plain", encoding="utf-8", loads=json.loads + ) except UnicodeDecodeError: return await self.send_embed_msg(ctx, title=_("Not a valid playlist file.")) @@ -1862,7 +1870,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, try again in a few " + "I'm unable to get a track from Lavalink at the moment, try again in a few " "minutes." ), ) diff --git a/redbot/cogs/audio/core/commands/queue.py b/redbot/cogs/audio/core/commands/queue.py index 46b966657..cc527407f 100644 --- a/redbot/cogs/audio/core/commands/queue.py +++ b/redbot/cogs/audio/core/commands/queue.py @@ -3,13 +3,14 @@ import contextlib import datetime import logging import math -from typing import MutableMapping, Optional, Union, Tuple + +from typing import MutableMapping, Optional import discord import lavalink -from redbot.core.utils import AsyncIter from redbot.core import commands +from redbot.core.utils import AsyncIter from redbot.core.utils.menus import ( DEFAULT_CONTROLS, close_menu, @@ -66,7 +67,10 @@ class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): dur = "LIVE" else: dur = self.format_time(player.current.length) - song = self.get_track_description(player.current, self.local_folder_current_path) or "" + song = ( + await 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) @@ -186,6 +190,10 @@ class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Clear Queue"), description=_("You need the DJ role to clear the queue."), ) + async for track in AsyncIter(player.queue): + await self.api_interface.persistent_queue_api.played( + ctx.guild.id, track.extras.get("enqueue_time") + ) player.queue.clear() await self.send_embed_msg( ctx, title=_("Queue Modified"), description=_("The queue has been cleared.") @@ -220,6 +228,9 @@ class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): if track.requester in listeners: clean_tracks.append(track) else: + await self.api_interface.persistent_queue_api.played( + ctx.guild.id, track.extras.get("enqueue_time") + ) removed_tracks += 1 player.queue = clean_tracks if removed_tracks == 0: @@ -252,6 +263,9 @@ class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): clean_tracks.append(track) else: removed_tracks += 1 + await self.api_interface.persistent_queue_api.played( + ctx.guild.id, track.extras.get("enqueue_time") + ) player.queue = clean_tracks if removed_tracks == 0: await self.send_embed_msg(ctx, title=_("Removed 0 tracks.")) @@ -325,6 +339,7 @@ class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: ctx.command.reset_cooldown(ctx) return await self.send_embed_msg( diff --git a/redbot/cogs/audio/core/events/cog.py b/redbot/cogs/audio/core/events/cog.py index 4e8039da3..79385f49c 100644 --- a/redbot/cogs/audio/core/events/cog.py +++ b/redbot/cogs/audio/core/events/cog.py @@ -2,6 +2,7 @@ import asyncio import datetime import logging import time + from typing import Optional import discord @@ -130,6 +131,13 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): ) except Exception as err: debug_exc_log(log, err, f"Failed to delete global daily playlist ID: {too_old_id}") + persist_cache = self._persist_queue_cache.setdefault( + guild.id, await self.config.guild(guild).persist_queue() + ) + if persist_cache: + await self.api_interface.persistent_queue_api.played( + guild_id=guild.id, track_id=track_identifier + ) @commands.Cog.listener() async def on_red_audio_queue_end( @@ -141,6 +149,21 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): await self.api_interface.local_cache_api.youtube.clean_up_old_entries() await asyncio.sleep(5) await self.playlist_api.delete_scheduled() + await self.api_interface.persistent_queue_api.drop(guild.id) + await asyncio.sleep(5) + await self.api_interface.persistent_queue_api.delete_scheduled() + + @commands.Cog.listener() + async def on_red_audio_track_enqueue(self, guild: discord.Guild, track, requester): + if not (track and guild): + return + persist_cache = self._persist_queue_cache.setdefault( + guild.id, await self.config.guild(guild).persist_queue() + ) + if persist_cache: + await self.api_interface.persistent_queue_api.enqueued( + guild_id=guild.id, room_id=track.extras["vc"], track=track + ) @commands.Cog.listener() async def on_red_audio_track_end( @@ -152,3 +175,6 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): await self.api_interface.local_cache_api.youtube.clean_up_old_entries() await asyncio.sleep(5) await self.playlist_api.delete_scheduled() + await self.api_interface.persistent_queue_api.drop(guild.id) + await asyncio.sleep(5) + await self.api_interface.persistent_queue_api.delete_scheduled() diff --git a/redbot/cogs/audio/core/events/dpy.py b/redbot/cogs/audio/core/events/dpy.py index 6f5b40639..b5f5315ea 100644 --- a/redbot/cogs/audio/core/events/dpy.py +++ b/redbot/cogs/audio/core/events/dpy.py @@ -1,19 +1,24 @@ import asyncio +import contextlib import logging import re + +from collections import OrderedDict from pathlib import Path from typing import Final, Pattern import discord import lavalink + from aiohttp import ClientConnectorError - +from discord.ext.commands import CheckFailure from redbot.core import commands +from redbot.core.utils.chat_formatting import box, humanize_list -from ..abc import MixinMeta -from ..cog_utils import CompositeMetaClass, _ from ...audio_logging import debug_exc_log from ...errors import TrackEnqueueError +from ..abc import MixinMeta +from ..cog_utils import HUMANIZED_PERM, CompositeMetaClass, _ log = logging.getLogger("red.cogs.Audio.cog.Events.dpy") @@ -39,11 +44,55 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): 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) + + current_perms = ctx.channel.permissions_for(ctx.me) + surpass_ignore = ( + isinstance(ctx.channel, discord.abc.PrivateChannel) + or await ctx.bot.is_owner(ctx.author) + or await ctx.bot.is_admin(ctx.author) + ) + guild = ctx.guild + if guild and not current_perms.is_superset(self.permission_cache): + current_perms_set = set(iter(current_perms)) + expected_perms_set = set(iter(self.permission_cache)) + diff = expected_perms_set - current_perms_set + missing_perms = dict((i for i in diff if i[-1] is not False)) + missing_perms = OrderedDict(sorted(missing_perms.items())) + missing_permissions = missing_perms.keys() + log.debug( + "Missing the following perms in %d, Owner ID: %d: %s", + ctx.guild.id, + ctx.guild.owner.id, + humanize_list(list(missing_permissions)), + ) + if not surpass_ignore: + text = _( + "I'm missing permissions in this server, " + "Please address this as soon as possible.\n\n" + "Expected Permissions:\n" + ) + for perm, value in missing_perms.items(): + text += "{perm}: [{status}]\n".format( + status=_("Enabled") if value else _("Disabled"), + perm=HUMANIZED_PERM.get(perm), + ) + text = text.strip() + if current_perms.send_messages and current_perms.read_messages: + await ctx.send(box(text=text, lang="ini")) + else: + log.info( + "Missing write permission in %d, Owner ID: %d", + ctx.guild.id, + ctx.guild.owner.id, + ) + raise CheckFailure(message=text) + + 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) + self._daily_global_playlist_cache.setdefault( self.bot.user.id, await self.config.daily_playlists() ) @@ -51,12 +100,16 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): self.local_folder_current_path = Path(await self.config.localpath()) if not ctx.guild: return + 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._persist_queue_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).persist_queue() + ) if dj_enabled: dj_role = self._dj_role_cache.setdefault( ctx.guild.id, await self.config.guild(ctx.guild).dj_role() @@ -78,12 +131,16 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): 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 + 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 + ctx, + title=_("Unable To Parse Argument"), + description=msg, + error=True, ) if error.send_cmd_help: await ctx.send_help() @@ -101,7 +158,10 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): ) else: await self.send_embed_msg( - ctx, title=_("Invalid Argument"), description=error.args[0], error=True + ctx, + title=_("Invalid Argument"), + description=error.args[0], + error=True, ) else: await ctx.send_help() @@ -137,6 +197,17 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): error=True, ) debug_exc_log(log, error, "This is a handled error") + elif isinstance(error, discord.errors.HTTPException): + handled = True + await self.send_embed_msg( + ctx, + title=_("There was an issue communicating with Discord."), + description=_("This error has been reported to the bot owner."), + error=True, + ) + log.exception( + "This is not handled in the core Audio cog, please report it.", exc_info=error + ) if not isinstance( error, ( @@ -186,3 +257,13 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): self.skip_votes[before.channel.guild].remove(member.id) except (ValueError, KeyError, AttributeError): pass + channel = self.rgetattr(member, "voice.channel", None) + bot_voice_state = self.rgetattr(member, "guild.me.voice.self_deaf", None) + if channel and bot_voice_state is False: + try: + player = lavalink.get_player(channel.guild.id) + except (KeyError, AttributeError): + pass + else: + if player.channel.id == channel.id: + await self.self_deafen(player) diff --git a/redbot/cogs/audio/core/events/lavalink.py b/redbot/cogs/audio/core/events/lavalink.py index 4f9e69f6f..6a7dbd331 100644 --- a/redbot/cogs/audio/core/events/lavalink.py +++ b/redbot/cogs/audio/core/events/lavalink.py @@ -18,6 +18,8 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): ) -> None: current_track = player.current current_channel = player.channel + if not current_channel: + return guild = self.rgetattr(current_channel, "guild", None) if await self.bot.cog_disabled_in_guild(self, guild): await player.stop() @@ -31,12 +33,15 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): current_length = self.rgetattr(current_track, "length", None) current_thumbnail = self.rgetattr(current_track, "thumbnail", None) current_extras = self.rgetattr(current_track, "extras", {}) + current_id = self.rgetattr(current_track, "_info", {}).get("identifier") 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) + description = await 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") @@ -51,12 +56,18 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): player.store("playing_song", current_track) player.store("requester", current_requester) self.bot.dispatch("red_audio_track_start", guild, current_track, current_requester) + if guild_id and current_track: + await self.api_interface.persistent_queue_api.played( + guild_id=guild_id, track_id=current_track.track_identifier + ) 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 guild_id: + await self.api_interface.persistent_queue_api.drop(guild_id) if ( autoplay and not player.queue @@ -82,7 +93,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): notify_channel, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, try again in a few " + "I'm unable to get a track from Lavalink at the moment, try again in a few " "minutes." ), ) @@ -127,13 +138,13 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): ) player.store("notify_message", notify_message) if event_type == lavalink.LavalinkEvents.TRACK_START and status: - player_check = self.get_active_player_count() + player_check = await 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() + player_check = await self.get_active_player_count() await self.update_bot_presence(*player_check) if event_type == lavalink.LavalinkEvents.QUEUE_END: @@ -146,7 +157,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): self.bot.dispatch("red_audio_audio_disconnect", guild) await player.disconnect() if status: - player_check = self.get_active_player_count() + player_check = await self.get_active_player_count() await self.update_bot_presence(*player_check) if event_type in [ @@ -209,5 +220,9 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): colour=await self.bot.get_embed_color(message_channel), description="{}\n{}".format(extra.replace("\n", ""), description), ) + if current_id: + asyncio.create_task( + self.api_interface.global_cache_api.report_invalid(current_id) + ) await message_channel.send(embed=embed) await player.skip() diff --git a/redbot/cogs/audio/core/tasks/lavalink.py b/redbot/cogs/audio/core/tasks/lavalink.py index 1be63aa89..05f970554 100644 --- a/redbot/cogs/audio/core/tasks/lavalink.py +++ b/redbot/cogs/audio/core/tasks/lavalink.py @@ -23,7 +23,9 @@ class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass): max_retries = 5 retry_count = 0 while retry_count < max_retries: - external = await self.config.use_external_lavalink() + configs = await self.config.all() + external = configs["use_external_lavalink"] + java_exec = configs["java_exc_path"] if external is False: settings = self._default_lavalink_settings host = settings["host"] @@ -34,7 +36,7 @@ class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass): await self.player_manager.shutdown() self.player_manager = ServerManager() try: - await self.player_manager.start() + await self.player_manager.start(java_exec) except LavalinkDownloadFailed as exc: await asyncio.sleep(1) if exc.should_retry: @@ -66,11 +68,10 @@ class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass): 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"] + host = configs["host"] + password = configs["password"] + rest_port = configs["rest_port"] + ws_port = configs["ws_port"] break else: log.critical( diff --git a/redbot/cogs/audio/core/tasks/player.py b/redbot/cogs/audio/core/tasks/player.py index f2715e158..d09b83d06 100644 --- a/redbot/cogs/audio/core/tasks/player.py +++ b/redbot/cogs/audio/core/tasks/player.py @@ -1,9 +1,11 @@ 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 @@ -48,6 +50,7 @@ class PlayerTasks(MixinMeta, metaclass=CompositeMetaClass): stop_times.pop(sid) try: player = lavalink.get_player(sid) + await self.api_interface.persistent_queue_api.drop(sid) await player.stop() await player.disconnect() except Exception as err: diff --git a/redbot/cogs/audio/core/tasks/startup.py b/redbot/cogs/audio/core/tasks/startup.py index 0e7417762..18e01de7b 100644 --- a/redbot/cogs/audio/core/tasks/startup.py +++ b/redbot/cogs/audio/core/tasks/startup.py @@ -1,14 +1,22 @@ +import asyncio +import datetime +import itertools import logging +from typing import Optional + import lavalink from redbot.core.data_manager import cog_data_path +from redbot.core.utils._internal_utils import send_to_owners_with_prefix_replaced from redbot.core.utils.dbtools import APSWConnectionWrapper from ...apis.interface import AudioAPIInterface from ...apis.playlist_wrapper import PlaylistWrapper +from ...audio_logging import debug_exc_log +from ...utils import task_callback from ..abc import MixinMeta -from ..cog_utils import _SCHEMA_VERSION, CompositeMetaClass +from ..cog_utils import _, _OWNER_NOTIFICATION, _SCHEMA_VERSION, CompositeMetaClass log = logging.getLogger("red.cogs.Audio.cog.Tasks.startup") @@ -19,11 +27,13 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): # 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()) + self.cog_init_task.add_done_callback(task_callback) async def initialize(self) -> None: await self.bot.wait_until_red_ready() # Unlike most cases, we want the cache to exit before migration. try: + await self.maybe_message_all_owners() self.db_conn = APSWConnectionWrapper( str(cog_data_path(self.bot.get_cog("Audio")) / "Audio.db") ) @@ -33,17 +43,109 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): self.playlist_api = PlaylistWrapper(self.bot, self.config, self.db_conn) await self.playlist_api.init() await self.api_interface.initialize() + self.global_api_user = await self.api_interface.global_cache_api.get_perms() await self.data_schema_migration( from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION ) await self.playlist_api.delete_scheduled() + await self.api_interface.persistent_queue_api.delete_scheduled() self.lavalink_restart_connect() self.player_automated_timer_task = self.bot.loop.create_task( self.player_automated_timer() ) + self.player_automated_timer_task.add_done_callback(task_callback) lavalink.register_event_listener(self.lavalink_event_handler) + await self.restore_players() 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() + + async def restore_players(self): + tries = 0 + tracks_to_restore = await self.api_interface.persistent_queue_api.fetch_all() + for guild_id, track_data in itertools.groupby(tracks_to_restore, key=lambda x: x.guild_id): + await asyncio.sleep(0) + try: + player: Optional[lavalink.Player] + track_data = list(track_data) + guild = self.bot.get_guild(guild_id) + persist_cache = self._persist_queue_cache.setdefault( + guild_id, await self.config.guild(guild).persist_queue() + ) + if not persist_cache: + await self.api_interface.persistent_queue_api.drop(guild_id) + continue + if self.lavalink_connection_aborted: + player = None + else: + try: + player = lavalink.get_player(guild_id) + except IndexError: + player = None + except KeyError: + player = None + + vc = 0 + if player is None: + while tries < 25 and vc is not None: + try: + vc = guild.get_channel(track_data[-1].room_id) + await lavalink.connect(vc) + player = lavalink.get_player(guild.id) + player.store("connect", datetime.datetime.utcnow()) + player.store("guild", guild_id) + await self.self_deafen(player) + break + except IndexError: + await asyncio.sleep(5) + tries += 1 + except Exception as exc: + debug_exc_log(log, exc, "Failed to restore music voice channel") + if vc is None: + break + + if tries >= 25 or guild is None or vc is None: + await self.api_interface.persistent_queue_api.drop(guild_id) + continue + + shuffle = await self.config.guild(guild).shuffle() + repeat = await self.config.guild(guild).repeat() + volume = await self.config.guild(guild).volume() + shuffle_bumped = await self.config.guild(guild).shuffle_bumped() + player.repeat = repeat + player.shuffle = shuffle + player.shuffle_bumped = shuffle_bumped + if player.volume != volume: + await player.set_volume(volume) + for track in track_data: + track = track.track_object + player.add(guild.get_member(track.extras.get("requester")) or guild.me, track) + player.maybe_shuffle() + + await player.play() + except Exception as err: + debug_exc_log(log, err, f"Error restoring player in {guild_id}") + await self.api_interface.persistent_queue_api.drop(guild_id) + + async def maybe_message_all_owners(self): + current_notification = await self.config.owner_notification() + if current_notification == _OWNER_NOTIFICATION: + return + if current_notification < 1 <= _OWNER_NOTIFICATION: + msg = _( + """Hello, this message brings you an important update regarding the core Audio cog: + +Starting from Audio v2.3.0+ you can take advantage of the **Global Audio API**, a new service offered by the Cog-Creators organization that allows your bot to greatly reduce the amount of requests done to YouTube / Spotify. This reduces the likelihood of YouTube rate-limiting your bot for making requests too often. +See `[p]help audioset globalapi` for more information. +Access to this service is disabled by default and **requires you to explicitly opt-in** to start using it. + +An access token is **required** to use this API. To obtain this token you may join and run `?audioapi register` in the #testing channel. +Note: by using this service you accept that your bot's IP address will be disclosed to the Cog-Creators organization and used only for the purpose of providing the Global API service. + +On a related note, it is highly recommended that you enable your local cache if you haven't yet. +To do so, run `[p]audioset cache 5`. This cache, which stores only metadata, will make repeated audio requests faster and further reduce the likelihood of YouTube rate-limiting your bot. Since it's only metadata the required disk space for this cache is expected to be negligible.""" + ) + await send_to_owners_with_prefix_replaced(self.bot, msg) + await self.config.owner_notification.set(1) diff --git a/redbot/cogs/audio/core/utilities/__init__.py b/redbot/cogs/audio/core/utilities/__init__.py index b7a88dbc2..824521234 100644 --- a/redbot/cogs/audio/core/utilities/__init__.py +++ b/redbot/cogs/audio/core/utilities/__init__.py @@ -3,6 +3,7 @@ from .equalizer import EqualizerUtilities from .formatting import FormattingUtilities from .local_tracks import LocalTrackUtilities from .miscellaneous import MiscellaneousUtilities +from .parsers import ParsingUtilities from .player import PlayerUtilities from .playlists import PlaylistUtilities from .queue import QueueUtilities @@ -18,6 +19,7 @@ class Utilities( PlaylistUtilities, QueueUtilities, ValidationUtilities, + ParsingUtilities, 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 index d3ea40e8b..6c77d2a78 100644 --- a/redbot/cogs/audio/core/utilities/equalizer.py +++ b/redbot/cogs/audio/core/utilities/equalizer.py @@ -1,6 +1,7 @@ import asyncio import contextlib import logging + from typing import List import discord diff --git a/redbot/cogs/audio/core/utilities/formatting.py b/redbot/cogs/audio/core/utilities/formatting.py index b60692333..84e7f694d 100644 --- a/redbot/cogs/audio/core/utilities/formatting.py +++ b/redbot/cogs/audio/core/utilities/formatting.py @@ -2,14 +2,16 @@ import datetime import logging import math import re +import time + from typing import List, Optional import discord import lavalink -from discord.embeds import EmptyEmbed -from redbot.core.utils import AsyncIter +from discord.embeds import EmptyEmbed from redbot.core import commands +from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import box, escape from ...audio_dataclasses import LocalPath, Query @@ -98,6 +100,7 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except AttributeError: return await self.send_embed_msg(ctx, title=_("Connect to a voice channel first.")) except IndexError: @@ -128,7 +131,9 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): 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) + description = await 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: @@ -148,14 +153,12 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): queue_dur = await self.queue_duration(ctx) queue_total_duration = self.format_time(queue_dur) before_queue_length = len(player.queue) - + query = Query.process_input(search_choice, self.local_folder_current_path) 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))}" - ), + ctx, + f"{search_choice.title} {search_choice.author} {search_choice.uri} " f"{str(query)}", + query_obj=query, ): if IS_DEBUG: log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") @@ -166,6 +169,13 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): elif guild_data["maxlength"] > 0: if self.is_track_length_allowed(search_choice, guild_data["maxlength"]): + search_choice.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, search_choice) player.maybe_shuffle() self.bot.dispatch( @@ -174,6 +184,13 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): else: return await self.send_embed_msg(ctx, title=_("Track exceeds maximum length.")) else: + search_choice.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, search_choice) player.maybe_shuffle() self.bot.dispatch( @@ -191,9 +208,11 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): await player.play() return await self.send_embed_msg(ctx, embed=songembed) - def _format_search_options(self, search_choice): + async 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) + description = await self.get_track_description( + search_choice, self.local_folder_current_path + ) return description, query async def _build_search_page( @@ -259,10 +278,10 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): ) return embed - def get_track_description( + async def get_track_description( self, track, local_folder_current_path, shorten=False ) -> Optional[str]: - """Get the user facing formatted track name""" + """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) @@ -299,7 +318,13 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): string = "{}...".format((string[:40]).rstrip(" ")) string = f'**{escape(f"{string}", formatting=True)}**' else: - if track.author.lower() not in track.title.lower(): + if track.is_stream: + icy = await self.icyparser(track.uri) + if icy: + title = icy + else: + title = f"{track.title} - {track.author}" + elif track.author.lower() not in track.title.lower(): title = f"{track.title} - {track.author}" else: title = track.title @@ -315,8 +340,10 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): 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""" + async 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: @@ -332,7 +359,13 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): else: return query.to_string_user() else: - if track.author.lower() not in track.title.lower(): + if track.is_stream: + icy = await self.icyparser(track.uri) + if icy: + title = icy + else: + title = f"{track.title} - {track.author}" + elif track.author.lower() not in track.title.lower(): title = f"{track.title} - {track.author}" else: title = track.title @@ -342,7 +375,7 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): return None def format_playlist_picker_data(self, pid, pname, ptracks, pauthor, scope) -> str: - """Format the values into a pretified codeblock""" + """Format the values into a prettified codeblock.""" author = self.bot.get_user(pauthor) or pauthor or _("Unknown") line = _( " - Name: <{pname}>\n" @@ -359,9 +392,9 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): player = lavalink.get_player(ctx.guild.id) paused = player.paused pos = player.position - dur = player.current.length + dur = getattr(player.current, "length", player.position or 1) sections = 12 - loc_time = round((pos / dur) * sections) + loc_time = round((pos / dur if dur != 0 else pos) * sections) bar = "\N{BOX DRAWINGS HEAVY HORIZONTAL}" seek = "\N{RADIO BUTTON}" if paused: diff --git a/redbot/cogs/audio/core/utilities/local_tracks.py b/redbot/cogs/audio/core/utilities/local_tracks.py index 401ac09f1..09ff73c75 100644 --- a/redbot/cogs/audio/core/utilities/local_tracks.py +++ b/redbot/cogs/audio/core/utilities/local_tracks.py @@ -1,16 +1,17 @@ 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 redbot.core.utils import AsyncIter -from ...errors import TrackEnqueueError from ...audio_dataclasses import LocalPath, Query +from ...errors import TrackEnqueueError from ..abc import MixinMeta from ..cog_utils import CompositeMetaClass, _ @@ -32,7 +33,7 @@ class LocalTrackUtilities(MixinMeta, metaclass=CompositeMetaClass): ) async def get_localtrack_folder_list(self, ctx: commands.Context, query: Query) -> List[Query]: - """Return a list of folders per the provided 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) @@ -49,7 +50,7 @@ class LocalTrackUtilities(MixinMeta, metaclass=CompositeMetaClass): 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""" + """Return a list of tracks per the provided query.""" if not await self.localtracks_folder_exists(ctx) or self.api_interface is None: return [] diff --git a/redbot/cogs/audio/core/utilities/miscellaneous.py b/redbot/cogs/audio/core/utilities/miscellaneous.py index e8eb84764..f2d15670b 100644 --- a/redbot/cogs/audio/core/utilities/miscellaneous.py +++ b/redbot/cogs/audio/core/utilities/miscellaneous.py @@ -5,21 +5,22 @@ import functools import json import logging import re -from typing import Any, Final, MutableMapping, Union, cast, Mapping, Pattern + +from typing import Any, Final, Mapping, MutableMapping, Pattern, Union, cast import discord import lavalink -from discord.embeds import EmptyEmbed -from redbot.core.utils import AsyncIter +from discord.embeds import EmptyEmbed from redbot.core import bank, commands from redbot.core.commands import Context +from redbot.core.utils import AsyncIter 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 +from ...utils import PlaylistScope, task_callback +from ..abc import MixinMeta +from ..cog_utils import CompositeMetaClass, _ log = logging.getLogger("red.cogs.Audio.cog.Utilities.miscellaneous") @@ -32,7 +33,9 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass): 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)) + task = self.bot.loop.create_task(self.clear_react(message, emoji)) + task.add_done_callback(task_callback) + return task async def maybe_charge_requester(self, ctx: commands.Context, jukebox_price: int) -> bool: jukebox = await self.config.guild(ctx.guild).jukebox() diff --git a/redbot/cogs/audio/core/utilities/parsers.py b/redbot/cogs/audio/core/utilities/parsers.py new file mode 100644 index 000000000..0b06bea33 --- /dev/null +++ b/redbot/cogs/audio/core/utilities/parsers.py @@ -0,0 +1,35 @@ +import logging +import re +import struct + +from typing import Final, Optional + +import aiohttp + +from ..abc import MixinMeta +from ..cog_utils import CompositeMetaClass + +log = logging.getLogger("red.cogs.Audio.cog.Utilities.Parsing") + +STREAM_TITLE: Final[re.Pattern] = re.compile(br"StreamTitle='([^']*)';") + + +class ParsingUtilities(MixinMeta, metaclass=CompositeMetaClass): + async def icyparser(self, url: str) -> Optional[str]: + try: + async with self.session.get(url, headers={"Icy-MetaData": "1"}) as resp: + metaint = int(resp.headers["icy-metaint"]) + for _ in range(5): + await resp.content.readexactly(metaint) + metadata_length = struct.unpack("B", await resp.content.readexactly(1))[0] * 16 + metadata = await resp.content.readexactly(metadata_length) + m = re.search(STREAM_TITLE, metadata.rstrip(b"\0")) + if m: + title = m.group(1) + if title: + title = title.decode("utf-8", errors="replace") + return title + else: + return None + except (KeyError, aiohttp.ClientConnectionError, aiohttp.ClientResponseError): + return None diff --git a/redbot/cogs/audio/core/utilities/player.py b/redbot/cogs/audio/core/utilities/player.py index 06828aec2..77d8e3b77 100644 --- a/redbot/cogs/audio/core/utilities/player.py +++ b/redbot/cogs/audio/core/utilities/player.py @@ -1,14 +1,15 @@ 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 discord.embeds import EmptyEmbed from redbot.core import commands +from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import bold, escape from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query @@ -42,7 +43,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): self._error_timer[guild] = now return self._error_counter[guild] >= 5 - def get_active_player_count(self) -> Tuple[Optional[str], int]: + async def get_active_player_count(self) -> Tuple[Optional[str], int]: try: current = next( ( @@ -52,7 +53,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ), None, ) - get_single_title = self.get_track_description_unformatted( + get_single_title = await self.get_track_description_unformatted( current, self.local_folder_current_path ) playing_servers = len(lavalink.active_players()) @@ -149,7 +150,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): elif autoplay and not player.queue: embed = discord.Embed( title=_("Track Skipped"), - description=self.get_track_description( + description=await self.get_track_description( player.current, self.local_folder_current_path ), ) @@ -184,7 +185,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): else: embed = discord.Embed( title=_("Track Skipped"), - description=self.get_track_description( + description=await self.get_track_description( player.current, self.local_folder_current_path ), ) @@ -208,6 +209,17 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): except (IndexError, KeyError): return False + async def self_deafen(self, player: lavalink.Player) -> None: + guild_id = self.rgetattr(player, "channel.guild.id", None) + if not guild_id: + return + if not await self.config.guild_from_id(guild_id).auto_deafen(): + return + channel_id = player.channel.id + node = player.manager.node + voice_ws = node.get_voice_ws(guild_id) + await voice_ws.voice_state(guild_id, channel_id, self_deaf=True) + async def _get_spotify_tracks( self, ctx: commands.Context, query: Query, forced: bool = False ) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]: @@ -285,7 +297,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, " + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), ) @@ -354,9 +366,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): 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 - ): + if not await self.is_query_allowed(self.config, ctx, f"{query}", query_obj=query): raise QueryUnauthorized( _("{query} is not an allowed query.").format(query=query.to_string_user()) ) @@ -373,7 +383,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, " + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), ) @@ -423,13 +433,12 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): async for track in AsyncIter(tracks): if len(player.queue) >= 10000: continue + query = Query.process_input(track, self.local_folder_current_path) 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))}" - ), + ctx, + f"{track.title} {track.author} {track.uri} " f"{str(query)}", + query_obj=query, ): if IS_DEBUG: log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") @@ -437,6 +446,13 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): elif guild_data["maxlength"] > 0: if self.is_track_length_allowed(track, guild_data["maxlength"]): track_len += 1 + track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, track) self.bot.dispatch( "red_audio_track_enqueue", player.channel.guild, track, ctx.author @@ -444,6 +460,13 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): else: track_len += 1 + track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, track) self.bot.dispatch( "red_audio_track_enqueue", player.channel.guild, track, ctx.author @@ -499,13 +522,15 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ) if seek and seek > 0: single_track.start_timestamp = seek * 1000 + query = Query.process_input(single_track, self.local_folder_current_path) if not await self.is_query_allowed( self.config, - ctx.guild, + ctx, ( f"{single_track.title} {single_track.author} {single_track.uri} " - f"{str(Query.process_input(single_track, self.local_folder_current_path))}" + f"{str(query)}" ), + query_obj=query, ): if IS_DEBUG: log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") @@ -515,6 +540,13 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ) elif guild_data["maxlength"] > 0: if self.is_track_length_allowed(single_track, guild_data["maxlength"]): + single_track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, single_track) player.maybe_shuffle() self.bot.dispatch( @@ -530,6 +562,13 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ) else: + single_track.extras.update( + { + "enqueue_time": int(time.time()), + "vc": player.channel.id, + "requester": ctx.author.id, + } + ) player.add(ctx.author, single_track) player.maybe_shuffle() self.bot.dispatch( @@ -542,7 +581,9 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): 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) + description = await 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( @@ -588,6 +629,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): lock=self.update_player_lock, notifier=notifier, forced=forced, + query_global=await self.config.global_db_enabled(), ) except SpotifyFetchError as error: self.update_player_lock(ctx, False) @@ -602,7 +644,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment," + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), error=True, @@ -657,6 +699,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): and len(player.queue) == 0 ): await player.move_to(user_channel) + await self.self_deafen(player) return True else: return False diff --git a/redbot/cogs/audio/core/utilities/playlists.py b/redbot/cogs/audio/core/utilities/playlists.py index d95f9339c..1b4cea9bf 100644 --- a/redbot/cogs/audio/core/utilities/playlists.py +++ b/redbot/cogs/audio/core/utilities/playlists.py @@ -4,14 +4,15 @@ 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 discord.embeds import EmptyEmbed from redbot.core import commands +from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import box from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import ReactionPredicate @@ -408,7 +409,7 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, " + "I'm unable to get a track from Lavalink at the moment, " "try again in a few minutes." ), ) @@ -513,6 +514,7 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) + await self.self_deafen(player) except IndexError: await self.send_embed_msg( ctx, @@ -593,7 +595,7 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, try again in a few " + "I'm unable to get a track from Lavalink at the moment, try again in a few " "minutes." ), ) @@ -619,7 +621,7 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Unable to Get Track"), description=_( - "I'm unable get a track from Lavalink at the moment, try again in a few " + "I'm unable to get a track from Lavalink at the moment, try again in a few " "minutes." ), ) diff --git a/redbot/cogs/audio/core/utilities/queue.py b/redbot/cogs/audio/core/utilities/queue.py index 2726d3682..5c031fed6 100644 --- a/redbot/cogs/audio/core/utilities/queue.py +++ b/redbot/cogs/audio/core/utilities/queue.py @@ -1,13 +1,14 @@ import logging import math + from typing import List, Tuple import discord import lavalink -from fuzzywuzzy import process -from redbot.core.utils import AsyncIter +from fuzzywuzzy import process from redbot.core import commands +from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import humanize_number from ...audio_dataclasses import LocalPath, Query @@ -46,7 +47,7 @@ class QueueUtilities(MixinMeta, metaclass=CompositeMetaClass): 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( + current_track_description = await self.get_track_description( player.current, self.local_folder_current_path ) if query.is_stream: @@ -65,7 +66,7 @@ class QueueUtilities(MixinMeta, metaclass=CompositeMetaClass): ): req_user = track.requester track_idx = i + 1 - track_description = self.get_track_description( + track_description = await self.get_track_description( track, self.local_folder_current_path, shorten=True ) queue_list += f"`{track_idx}.` {track_description}, " @@ -76,6 +77,7 @@ class QueueUtilities(MixinMeta, metaclass=CompositeMetaClass): 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) diff --git a/redbot/cogs/audio/core/utilities/validation.py b/redbot/cogs/audio/core/utilities/validation.py index b20b9be59..0e3c3d084 100644 --- a/redbot/cogs/audio/core/utilities/validation.py +++ b/redbot/cogs/audio/core/utilities/validation.py @@ -1,11 +1,13 @@ import logging import re -from typing import Final, List, Set, Pattern + +from typing import Final, List, Optional, Pattern, Set, Union from urllib.parse import urlparse import discord from redbot.core import Config +from redbot.core.commands import Context from ...audio_dataclasses import Query from ..abc import MixinMeta @@ -54,11 +56,21 @@ class ValidationUtilities(MixinMeta, metaclass=CompositeMetaClass): 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 + self, + config: Config, + ctx_or_channel: Optional[Union[Context, discord.TextChannel]], + query: str, + query_obj: Query, ) -> bool: - """Checks if the query is allowed in this server or globally""" - - query = query.lower().strip() + """Checks if the query is allowed in this server or globally.""" + if ctx_or_channel: + guild = ctx_or_channel.guild + channel = ( + ctx_or_channel.channel if isinstance(ctx_or_channel, Context) else ctx_or_channel + ) + query = query.lower().strip() + else: + guild = None if query_obj is not None: query = query_obj.lavalink_query.replace("ytsearch:", "youtubesearch").replace( "scsearch:", "soundcloudsearch" diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index f0a9ef24b..4a084ab14 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -1,6 +1,7 @@ import asyncio import asyncio.subprocess # disables for # https://github.com/PyCQA/pylint/issues/1469 import itertools +import json import logging import pathlib import platform @@ -9,14 +10,16 @@ import shutil import sys import tempfile import time -from typing import ClassVar, Final, List, Optional, Tuple, Pattern + +from typing import ClassVar, Final, List, Optional, Pattern, Tuple import aiohttp -from tqdm import tqdm from redbot.core import data_manager +from tqdm import tqdm from .errors import LavalinkDownloadFailed +from .utils import task_callback log = logging.getLogger("red.audio.manager") JAR_VERSION: Final[str] = "3.3.1" @@ -57,6 +60,7 @@ class ServerManager: _jvm: ClassVar[Optional[str]] = None _lavalink_branch: ClassVar[Optional[str]] = None _buildtime: ClassVar[Optional[str]] = None + _java_exc: ClassVar[str] = "java" def __init__(self) -> None: self.ready: asyncio.Event = asyncio.Event() @@ -65,6 +69,10 @@ class ServerManager: self._monitor_task: Optional[asyncio.Task] = None self._shutdown: bool = False + @property + def path(self) -> Optional[str]: + return self._java_exc + @property def jvm(self) -> Optional[str]: return self._jvm @@ -85,8 +93,9 @@ class ServerManager: def build_time(self) -> Optional[str]: return self._buildtime - async def start(self) -> None: + async def start(self, java_path: str) -> None: arch_name = platform.machine() + self._java_exc = java_path if arch_name in self._blacklisted_archs: raise asyncio.CancelledError( "You are attempting to run Lavalink audio on an unsupported machine architecture." @@ -121,6 +130,7 @@ class ServerManager: log.warning("Timeout occurred whilst waiting for internal Lavalink server to be ready") self._monitor_task = asyncio.create_task(self._monitor()) + self._monitor_task.add_done_callback(task_callback) async def _get_jar_args(self) -> List[str]: (java_available, java_version) = await self._has_java() @@ -128,27 +138,36 @@ class ServerManager: if not java_available: raise RuntimeError("You must install Java 11 for Lavalink to run.") - return ["java", "-Djdk.tls.client.protocols=TLSv1.2", "-jar", str(LAVALINK_JAR_FILE)] + return [ + self._java_exc, + "-Djdk.tls.client.protocols=TLSv1.2", + "-jar", + str(LAVALINK_JAR_FILE), + ] async def _has_java(self) -> Tuple[bool, Optional[Tuple[int, int]]]: if self._java_available is not None: # Return cached value if we've checked this before return self._java_available, self._java_version - java_available = shutil.which("java") is not None + java_exec = shutil.which(self._java_exc) + java_available = java_exec is not None if not java_available: self.java_available = False self.java_version = None else: self._java_version = version = await self._get_java_version() self._java_available = (11, 0) <= version < (12, 0) + self._java_exc = java_exec return self._java_available, self._java_version - @staticmethod - async def _get_java_version() -> Tuple[int, int]: + async def _get_java_version(self) -> Tuple[int, int]: """This assumes we've already checked that java exists.""" _proc: asyncio.subprocess.Process = ( await asyncio.create_subprocess_exec( # pylint:disable=no-member - "java", "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + self._java_exc, + "-version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) ) # java -version outputs to stderr @@ -171,10 +190,7 @@ class ServerManager: elif short_match: return int(short_match["major"]), 0 - raise RuntimeError( - "The output of `java -version` was unexpected. Please report this issue on Red's " - "issue tracker." - ) + raise RuntimeError(f"The output of `{self._java_exc} -version` was unexpected.") async def _wait_for_launcher(self) -> None: log.debug("Waiting for Lavalink server to be ready") @@ -202,7 +218,7 @@ class ServerManager: log.info("Internal Lavalink jar shutdown unexpectedly") if not self._has_java_error(): log.info("Restarting internal Lavalink server") - await self.start() + await self.start(self._java_exc) else: log.critical( "Your Java is borked. Please find the hs_err_pid%d.log file" @@ -228,7 +244,7 @@ class ServerManager: async def _download_jar(self) -> None: log.info("Downloading Lavalink.jar...") - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(json_serialize=json.dumps) as session: async with session.get(LAVALINK_DOWNLOAD_URL) as response: if response.status == 404: # A 404 means our LAVALINK_DOWNLOAD_URL is invalid, so likely the jar version diff --git a/redbot/cogs/audio/sql_statements.py b/redbot/cogs/audio/sql_statements.py index e37448d48..93d834d42 100644 --- a/redbot/cogs/audio/sql_statements.py +++ b/redbot/cogs/audio/sql_statements.py @@ -54,6 +54,15 @@ __all__ = [ "LAVALINK_QUERY_LAST_FETCHED_RANDOM", "LAVALINK_DELETE_OLD_ENTRIES", "LAVALINK_FETCH_ALL_ENTRIES_GLOBAL", + # Persisting Queue statements + "PERSIST_QUEUE_DROP_TABLE", + "PERSIST_QUEUE_CREATE_TABLE", + "PERSIST_QUEUE_CREATE_INDEX", + "PERSIST_QUEUE_PLAYED", + "PERSIST_QUEUE_DELETE_SCHEDULED", + "PERSIST_QUEUE_FETCH_ALL", + "PERSIST_QUEUE_UPSERT", + "PERSIST_QUEUE_BULK_PLAYED", ] # PRAGMA Statements @@ -555,3 +564,83 @@ LAVALINK_FETCH_ALL_ENTRIES_GLOBAL: Final[ SELECT query, data FROM lavalink """ + +# Persisting Queue statements +PERSIST_QUEUE_DROP_TABLE: Final[ + str +] = """ +DROP TABLE IF EXISTS persist_queue ; +""" +PERSIST_QUEUE_CREATE_TABLE: Final[ + str +] = """ +CREATE TABLE IF NOT EXISTS persist_queue( + guild_id INTEGER NOT NULL, + room_id INTEGER NOT NULL, + track JSON NOT NULL, + played BOOLEAN DEFAULT false, + track_id TEXT NOT NULL, + time INTEGER NOT NULL, + PRIMARY KEY (guild_id, room_id, track_id) +); +""" +PERSIST_QUEUE_CREATE_INDEX: Final[ + str +] = """ +CREATE INDEX IF NOT EXISTS track_index ON persist_queue (guild_id, track_id); +""" +PERSIST_QUEUE_PLAYED: Final[ + str +] = """ +UPDATE persist_queue + SET + played = true +WHERE + ( + guild_id = :guild_id + AND track_id = :track_id + ) +; +""" +PERSIST_QUEUE_BULK_PLAYED: Final[ + str +] = """ +UPDATE persist_queue + SET + played = true +WHERE guild_id = :guild_id +; +""" +PERSIST_QUEUE_DELETE_SCHEDULED: Final[ + str +] = """ +DELETE +FROM + persist_queue +WHERE + played = true; +""" +PERSIST_QUEUE_FETCH_ALL: Final[ + str +] = """ +SELECT + guild_id, room_id, track +FROM + persist_queue +WHERE played = false +ORDER BY time ASC; +""" +PERSIST_QUEUE_UPSERT: Final[ + str +] = """ +INSERT INTO + persist_queue (guild_id, room_id, track, played, track_id, time) +VALUES + ( + :guild_id, :room_id, :track, :played, :track_id, :time + ) +ON CONFLICT (guild_id, room_id, track_id) DO +UPDATE + SET + time = excluded.time +""" diff --git a/redbot/cogs/audio/utils.py b/redbot/cogs/audio/utils.py index f52bf7ddc..206d9383b 100644 --- a/redbot/cogs/audio/utils.py +++ b/redbot/cogs/audio/utils.py @@ -1,4 +1,8 @@ +import asyncio +import contextlib +import logging import time + from enum import Enum, unique from typing import MutableMapping @@ -6,6 +10,8 @@ import discord from redbot.core import commands +log = logging.getLogger("red.cogs.Audio.task.callback") + class CacheLevel: __slots__ = ("value",) @@ -205,3 +211,9 @@ class PlaylistScope(Enum): @staticmethod def list(): return list(map(lambda c: c.value, PlaylistScope)) + + +def task_callback(task: asyncio.Task) -> None: + with contextlib.suppress(asyncio.CancelledError, asyncio.InvalidStateError): + if exc := task.exception(): + log.exception(f"{task.get_name()} raised an Exception", exc_info=exc)